web-dev-qa-db-de.com

Warum ist diese Schleife schneller als ein Wörterbuchverständnis zum Erstellen eines Wörterbuchs?

Ich komme nicht aus der Software/Informatik, aber ich liebe es, in Python zu programmieren und kann im Allgemeinen verstehen, warum die Dinge schneller sind. Ich bin wirklich neugierig zu wissen, warum diese for-Schleife schneller läuft als das Wörterbuch Verständnis. Irgendwelche Einsichten?

Problem: Wenn Sie ein Wörterbuch a mit diesen Schlüsseln und Werten angeben, geben Sie ein Wörterbuch mit den Werten als Schlüssel und den Schlüsseln als Werten zurück. (Herausforderung: mach das in einer Zeile)

und der Code

a = {'a':'hi','b':'hey','c':'yo'}

b = {}
for i,j in a.items():
    b[j]=i

%% timeit 932 ns ± 37.2 ns per loop

b = {v: k for k, v in a.items()}

%% timeit 1.08 µs ± 16.4 ns per loop
39
Nadim Younes

Sie testen mit einer viel zu kleinen Eingabe. Während ein Wörterbuchverständnis im Vergleich zu einem Listenverständnis weniger Leistungsvorteile gegenüber einer for -Schleife aufweist, kann und kann es bei realistischen Problemgrößen for -Schleifen übertreffen, insbesondere beim Targeting ein globaler Name.

Ihre Eingabe besteht aus nur 3 Schlüsselwertpaaren. Beim Testen mit 1000 Elementen stellen wir stattdessen fest, dass die Timings sehr nahe beieinander liegen:

>>> import timeit
>>> from random import choice, randint; from string import ascii_lowercase as letters
>>> looped = '''\
... b = {}
... for i,j in a.items():
...     b[j]=i
... '''
>>> dictcomp = '''b = {v: k for k, v in a.items()}'''
>>> def rs(): return ''.join([choice(letters) for _ in range(randint(3, 15))])
...
>>> a = {rs(): rs() for _ in range(1000)}
>>> len(a)
1000
>>> count, total = timeit.Timer(looped, 'from __main__ import a').autorange()
>>> (total / count) * 1000000   # microseconds per run
66.62004760000855
>>> count, total = timeit.Timer(dictcomp, 'from __main__ import a').autorange()
>>> (total / count) * 1000000   # microseconds per run
64.5464928005822

Der Unterschied besteht darin, dass der Befehl comp schneller ist, aber nurjustin dieser Größenordnung. Mit 100-mal so vielen Schlüsselwertpaaren ist der Unterschied etwas größer:

>>> a = {rs(): rs() for _ in range(100000)}
>>> len(a)
98476
>>> count, total = timeit.Timer(looped, 'from __main__ import a').autorange()
>>> total / count * 1000  # milliseconds, different scale!
15.48140200029593
>>> count, total = timeit.Timer(dictcomp, 'from __main__ import a').autorange()
>>> total / count * 1000  # milliseconds, different scale!
13.674790799996117

das ist nichtdassein großer Unterschied, wenn man bedenkt, dass beide beinahe 100.000 Schlüssel-Wert-Paare verarbeitet haben. Trotzdem ist die for -Schleife eindeutiglangsamer.

Warum also der Geschwindigkeitsunterschied mit 3 Elementen? Da ein Verständnis (Wörterbuch, Menge, Listenverständnis oder ein Generatorausdruck) unter der Haube als neuesfunctionimplementiert ist und der Aufruf dieser Funktion die einfache Schleife kostet muss nicht bezahlen.

Hier ist die Zerlegung des Bytecodes für beide Alternativen. Beachten Sie die Opcodes MAKE_FUNCTION und CALL_FUNCTION im Bytecode der obersten Ebene für das Diktierverständnis. Es gibt einen separaten Abschnitt für die Aufgaben dieser Funktion, und es gibt tatsächlich nur sehr wenige Unterschiede zwischen den beiden Ansätze hier:

>>> import dis
>>> dis.dis(looped)
  1           0 BUILD_MAP                0
              2 STORE_NAME               0 (b)

  2           4 SETUP_LOOP              28 (to 34)
              6 LOAD_NAME                1 (a)
              8 LOAD_METHOD              2 (items)
             10 CALL_METHOD              0
             12 GET_ITER
        >>   14 FOR_ITER                16 (to 32)
             16 UNPACK_SEQUENCE          2
             18 STORE_NAME               3 (i)
             20 STORE_NAME               4 (j)

  3          22 LOAD_NAME                3 (i)
             24 LOAD_NAME                0 (b)
             26 LOAD_NAME                4 (j)
             28 STORE_SUBSCR
             30 JUMP_ABSOLUTE           14
        >>   32 POP_BLOCK
        >>   34 LOAD_CONST               0 (None)
             36 RETURN_VALUE
