2015-07-13 11 views
7

Próbuję zrozumieć, jak działa GIL programu CPython i jakie są różnice między GIL w CPython 2.7.x i CPython 3.4.x. Używam tego kodu do benchmarkingu:Dlaczego ten skrypt Pythona działa 4x wolniej na wielu rdzeniach niż na jednym rdzeniu

from __future__ import print_function 

import argparse 
import resource 
import sys 
import threading 
import time 


def countdown(n): 
    while n > 0: 
     n -= 1 


def get_time(): 
    stats = resource.getrusage(resource.RUSAGE_SELF) 
    total_cpu_time = stats.ru_utime + stats.ru_stime 
    return time.time(), total_cpu_time, stats.ru_utime, stats.ru_stime 


def get_time_diff(start_time, end_time): 
    return tuple((end-start) for start, end in zip(start_time, end_time)) 


def main(total_cycles, max_threads, no_headers=False): 
    header = ("%4s %8s %8s %8s %8s %8s %8s %8s %8s" % 
       ("#t", "seq_r", "seq_c", "seq_u", "seq_s", 
       "par_r", "par_c", "par_u", "par_s")) 
    row_format = ("%(threads)4d " 
        "%(seq_r)8.2f %(seq_c)8.2f %(seq_u)8.2f %(seq_s)8.2f " 
        "%(par_r)8.2f %(par_c)8.2f %(par_u)8.2f %(par_s)8.2f") 
    if not no_headers: 
     print(header) 
    for thread_count in range(1, max_threads+1): 
     # We don't care about a few lost cycles 
     cycles = total_cycles // thread_count 

     threads = [threading.Thread(target=countdown, args=(cycles,)) 
        for i in range(thread_count)] 

     start_time = get_time() 
     for thread in threads: 
      thread.start() 
      thread.join() 
     end_time = get_time() 
     sequential = get_time_diff(start_time, end_time) 

     threads = [threading.Thread(target=countdown, args=(cycles,)) 
        for i in range(thread_count)] 
     start_time = get_time() 
     for thread in threads: 
      thread.start() 
     for thread in threads: 
      thread.join() 
     end_time = get_time() 
     parallel = get_time_diff(start_time, end_time) 

     print(row_format % {"threads": thread_count, 
          "seq_r": sequential[0], 
          "seq_c": sequential[1], 
          "seq_u": sequential[2], 
          "seq_s": sequential[3], 
          "par_r": parallel[0], 
          "par_c": parallel[1], 
          "par_u": parallel[2], 
          "par_s": parallel[3]}) 


if __name__ == "__main__": 
    arg_parser = argparse.ArgumentParser() 
    arg_parser.add_argument("max_threads", nargs="?", 
          type=int, default=5) 
    arg_parser.add_argument("total_cycles", nargs="?", 
          type=int, default=50000000) 
    arg_parser.add_argument("--no-headers", 
          action="store_true") 
    args = arg_parser.parse_args() 
    sys.exit(main(args.total_cycles, args.max_threads, args.no_headers)) 

Po uruchomieniu tego skryptu na mój quad-core i5-2500 maszynie pod Ubuntu 14.04 z Python 2.7.6, mam następujące wyniki (_R podpórek czasie rzeczywistym, _C do czasu procesora, _U dla trybu użytkownika, _S dla trybu jądra):

#t seq_r seq_c seq_u seq_s par_r par_c par_u par_s 
    1  1.47  1.47  1.47  0.00  1.46  1.46  1.46  0.00 
    2  1.74  1.74  1.74  0.00  3.33  5.45  3.52  1.93 
    3  1.87  1.90  1.90  0.00  3.08  6.42  3.77  2.65 
    4  1.78  1.83  1.83  0.00  3.73  6.18  3.88  2.30 
    5  1.73  1.79  1.79  0.00  3.74  6.26  3.87  2.39 

teraz jeśli wiążę wszystkie wątki do jednego rdzenia, wyniki są bardzo różne:

