Czy „x <y <z” jest szybsze niż „x <y i y <z”?

129

Z tej strony wiemy, że:

Porównania łańcuchowe są szybsze niż korzystanie z andoperatora. Pisz x < y < zzamiast x < y and y < z.

Jednak otrzymałem inny wynik testowania następujących fragmentów kodu:

$ python -m timeit "x = 1.2" "y = 1.3" "z = 1.8" "x < y < z"
1000000 loops, best of 3: 0.322 usec per loop
$ python -m timeit "x = 1.2" "y = 1.3" "z = 1.8" "x < y and y < z"
1000000 loops, best of 3: 0.22 usec per loop
$ python -m timeit "x = 1.2" "y = 1.3" "z = 1.1" "x < y < z"
1000000 loops, best of 3: 0.279 usec per loop
$ python -m timeit "x = 1.2" "y = 1.3" "z = 1.1" "x < y and y < z"
1000000 loops, best of 3: 0.215 usec per loop

Wydaje się, że x < y and y < zjest szybszy niż x < y < z. Czemu?

Po przeszukaniu niektórych postów na tej stronie (takich jak ten ) wiem, że kluczem jest „ocenione tylko raz” x < y < z, ale nadal jestem zdezorientowany. Aby przeprowadzić dalsze badania, zdemontowałem te dwie funkcje za pomocą dis.dis:

import dis

def chained_compare():
        x = 1.2
        y = 1.3
        z = 1.1
        x < y < z

def and_compare():
        x = 1.2
        y = 1.3
        z = 1.1
        x < y and y < z

dis.dis(chained_compare)
dis.dis(and_compare)

A wynik to:

## chained_compare ##

  4           0 LOAD_CONST               1 (1.2)
              3 STORE_FAST               0 (x)

  5           6 LOAD_CONST               2 (1.3)
              9 STORE_FAST               1 (y)

  6          12 LOAD_CONST               3 (1.1)
             15 STORE_FAST               2 (z)

  7          18 LOAD_FAST                0 (x)
             21 LOAD_FAST                1 (y)
             24 DUP_TOP
             25 ROT_THREE
             26 COMPARE_OP               0 (<)
             29 JUMP_IF_FALSE_OR_POP    41
             32 LOAD_FAST                2 (z)
             35 COMPARE_OP               0 (<)
             38 JUMP_FORWARD             2 (to 43)
        >>   41 ROT_TWO
             42 POP_TOP
        >>   43 POP_TOP
             44 LOAD_CONST               0 (None)
             47 RETURN_VALUE

## and_compare ##

 10           0 LOAD_CONST               1 (1.2)
              3 STORE_FAST               0 (x)

 11           6 LOAD_CONST               2 (1.3)
              9 STORE_FAST               1 (y)

 12          12 LOAD_CONST               3 (1.1)
             15 STORE_FAST               2 (z)

 13          18 LOAD_FAST                0 (x)
             21 LOAD_FAST                1 (y)
             24 COMPARE_OP               0 (<)
             27 JUMP_IF_FALSE_OR_POP    39
             30 LOAD_FAST                1 (y)
             33 LOAD_FAST                2 (z)
             36 COMPARE_OP               0 (<)
        >>   39 POP_TOP
             40 LOAD_CONST               0 (None)

Wygląda na to, że x < y and y < zma mniej rozproszonych poleceń niż x < y < z. Powinienem rozważyć x < y and y < zszybciej niż x < y < z?

Przetestowano w języku Python 2.7.6 na procesorze Intel (R) Xeon (R) E5640 @ 2,67 GHz.