>>> dis.dis(dictcomp)
  1           0 LOAD_CONST               0 (<code object <dictcomp> at 0x11d6ade40, file "<dis>", line 1>)
              2 LOAD_CONST               1 ('<dictcomp>')
              4 MAKE_FUNCTION            0
              6 LOAD_NAME                0 (a)
              8 LOAD_METHOD              1 (items)
             10 CALL_METHOD              0
             12 GET_ITER
             14 CALL_FUNCTION            1
             16 STORE_NAME               2 (b)
             18 LOAD_CONST               2 (None)
             20 RETURN_VALUE

Disassembly of <code object <dictcomp> at 0x11d6ade40, file "<dis>", line 1>:
  1           0 BUILD_MAP                0
              2 LOAD_FAST                0 (.0)
        >>    4 FOR_ITER                14 (to 20)
              6 UNPACK_SEQUENCE          2
              8 STORE_FAST               1 (k)
             10 STORE_FAST               2 (v)
             12 LOAD_FAST                1 (k)
             14 LOAD_FAST                2 (v)
             16 MAP_ADD                  2
             18 JUMP_ABSOLUTE            4
        >>   20 RETURN_VALUE

Die wesentlichen Unterschiede: Der geloopte Code verwendet für jede Iteration LOAD_NAME Für b und STORE_SUBSCR, Um das Schlüssel-Wert-Paar im geladenen Wörterbuch zu speichern. Das Wörterbuch-Verständnis verwendet MAP_ADD, Um das Gleiche wie STORE_SUBSCR Zu erreichen, muss jedoch nicht jedes Mal diesen b -Namen laden.

Aber nur mit3 Iterationenist die Kombination MAKE_FUNCTION/CALL_FUNCTION, Die das Dikt-Verständnis ausführen muss, der wirkliche Nachteil für die Leistung:

>>> make_and_call = '(lambda i: None)(None)'
>>> dis.dis(make_and_call)
  1           0 LOAD_CONST               0 (<code object <lambda> at 0x11d6ab270, file "<dis>", line 1>)
              2 LOAD_CONST               1 ('<lambda>')
              4 MAKE_FUNCTION            0
              6 LOAD_CONST               2 (None)
              8 CALL_FUNCTION            1
             10 RETURN_VALUE

Disassembly of <code object <lambda> at 0x11d6ab270, file "<dis>", line 1>:
  1           0 LOAD_CONST               0 (None)
              2 RETURN_VALUE
>>> count, total = timeit.Timer(make_and_call).autorange()
>>> total / count * 1000000
0.12945385499915574

Mehr als 0,1 μs, um ein Funktionsobjekt mit einem Argument zu erstellen und es aufzurufen (mit einem zusätzlichen LOAD_CONST Für den None -Wert, den wir übergeben)! Und das ist nur der Unterschied zwischen dem Loop- und dem Verständnis-Timing für 3 Schlüssel-Wert-Paare.

Man kann das damit vergleichen, dass man überrascht ist, dass ein Mann mit einer Schaufel ein kleines Loch schneller graben kann als ein Bagger. Der Bagger kann zwar schnell graben, aber ein Mann mit einer Schaufel kann schneller anfangen, wenn Sie den Bagger erst starten und in Position bringen müssen!