taskset -c 0 python countdown.py 
    #t seq_r seq_c seq_u seq_s par_r par_c par_u par_s 
    1  1.46  1.46  1.46  0.00  1.46  1.46  1.46  0.00 
    2  1.74  1.74  1.73  0.00  1.69  1.68  1.68  0.00 
    3  1.47  1.47  1.47  0.00  1.58  1.58  1.54  0.04 
    4  1.74  1.74  1.74  0.00  2.02  2.02  1.87  0.15 
    5  1.46  1.46  1.46  0.00  1.91  1.90  1.75  0.15 

Więc pytanie jest : po co uruchamiać ten Python kod na wielu rdzeniach jest 1,5x-2x wolniejszy przez zegar ścienny i 4 x 5 razy wolniejszy w porównaniu z zegarem procesora, niż na pojedynczym rdzeniu?

wyjściowa wokół i googling produkowane dwie hipotezy:

  1. Podczas pracy na wielu rdzeniach, nitki mogą być ponownie zaplanowane na innym rdzeniu co oznacza, że ​​lokalna pamięć podręczna zostanie unieważniony, stąd spowolnienie.
  2. Koszt zawieszania nici na jednym rdzeniu i aktywowania go na innym rdzeniu jest większy niż zawieszanie i aktywowanie wątku na tym samym rdzeniu.

Czy są jakieś inne powody? Chciałbym zrozumieć, co się dzieje i być w stanie poprzeć moje zrozumienie liczbami (co oznacza, że ​​jeśli spowolnienie jest spowodowane brakami w pamięci podręcznej, chcę zobaczyć i porównać liczby dla obu przypadków).

+0

Tak, jestem świadomy GIL i nie dziwi mnie, że odliczanie w równoległych wątkach jest wolniejsze niż w pojedynczym wątku. Co mnie zaskakuje i czego nie rozumiem, to dlaczego uruchamianie tego skryptu na wielu rdzeniach jest znacznie wolniejsze niż uruchamianie go na jednym rdzeniu. – user108884

+0

Zauważyłem, że dodając czasy podane w pierwszej wersji (czyli bez zestawu zadań), suma nie odpowiadała czasowi podanemu przez 'time'. Jeśli 'time.clock()' zostanie zmieniony na 'time.time()', rozbieżność zniknie. Nadal wydaje się być niewielka przewaga przy korzystaniu z 'zestawu zadań' jednak nie wiem co to wszystko znaczy ... – brm

+0

