Warum ist die frühe Rückkehr langsamer als sonst?

179

Dies ist eine Folgefrage zu einer Antwort, die ich vor ein paar Tagen gegeben habe . Bearbeiten: Es scheint, dass das OP dieser Frage bereits den Code verwendet hat, den ich an ihn gesendet habe, um dieselbe Frage zu stellen , aber ich war mir dessen nicht bewusst. Entschuldigung. Die Antworten sind jedoch unterschiedlich!

Im Wesentlichen habe ich Folgendes beobachtet:

>>> def without_else(param=False):
...     if param:
...         return 1
...     return 0
>>> def with_else(param=False):
...     if param:
...         return 1
...     else:
...         return 0
>>> from timeit import Timer as T
>>> T(lambda : without_else()).repeat()
[0.3011460304260254, 0.2866089344024658, 0.2871549129486084]
>>> T(lambda : with_else()).repeat()
[0.27536892890930176, 0.2693932056427002, 0.27011704444885254]
>>> T(lambda : without_else(True)).repeat()
[0.3383951187133789, 0.32756996154785156, 0.3279120922088623]
>>> T(lambda : with_else(True)).repeat()
[0.3305950164794922, 0.32186388969421387, 0.3209099769592285]

... oder mit anderen Worten: Die elseKlausel ist schneller, unabhängig davon, welche ifBedingung ausgelöst wird oder nicht.

Ich nehme an, es hat mit dem unterschiedlichen Bytecode zu tun, der von den beiden generiert wurde, aber kann jemand dies im Detail bestätigen / erklären?

BEARBEITEN: Es scheint, dass nicht jeder in der Lage ist, meine Timings zu reproduzieren. Daher hielt ich es für nützlich, einige Informationen zu meinem System anzugeben. Ich verwende Ubuntu 11.10 64-Bit mit dem installierten Standard-Python. pythongeneriert die folgenden Versionsinformationen:

Python 2.7.2+ (default, Oct  4 2011, 20:06:09) 
[GCC 4.6.1] on linux2

Hier sind die Ergebnisse der Demontage in Python 2.7:

>>> dis.dis(without_else)
  2           0 LOAD_FAST                0 (param)
              3 POP_JUMP_IF_FALSE       10

  3           6 LOAD_CONST               1 (1)
              9 RETURN_VALUE        

  4     >>   10 LOAD_CONST               2 (0)
             13 RETURN_VALUE        
>>> dis.dis(with_else)
  2           0 LOAD_FAST                0 (param)
              3 POP_JUMP_IF_FALSE       10

  3           6 LOAD_CONST               1 (1)
              9 RETURN_VALUE        

  5     >>   10 LOAD_CONST               2 (0)
             13 RETURN_VALUE        
             14 LOAD_CONST               0 (None)
             17 RETURN_VALUE        
Mac
quelle
1
Es gab eine identische Frage zu SO, die ich jetzt nicht finden kann. Sie überprüften den generierten Bytecode und es gab einen zusätzlichen Schritt. Die beobachteten Unterschiede waren sehr abhängig vom Tester (Maschine, SO ..) und fanden manchmal nur sehr sehr kleine Unterschiede.
Joaquin
3
Unter 3.x erzeugen beide identischen Bytecode, außer für einen nicht erreichbaren Code ( LOAD_CONST(None); RETURN_VALUE- aber wie gesagt, er wird nie erreicht) am Ende von with_else. Ich bezweifle sehr, dass toter Code eine Funktion schneller macht. Könnte jemand eine disauf 2.7 zur Verfügung stellen?
4
Ich konnte das nicht reproduzieren. Funktioniert mit elseund Falsewar am langsamsten von allen (152ns). Die zweitschnellste war Trueohne else(143 ns) und zwei andere waren im Grunde gleich (137 ns und 138 ns). Ich habe keinen Standardparameter verwendet und ihn %timeitin iPython gemessen .
25.
Ich kann diese Timings nicht reproduzieren, manchmal ist die with_else schneller, manchmal ist dies die without_else-Version, es sieht so aus, als wären sie mir ziemlich ähnlich ...
Cédric Julien
1
Ergebnisse der Demontage hinzugefügt. Ich verwende Ubuntu 11.10, 64-Bit, Standard Python 2.7 - gleiche Konfiguration wie @mac. Ich stimme auch zu, dass dies with_elsespürbar schneller ist.
Chris Morgan

Antworten:

387

Dies ist eine reine Vermutung, und ich habe keinen einfachen Weg gefunden, um zu überprüfen, ob es richtig ist, aber ich habe eine Theorie für Sie.

Ich habe Ihren Code ausprobiert und erhalte die gleichen Ergebnisse, without_else()ist immer wieder etwas langsamer als with_else():

>>> T(lambda : without_else()).repeat()
[0.42015745017874906, 0.3188967452567226, 0.31984281521812363]
>>> T(lambda : with_else()).repeat()
[0.36009842032996175, 0.28962249392031936, 0.2927151355828528]
>>> T(lambda : without_else(True)).repeat()
[0.31709728471076915, 0.3172671387005721, 0.3285821242644147]
>>> T(lambda : with_else(True)).repeat()
[0.30939889008243426, 0.3035132258429485, 0.3046679117038593]