Abgesehen von ein paar Schlüssel-Wert-Paaren (ein größeres Loch graben) verschwindet die Funktion zum Erstellen und Aufrufen von Kosten ins Nichts. An dieser Stelle machen das Diktierverständnis und die explizite Schleifeim Grunde genommendasselbe:

  • nehmen Sie das nächste Schlüssel-Wert-Paar und platzieren Sie diese auf dem Stapel
  • rufen Sie den Hook dict.__setitem__ über eine Bytecode-Operation mit den beiden obersten Elementen auf dem Stapel auf (entweder STORE_SUBSCR oder MAP_ADD. Dies gilt nicht als Funktionsaufruf, da es sich um einen solchen handelt alle intern in der Interpreter-Schleife behandelt.

Dies unterscheidet sich von einem Listenverständnis, bei dem die einfache Schleifenversion list.append() verwenden müsste, wobei eine Attributsuche und ein Funktionsaufrufjede Schleifeniteration. Der Geschwindigkeitsvorteil für das Listenverständnis ergibt sich aus diesem Unterschied. siehe Pythonlistenverständnis teuer

Was ein diktiertes Verständnis hinzufügt, ist, dass der Name des Zielwörterbuchs nur einmal nachgeschlagen werden muss, wenn b an das endgültige Wörterbuchobjekt gebunden wird. Wenn das Zielwörterbuch einglobalanstelle einer lokalen Variablen ist, gewinnt das Verständnis zweifellos:

>>> a = {rs(): rs() for _ in range(1000)}
>>> len(a)
1000
>>> namespace = {}
>>> count, total = timeit.Timer(looped, 'from __main__ import a; global b', globals=namespace).autorange()
>>> (total / count) * 1000000
76.72348440100905
>>> count, total = timeit.Timer(dictcomp, 'from __main__ import a; global b', globals=namespace).autorange()
>>> (total / count) * 1000000
64.72114819916897
>>> len(namespace['b'])
1000

Verwenden Sie also einfach ein Diktierverständnis. Der Unterschied zwischen <30 zu verarbeitenden Elementen ist zu gering, um sich darum zu kümmern, und sobald Sie ein globales Element generieren oder mehr Elemente haben, gewinnt das Diktatverständnis trotzdem an Bedeutung.

70
Martijn Pieters

Diese Frage ähnelt in gewisser Hinsicht der Frage Warum ist ein Listenverständnis so viel schneller als das Anhängen an eine Liste? die ich vor langer Zeit beantwortet habe. Der Grund dafür, dass dieses Verhalten für Sie überraschend ist, ist offensichtlich, dass Ihr Wörterbuch viel zu klein ist, um die Kosten für das Erstellen eines neuen Funktionsrahmens und das Verschieben/Ziehen im Stapel zu übersteigen. Um das besser zu verstehen, gehen wir unter die Haut der Schleppschnipsel, die Sie haben:

In [1]: a = {'a':'hi','b':'hey','c':'yo'}
   ...: 
   ...: def reg_loop(a):
   ...:     b = {}
   ...:     for i,j in a.items():
   ...:         b[j]=i
   ...:         

In [2]: def dict_comp(a):
   ...:     b = {v: k for k, v in a.items()}
   ...:     

In [3]: 

In [3]: %timeit reg_loop(a)
529 ns ± 7.89 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

In [4]: 

In [4]: %timeit dict_comp(a)
656 ns ± 5.39 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

In [5]: 

In [5]: import dis

In [6]: dis.dis(reg_loop)
  4           0 BUILD_MAP                0
              2 STORE_FAST               1 (b)

  5           4 SETUP_LOOP              28 (to 34)
              6 LOAD_FAST                0 (a)
              8 LOAD_METHOD              0 (items)
             10 CALL_METHOD              0
             12 GET_ITER
        >>   14 FOR_ITER                16 (to 32)
             16 UNPACK_SEQUENCE          2
             18 STORE_FAST               2 (i)
             20 STORE_FAST               3 (j)

  6          22 LOAD_FAST                2 (i)
             24 LOAD_FAST                1 (b)
             26 LOAD_FAST                3 (j)
             28 STORE_SUBSCR
             30 JUMP_ABSOLUTE           14
        >>   32 POP_BLOCK
        >>   34 LOAD_CONST               0 (None)
             36 RETURN_VALUE

In [7]: 

In [7]: dis.dis(dict_comp)
  2           0 LOAD_CONST               1 (<code object <dictcomp> at 0x7fbada1adf60, file "<ipython-input-2-aac022159794>", line 2>)
              2 LOAD_CONST               2 ('dict_comp.<locals>.<dictcomp>')
              4 MAKE_FUNCTION            0
              6 LOAD_FAST                0 (a)
              8 LOAD_METHOD              0 (items)
             10 CALL_METHOD              0
             12 GET_ITER
             14 CALL_FUNCTION            1
             16 STORE_FAST               1 (b)
             18 LOAD_CONST               0 (None)
             20 RETURN_VALUE

Beim zweiten zerlegten Code (dikt. Verstehen) haben Sie ein MAKE_FUNCTION Opcode der, wie auch in der Dokumentation angegeben, ein neues Funktionsobjekt auf den Stack schiebt. und später CALL_FUNCTION which Ruft ein aufrufbares Objekt mit Positionsargumenten auf. und dann:

entfernt alle Argumente und das aufrufbare Objekt vom Stapel, ruft das aufrufbare Objekt mit diesen Argumenten auf und gibt den vom aufrufbaren Objekt zurückgegebenen Rückgabewert aus.

Alle diese Operationen haben ihre Kosten, aber wenn das Wörterbuch größer wird, werden die Kosten für die Zuweisung der Schlüsselwertelemente zum Wörterbuch größer als für die Erstellung einer Funktion unter der Haube. Mit anderen Worten, die Kosten für den Aufruf des __setitem__ Die Methode des Wörterbuchs ab einem bestimmten Punkt übersteigt die Kosten für das Erstellen und Anhalten eines Wörterbuchobjekts im laufenden Betrieb.

Beachten Sie auch, dass es mit Sicherheit mehrere andere Operationen gibt (in diesem Fall OP_CODES), die eine entscheidende Rolle in diesem Spiel spielen. Ich denke, es lohnt sich, sie zu untersuchen und zu überlegen, ob ich sie Ihnen als Übung vorstellen werde.

16
Kasrâmvd