Włącz * nix time.clock() zgłasza czas procesora, a nie czas zegara ściennego (https: // docs. python.org/2.7/library/time.html#time.clock). Tak więc wyniki powinny być interpretowane w ten sposób: potrzeba dużo więcej wysiłku, aby uruchomić ten kod na wielu rdzeniach niż na pojedynczym rdzeniu. Nie jestem pierwszym, który potknął się o te wyniki (np. Https://youtu.be/Obt-vMVdM8s?t=55s), ale nie jestem usatysfakcjonowany wyjaśnieniem. Ale masz rację, powinienem mierzyć i raportować również w czasie rzeczywistym. Zaktualizuję kod. – user108884

Odpowiedz

4

Jest to spowodowane odrzuceniem GIL, gdy wiele rodzimych wątków rywalizuje o GIL. Materiały Davida Beazleya na ten temat powiedzą wszystko, co chcesz wiedzieć.

Zapoznaj się z info here, aby uzyskać ładne przedstawienie graficzne tego, co się dzieje.

Python3.2 wprowadził zmiany w GIL, które pomagają rozwiązać ten problem, więc powinieneś zobaczyć lepszą wydajność w wersji 3.2 i późniejszych.

Należy również zauważyć, że GIL jest szczegółem implementacji referencyjnej implementacji języka cpython. Inne implementacje, takie jak Jython, nie mają GIL i nie cierpią z powodu tego szczególnego problemu. Pomocne będzie również

The rest of D. Beazley's info on the GIL.

Aby w konkretny sposób odpowiedzieć na pytanie, dlaczego wydajność jest znacznie gorsza, gdy zaangażowanych jest wiele rdzeni, zobacz slajd 29-41 prezentacji Inside the GIL. Prowadzi do szczegółowej dyskusji na temat wielowątkowej rywalizacji GIL w przeciwieństwie do wielu wątków na jednym rdzeniu. Slajd 32 wyraźnie pokazuje, że liczba wywołań systemowych z powodu narzutów sygnalizacji wątku przechodzi przez dach podczas dodawania rdzeni. Dzieje się tak dlatego, że wątki działają teraz symultanicznie na różnych rdzeniach i pozwalają im na udział w prawdziwej bitwie GIL. W przeciwieństwie do wielu wątków współużytkujących pojedynczy procesor. Dobre podsumowanie pocisk z powyższej prezentacji jest:

Z wielu rdzeni, nici CPU-bound uzyskać zaplanowane jednocześnie (na różnych rdzeni), a następnie mieć bitwę GIL.

+0

Tak, natknąłem się na to wyjaśnienie, kiedy po raz pierwszy zacząłem kopać w GIL. Dalsze wykopaliska ujawniły, że to wyjaśnienie nie wystarczy. Spójrz na Python-2.7.6/Modules/threadmodule.c: 603 static void t_bootstrap (void * boot_raw). Jest to funkcja, którą Python przechodzi do wątku systemu operacyjnego, gdy wątek Python jest uruchamiany (przez thread_PyThread_start_new_thread). Jeśli dobrze zrozumiałem Dave'a Beazley'a, jego punktem jest to, że wątki systemu operacyjnego wciąż działają i starają się zdobywać GIL przez cały czas - ale tak się nie dzieje. Wątek systemu operacyjnego zostaje zawieszony, gdy próbuje uzyskać GIL i nie zostaje przebudzony, dopóki GIL nie zostanie zwolniony. – user108884

+0

Uruchomiony wątek zwalnia GIL w przypadku potencjalnie blokowania operacji we/wy i każdego sys.getcheckinterval() "ticks". Tak więc w przypadku odliczania wątków OS bardzo często przełączane - stąd spowolnienie. Nie do końca rozumiem, dlaczego spowolnienie jest bardziej znaczące, jeśli wątki działają na wielu rdzeniach. – user108884

+0

Uważam, że informacja Beazleya odegrała gdzieś tę różnicę. Będę jednak musiał to później sprawdzić. W drodze do pracy już teraz. – mshildt

1

GIL zapobiega równoczesnemu uruchamianiu kilku wątków Pythona. Oznacza to, że gdy jeden wątek musi wykonać kod bajtowy Pythona (wewnętrzna reprezentacja kodu), uzyska on blokadę (skutecznie zatrzymując pozostałe wątki na innych rdzeniach). Aby to działało, procesor musi opróżnić wszystkie linie pamięci podręcznej. W przeciwnym razie aktywny wątek działałby na nieaktualnych danych.

Po uruchomieniu wątków na pojedynczym procesorze nie jest konieczne spłukiwanie pamięci podręcznej.

To powinno wyjaśniać większość spowolnienia. Jeśli chcesz uruchomić kod Pythona równolegle, musisz użyć procesów i IPC (gniazda, semafory, IO). Ale może to być powolne z różnych powodów (pamięć musi być kopiowana między procesami).

Innym podejściem jest przeniesienie więcej kodu do biblioteki C, która nie zatrzymuje GIL podczas jego wykonywania. Umożliwiłoby to równoległe uruchamianie więcej kodu.

+0

Czy jest jakiś sposób na * nix, aby poprzeć tę hipotezę (o pamięci podręcznej) liczbami? Czy możesz zasugerować jakieś dobre źródła, aby przeczytać więcej na ten temat? – user108884

+0

Równoległe lub równoległe? Kod gwintowany jest uważany za współbieżny nawet w systemach z pojedynczym procesorem. – progo

Powiązane problemy