zangw
źródło
8
Więcej rozłączonych poleceń nie oznacza większej złożoności ani wolniejszego kodu. Jednak widząc twoje timeittesty, zaciekawiło mnie to.
Marco Bonelli
6
Założyłem, że różnica w szybkości w porównaniu z „ocenianą raz” pojawia się, gdy ynie jest to tylko wyszukiwanie zmiennych, ale droższy proces, taki jak wywołanie funkcji? To 10 < max(range(100)) < 15znaczy jest szybsze niż 10 < max(range(100)) and max(range(100)) < 15ponieważ max(range(100))jest wywoływane raz dla obu porównań.
zehnpaard
2
@MarcoBonelli Dzieje się tak, gdy zdemontowany kod 1) nie zawiera pętli i 2) każdy kod bajtowy jest bardzo szybki, ponieważ w tym momencie narzut pętli głównej staje się znaczący.
Bakuriu
2
Przewidywanie gałęzi może zepsuć twoje testy.
Corey Ogburn
2
@zehnpaard Zgadzam się z tobą. Gdy „y” jest czymś więcej niż zwykłą wartością (np. Wywołanie funkcji lub obliczenie), spodziewałbym się, że fakt, że „y” jest obliczany raz w x <y <z, będzie miał znacznie bardziej zauważalny wpływ. W przedstawionym przypadku jesteśmy w obrębie słupków błędów: dominują efekty (nieudanego) przewidywania rozgałęzień, mniej niż optymalny kod bajtowy i inne efekty specyficzne dla platformy / procesora. Nie unieważnia to ogólnej zasady, że jednorazowa ocena jest lepsza (a także bardziej czytelna), ale pokazuje, że może to nie dodać tak dużej wartości w ekstremach.
MartyMacGyver

Odpowiedzi:

111

Różnica polega na tym, że in x < y < z yjest oceniane tylko raz. Nie robi to dużej różnicy, jeśli y jest zmienną, ale robi to, gdy jest to wywołanie funkcji, której obliczenie zajmuje trochę czasu.

from time import sleep
def y():
    sleep(.2)
    return 1.3
%timeit 1.2 < y() < 1.8
10 loops, best of 3: 203 ms per loop
%timeit 1.2 < y() and y() < 1.8
1 loops, best of 3: 405 ms per loop
Obrabować
źródło
18
Oczywiście może również istnieć różnica semantyczna. Nie tylko może y () zwrócić dwie różne wartości, ale ze zmienną ocena operatora mniej niż w x <y może zmienić wartość y. Dlatego często nie jest zoptymalizowany w kodzie bajtowym (jeśli na przykład y jest zmienną nielokalną lub bierze udział w zamknięciu)
Random832
Ciekawe, dlaczego potrzebujesz funkcji sleep()wewnętrznej?
Prof
@Prof To jest symulacja funkcji, której obliczenie wyniku zajmuje trochę czasu. Jeśli funkcje zwrócą się natychmiast, nie będzie dużej różnicy między dwoma wynikami timeit.
Rob
@Rob Dlaczego nie byłoby dużej różnicy? Byłoby to 3 ms vs 205 ms, co dobrze to pokazuje, prawda?
Prof
@Prof Brakuje Ci punktu, w którym y () jest obliczane dwukrotnie, czyli 2x200ms zamiast 1x200ms. Reszta (3/5 ms) to nieistotny szum w pomiarze czasu.
Rob
22

Optymalny kod bajtowy dla obu zdefiniowanych funkcji to

          0 LOAD_CONST               0 (None)
          3 RETURN_VALUE

ponieważ wynik porównania nie jest używany. Uatrakcyjnijmy sytuację, zwracając wynik porównania. Niech wynik będzie niemożliwy do poznania w czasie kompilacji.

def interesting_compare(y):
    x = 1.1
    z = 1.3
    return x < y < z  # or: x < y and y < z

Ponownie, obie wersje porównania są semantycznie identyczne, więc optymalny kod bajtowy jest taki sam dla obu konstrukcji. Najlepiej, jak potrafię, wyglądałoby to tak. Dodałem adnotacje do każdego wiersza z zawartością stosu przed i po każdym opkodzie, w notacji Forth (wierzchołek stosu po prawej stronie, --dzieli przed i po, koniec ?oznacza coś, co może tam być lub nie). Zauważ, że RETURN_VALUEodrzuca wszystko, co pozostało na stosie poniżej zwróconej wartości.

          0 LOAD_FAST                0 (y)    ;          -- y
          3 DUP_TOP                           ; y        -- y y
          4 LOAD_CONST               0 (1.1)  ; y y      -- y y 1.1
          7 COMPARE_OP               4 (>)    ; y y 1.1  -- y pred
         10 JUMP_IF_FALSE_OR_POP     19       ; y pred   -- y
         13 LOAD_CONST               1 (1.3)  ; y        -- y 1.3
         16 COMPARE_OP               0 (<)    ; y 1.3    -- pred
     >>  19 RETURN_VALUE                      ; y? pred  --

