Python: dlaczego * i ** są szybsze niż / i sqrt ()?

80

Podczas optymalizacji kodu zdałem sobie sprawę z następujących kwestii:

>>> from timeit import Timer as T
>>> T(lambda : 1234567890 / 4.0).repeat()
[0.22256922721862793, 0.20560789108276367, 0.20530295372009277]
>>> from __future__ import division
>>> T(lambda : 1234567890 / 4).repeat()
[0.14969301223754883, 0.14155197143554688, 0.14141488075256348]
>>> T(lambda : 1234567890 * 0.25).repeat()
[0.13619112968444824, 0.1281130313873291, 0.12830305099487305]

i również:

>>> from math import sqrt
>>> T(lambda : sqrt(1234567890)).repeat()
[0.2597470283508301, 0.2498021125793457, 0.24994492530822754]
>>> T(lambda : 1234567890 ** 0.5).repeat()
[0.15409398078918457, 0.14059877395629883, 0.14049601554870605]

Zakładam, że ma to związek ze sposobem implementacji Pythona w C, ale zastanawiam się, czy ktoś miałby ochotę wyjaśnić, dlaczego tak jest?

prochowiec
źródło
Odpowiedź, którą zaakceptowałeś na swoje pytanie (która, jak zakładam, odpowiada na twoje prawdziwe pytanie) nie ma wiele wspólnego z tytułem pytania. Czy możesz go edytować, aby mieć coś wspólnego z ciągłym zwijaniem?
Zan Lynx,
1
@ZanLynx - Cześć. Czy mógłbyś to wyjaśnić? Uważam, że tytuł pytania dokładnie wyraża to, co chciałem wiedzieć (dlaczego X jest szybsze niż Y) i że wybrana przeze mnie odpowiedź właśnie to ... Wydaje mi się idealnie pasująca do mnie ... ale może coś przeoczę?
Mac
8
Funkcje mnożenia i potęgi są zawsze szybsze niż dzielenie i funkcje sqrt () ze względu na swoją naturę. Operacje dzielenia i pierwiastka generalnie muszą wykorzystywać serię dokładniejszych i dokładniejszych przybliżeń i nie mogą bezpośrednio prowadzić do właściwej odpowiedzi, tak jak może to być mnożenie.
Zan Lynx
Wydaje mi się, że tytuł pytania powinien coś mówić o tym, że wszystkie wartości są stałymi dosłownie, co okazuje się kluczem do odpowiedzi. Na typowym sprzęcie mnożenie i dodawanie / odejmowanie liczb całkowitych i FP jest tanie; integer i FP div oraz FP sqrt są drogie (być może 3x gorsze opóźnienie i 10x gorsza przepustowość niż FP mul). (Większość procesorów implementuje te operacje sprzętowo jako pojedyncze instrukcje asm, w przeciwieństwie do cube-root, pow () lub cokolwiek innego).
Peter Cordes
1
Ale nie zdziwiłbym się, gdyby narzut interpretera Pythona nadal przyćmiał różnicę między instrukcjami mul i div asm. Ciekawostka: na x86 dzielenie FP jest zwykle wydajniejsze niż dzielenie liczb całkowitych. ( agner.org/optimize ). 64-bitowe dzielenie całkowitoliczbowe w Intel Skylake ma opóźnienie 42-95 cykli, w porównaniu z 26 cyklami dla 32-bitowych liczb całkowitych, w porównaniu z 14 cyklami dla podwójnej precyzji FP. (64-bitowe mnożenie liczb całkowitych to opóźnienie 3 cykli, FP mul to 4). Różnice w przepustowości są jeszcze większe (int / FP mul i add to co najmniej jeden na zegar, ale dzielenie i sqrt nie są w pełni potokowe.)
Peter Cordes

Odpowiedzi:

114

(Dość nieoczekiwany) powód twoich wyników jest taki, że Python wydaje się składać stałe wyrażenia obejmujące mnożenie i potęgowanie zmiennoprzecinkowe, ale nie dzielenie. math.sqrt()jest zupełnie inną bestią, ponieważ nie ma dla niej kodu bajtowego i zawiera wywołanie funkcji.

W Pythonie 2.6.5 następujący kod:

x1 = 1234567890.0 / 4.0
x2 = 1234567890.0 * 0.25
x3 = 1234567890.0 ** 0.5
x4 = math.sqrt(1234567890.0)

kompiluje się do następujących kodów bajtowych:

  # x1 = 1234567890.0 / 4.0
  4           0 LOAD_CONST               1 (1234567890.0)
              3 LOAD_CONST               2 (4.0)
              6 BINARY_DIVIDE       
              7 STORE_FAST               0 (x1)

  # x2 = 1234567890.0 * 0.25
  5          10 LOAD_CONST               5 (308641972.5)
             13 STORE_FAST               1 (x2)

  # x3 = 1234567890.0 ** 0.5
  6          16 LOAD_CONST               6 (35136.418286444619)
             19 STORE_FAST               2 (x3)

  # x4 = math.sqrt(1234567890.0)
  7          22 LOAD_GLOBAL              0 (math)
             25 LOAD_ATTR                1 (sqrt)
             28 LOAD_CONST               1 (1234567890.0)
             31 CALL_FUNCTION            1
             34 STORE_FAST               3 (x4)

Jak widać, mnożenie i potęgowanie nie zajmuje dużo czasu, ponieważ są one wykonywane podczas kompilacji kodu. Dzielenie trwa dłużej, ponieważ dzieje się to w czasie wykonywania. Pierwiastek kwadratowy jest nie tylko najbardziej kosztowną obliczeniowo operacją z tych czterech, ale także wiąże się z różnymi kosztami narzutów, których nie mają inne (wyszukiwanie atrybutów, wywołanie funkcji itp.).

Jeśli wyeliminujesz efekt ciągłego zwijania, niewiele jest do oddzielenia mnożenia i dzielenia:

In [16]: x = 1234567890.0

In [17]: %timeit x / 4.0
10000000 loops, best of 3: 87.8 ns per loop

In [18]: %timeit x * 0.25
10000000 loops, best of 3: 91.6 ns per loop

math.sqrt(x)jest w rzeczywistości trochę szybszy niż x ** 0.5, prawdopodobnie dlatego, że jest to szczególny przypadek tego ostatniego i dlatego można go wykonać wydajniej, pomimo kosztów ogólnych:

In [19]: %timeit x ** 0.5
1000000 loops, best of 3: 211 ns per loop

In [20]: %timeit math.sqrt(x)
10000000 loops, best of 3: 181 ns per loop

edycja 2011-11-16: Zwijanie wyrażeń stałych jest wykonywane przez optymalizator wizjera Pythona. Kod źródłowy ( peephole.c) zawiera następujący komentarz wyjaśniający, dlaczego dzielenie stałe nie jest zawijane:

    case BINARY_DIVIDE:
        /* Cannot fold this operation statically since
           the result can depend on the run-time presence
           of the -Qnew flag */
        return 0;

-QnewFlaga umożliwia podział „prawdziwą” zdefiniowanego w PEP 238 .

NPE
źródło
2
Może to „ochrona” przed dzieleniem przez zero?
hugomg
2
@missingno: Nie jest dla mnie jasne, dlaczego taka „ochrona” byłaby konieczna, skoro oba argumenty są znane w czasie kompilacji, podobnie jak wynik (który jest jednym z: + inf, -inf, NaN).
NPE
13
Stałe zwijanie działa /w Pythonie 3, //aw Pythonie 2 i 3. Najprawdopodobniej jest to spowodowane faktem, że /w Pythonie 2 może mieć różne znaczenia. Może po wykonaniu stałego zwijania nie wiadomo jeszcze, czy from __future__ import divisionjest w efekcie?
interjay
4
@aix - 1./0.w Pythonie 2.7 nie powoduje, NaNale plik ZeroDivisionError.
beztrosko
2
@Caridorc: Python jest kompilowany do kodu bajtowego (pliki .pyc), który jest następnie interpretowany przez środowisko wykonawcze Pythona. Kod bajtowy to nie to samo, co kod asemblacyjny / maszynowy (co na przykład generuje kompilator C). Moduł dis może służyć do sprawdzania kodu bajtowego, do którego kompiluje się dany fragment kodu.
Tony Suffolk 66