Wenn man bedenkt, dass der Bytecode identisch ist, ist der einzige Unterschied der Name der Funktion. Insbesondere führt der Timing-Test eine Suche nach dem globalen Namen durch. Versuchen Sie es umzubenennen without_else()und der Unterschied verschwindet:

>>> def no_else(param=False):
    if param:
        return 1
    return 0

>>> T(lambda : no_else()).repeat()
[0.3359846013948413, 0.29025818923918223, 0.2921801513879245]
>>> T(lambda : no_else(True)).repeat()
[0.3810395594970828, 0.2969634408842694, 0.2960104566362247]

Ich vermute das without_else es eine Hash-Kollision mit etwas anderem gibt, globals()sodass die Suche nach globalen Namen etwas langsamer ist.

Bearbeiten : Ein Wörterbuch mit 7 oder 8 Schlüsseln hat wahrscheinlich 32 Steckplätze, daher hat es auf dieser Basis without_elseeine Hash-Kollision mit__builtins__ :

>>> [(k, hash(k) % 32) for k in globals().keys() ]
[('__builtins__', 8), ('with_else', 9), ('__package__', 15), ('without_else', 8), ('T', 21), ('__name__', 25), ('no_else', 28), ('__doc__', 29)]

Um zu verdeutlichen, wie das Hashing funktioniert:

__builtins__ Hashes auf -1196389688, wodurch die Tabellengröße (32) modulo reduziert wurde, bedeutet, dass sie im Steckplatz Nr. 8 der Tabelle gespeichert ist.

without_elseHashes auf 505688136, wodurch Modulo 32 auf 8 reduziert wurde, sodass es zu einer Kollision kommt. Um dies zu beheben, berechnet Python:

Beginnen mit:

j = hash % 32
perturb = hash

Wiederholen Sie diesen Vorgang, bis wir einen freien Platz gefunden haben:

j = (5*j) + 1 + perturb;
perturb >>= 5;
use j % 2**i as the next table index;

das gibt es 17 als nächsten Index zu verwenden. Zum Glück ist das kostenlos, so dass sich die Schleife nur einmal wiederholt. Die Größe der Hash-Tabelle ist eine Potenz von 2, ebenso 2**iwie die Größe der Hash-Tabelle idie Anzahl der aus dem Hash-Wert verwendeten Bits j.

Jede Sonde in der Tabelle kann eine davon finden:

  • Der Steckplatz ist leer. In diesem Fall stoppt die Prüfung und wir wissen, dass der Wert nicht in der Tabelle enthalten ist.

  • Der Slot wird nicht verwendet, wurde aber in der Vergangenheit verwendet. In diesem Fall versuchen wir es mit dem nächsten wie oben berechneten Wert.

  • Der Slot ist voll, aber der in der Tabelle gespeicherte vollständige Hashwert stimmt nicht mit dem Hash des gesuchten Schlüssels überein (dies geschieht im Fall von __builtins__vs without_else).

  • Der Slot ist voll und hat genau den gewünschten Hash-Wert. Dann prüft Python, ob der Schlüssel und das Objekt, nach dem wir suchen, dasselbe Objekt sind (in diesem Fall, weil kurze Zeichenfolgen, die Bezeichner sein könnten, so interniert sind identische Bezeichner verwenden genau dieselbe Zeichenfolge).

  • Wenn der Slot voll ist, stimmt der Hash genau überein, aber die Schlüssel sind nicht das gleiche Objekt. Dann und nur dann wird Python versuchen, sie auf Gleichheit zu vergleichen. Dies ist vergleichsweise langsam, sollte aber bei Namenssuchen eigentlich nicht passieren.

Duncan
quelle
9
@ Chris, nein, die Länge der Zeichenfolge sollte nicht signifikant sein. Wenn Sie zum ersten Mal eine Zeichenfolge hashen, dauert es proportional zur Zeichenfolgenlänge, aber dann wird der berechnete Hash im Zeichenfolgenobjekt zwischengespeichert, sodass nachfolgende Hashes O (1) sind.
Duncan
1
Ah ok, ich war mir des Cachings nicht bewusst, aber das macht Sinn
Chris Eberle
9
Faszinierend! Darf ich dich Sherlock nennen? ;) Wie auch immer, ich hoffe ich werde nicht vergessen, dir einige zusätzliche Punkte mit einem Kopfgeld zu geben, sobald die Frage berechtigt ist.
Voo
4
@mac, nicht ganz. Ich werde ein wenig über die Hash-Auflösung hinzufügen (wollte es in den Kommentar einpressen, aber es ist interessanter als ich dachte).
Duncan
6
@ Duncan - Vielen Dank, dass Sie sich die Zeit genommen haben, den Hash-Prozess zu veranschaulichen. Erstklassige Antwort! :)
Mac