Jeśli implementacja języka, CPython, PyPy, cokolwiek, nie generuje tego kodu bajtowego (lub własnej równoważnej sekwencji operacji) dla obu odmian, świadczy to o słabej jakości tego kompilatora kodu bajtowego . Uzyskanie z sekwencji kodu bajtowego, który wysłałeś na powyższe, jest rozwiązanym problemem (myślę, że wszystko, czego potrzebujesz w tym przypadku, to ciągłe zwijanie , eliminacja martwego kodu i lepsze modelowanie zawartości stosu; powszechna eliminacja podwyrażeń również byłaby tania i wartościowa ), i naprawdę nie ma wymówki, by nie robić tego w nowoczesnej implementacji języka.

Teraz zdarza się, że wszystkie obecne implementacje języka mają słabej jakości kompilatory kodu bajtowego. Ale powinieneś zignorować to podczas kodowania! Udawaj, że kompilator kodu bajtowego jest dobry i napisz najbardziej czytelny kod. Prawdopodobnie i tak będzie wystarczająco szybko. Jeśli tak nie jest, najpierw poszukaj ulepszeń algorytmicznych, a następnie spróbuj Cython - to zapewni znacznie większą poprawę przy tym samym wysiłku niż jakiekolwiek poprawki na poziomie wyrażenia, które możesz zastosować.

zwol
źródło
Cóż, najważniejsza ze wszystkich optymalizacji musiałaby być możliwa w pierwszej kolejności: inlining. I to nie jest „rozwiązany problem” dla języków dynamicznych, które pozwalają na dynamiczną zmianę implementacji funkcji (choć wykonalne - HotSpot może robić podobne rzeczy i rzeczy takie jak Graal pracują nad udostępnieniem tego rodzaju optymalizacji dla Pythona i innych dynamicznych języków ). A ponieważ sama funkcja może być wywoływana z różnych modułów (lub wywołanie może być generowane dynamicznie!), Naprawdę nie można tam dokonać tych optymalizacji.
Voo
1
@Voo Mój ręcznie zoptymalizowany kod bajtowy ma dokładnie taką samą semantykę jak oryginał, nawet w obecności dowolnego dynamizmu (jeden wyjątek: zakładany jest a <b ≡ b> a). Również inlining jest przereklamowany. Jeśli zrobisz tego za dużo - a zbyt łatwo jest to zrobić za dużo - wysadzasz I-cache i tracisz wszystko, co zdobyłeś, a potem trochę.
zwolnij
Masz rację, myślałem, że chodziło Ci o to, aby zoptymalizować swój interesting_comparekod do prostego kodu bajtowego na górze (który działałby tylko z wstawianiem). Całkowicie offtopic, ale: Inlining jest jedną z najważniejszych optymalizacji każdego kompilatora. Możesz spróbować uruchomić niektóre testy porównawcze za pomocą, powiedzmy, HotSpot na prawdziwych programach (nie na niektórych testach matematycznych, które spędzają 99% czasu w jednej gorącej pętli, która jest zoptymalizowana ręcznie [i dlatego nie ma już żadnych wywołań funkcji]), kiedy wyłączasz możliwość wstawiania czegokolwiek - zobaczysz duże regresje.
Voo
@Voo Tak, prosty kod bajtowy na górze miał być "optymalną wersją" oryginalnego kodu OP, nie interesting_compare.
zwolnij
„jeden wyjątek: zakłada się a <b ≡ b> a” → co po prostu nie jest prawdą w Pythonie. Ponadto myślę, że CPython nie może nawet naprawdę założyć, że operacje na ynie zmieniają stosu, ponieważ ma wiele narzędzi do debugowania.
Veedrac,
8

Ponieważ różnica w wynikach wydaje się wynikać z braku optymalizacji, myślę, że w większości przypadków należy zignorować tę różnicę - może się zdarzyć, że różnica zniknie. Różnica polega na tym, że ypowinien być oceniany tylko raz, a to rozwiązuje się, powielając go na stosie, co wymaga dodatkowej opłaty POP_TOP- rozwiązanie do użycia LOAD_FASTmoże być jednak możliwe.

Istotna różnica polega jednak na tym, że w x<y and y<zdrugim yprzypadku należy ocenić dwukrotnie, jeśli x<yocena jest prawdą, ma to konsekwencje, jeśli ocena yzajmuje dużo czasu lub ma skutki uboczne.

W większości scenariuszy powinieneś używać, x<y<zmimo że jest nieco wolniejszy.

Król nieba
źródło
6

Po pierwsze, twoje porównanie jest prawie bez znaczenia, ponieważ dwie różne konstrukcje nie zostały wprowadzone w celu zapewnienia poprawy wydajności, więc nie powinieneś na tej podstawie decydować, czy użyć jednej zamiast drugiej.

x < y < zKonstrukt:

  1. Jest jaśniejsze i bardziej bezpośrednie w swoim znaczeniu.
  2. Jej semantyka jest to, czego można oczekiwać od „znaczenia” matematycznego porównania: oszacowania możliwych x, yi z raz i sprawdzić, czy cały warunek jest spełniony. Użycie andzmienia semantykę, oceniając ywiele razy, co może zmienić wynik .

Więc wybierz jedną zamiast drugiej w zależności od wybranej semantyki i, jeśli są równoważne, czy jedna jest bardziej czytelna od drugiej.

To powiedziawszy: bardziej zdemontowany kod nie oznacza wolniejszego kodu. Jednak wykonywanie większej liczby operacji na kodzie bajtowym oznacza, że ​​każda operacja jest prostsza, a mimo to wymaga iteracji głównej pętli. Oznacza to, że jeśli operacje, które wykonujesz, są niezwykle szybkie (np. Wyszukiwanie lokalnych zmiennych tak jak tam robisz), wtedy obciążenie związane z wykonywaniem większej liczby operacji na kodzie bajtowym może mieć znaczenie.

Ale uwaga, że wynik ten ma nie trzymać w sytuacji bardziej ogólny jedynie do „najgorszego przypadku”, które stało się profil. Jak zauważyli inni, jeśli zmienisz ycoś na coś, co zajmuje nawet trochę więcej czasu, zobaczysz, że wyniki się zmieniają, ponieważ notacja łańcuchowa ocenia to tylko raz.

Zreasumowanie:

  • Rozważ semantykę przed wykonaniem.
  • Weź pod uwagę czytelność.
  • Nie ufaj mikro benchmarkom. Zawsze profiluj z różnymi rodzajami parametrów, aby zobaczyć, jak czas funkcji / wyrażenia zachowuje się w stosunku do wspomnianych parametrów i zastanowić się, jak planujesz go użyć.
Bakuriu
źródło
5
Myślę, że twoja odpowiedź nie obejmuje prostego i istotnego faktu, że cytowana strona, w konkretnym przypadku pytania - porównywanie float, jest po prostu błędna. Łańcuchowe porównanie nie jest szybsze, co widać w obu pomiarach i wygenerowanym kodzie bajtowym.
pvg
30
Odpowiedź na pytanie z tagiem „może nie powinieneś tak dużo myśleć o wydajności” nie wydaje mi się przydatna. Robisz potencjalnie protekcjonalne założenia dotyczące zrozumienia przez pytającego ogólnych zasad programowania, a następnie mówisz głównie o nich zamiast o bieżącym problemie.
Ben Millwood
@Veerdac źle czytasz komentarz. Proponowana optymalizacja w oryginalnym dokumencie, na którym opierał się PO, jest błędna, przynajmniej w przypadku zmiennoprzecinkowych. To nie jest szybsze.
pvg