web-dev-qa-db-fra.com

Pourquoi cette boucle est-elle plus rapide qu'une compréhension de dictionnaire pour créer un dictionnaire?

Je ne viens pas d'une formation en informatique/logiciel mais j'aime coder en Python et je peux généralement comprendre pourquoi les choses sont plus rapides. Je suis vraiment curieux de savoir pourquoi cette boucle for s'exécute plus rapidement que la compréhension du dictionnaire.

Problème: Étant donné un dictionnaire a avec ces clés et valeurs, retourne un dictionnaire avec les valeurs en tant que clés et les clés en tant que valeurs. (défi: faites ceci en une ligne)

et le 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

Vous testez avec une entrée trop petite; Bien que la compréhension d'un dictionnaire présente moins d'avantages en termes de performances que la boucle d'une liste for, elle peut battre les boucles for, pour des problèmes de taille réalistes, en particulier lors du ciblage. un nom global.

Votre entrée consiste en seulement 3 paires clé-valeur. En testant avec 1000 éléments à la place, nous constatons que les timings sont très proches:

>>> 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

La différence est là, le dict comp est plus rapide mais seulement juste à cette échelle. Avec 100 fois plus de paires clé-valeur, la différence est un peu plus grande:

>>> 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

qui n'est pas si une grande différence lorsque vous considérez que les deux ont traité près de 100 000 paires clé-valeur. Néanmoins, la boucle for est clairement plus lente .

Alors pourquoi la différence de vitesse avec 3 éléments? Parce qu'une compréhension (dictionnaire, ensemble, compréhensions de liste ou expression génératrice) est implémentée sous la forme d'une nouvelle fonction , et l'appel de cette fonction a une base Coût la boucle simple n'a pas à payer.

Voici le désassemblage pour le bytecode pour les deux alternatives; Notez les opcodes MAKE_FUNCTION et CALL_FUNCTION dans le bytecode de niveau supérieur pour la compréhension du dict, il y a une section séparée pour ce que cette fonction fait alors, et il y a en fait très peu de différences entre les deux approches ici:

>>> 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

Les différences matérielles: le code en boucle utilise LOAD_NAME Pour b à chaque itération et STORE_SUBSCR Pour stocker la paire clé-valeur dans dict chargée. La compréhension du dictionnaire utilise MAP_ADD Pour obtenir la même chose que STORE_SUBSCR Mais ne doit pas charger ce nom b à chaque fois.

Mais avec seulement 3 itérations , le combo MAKE_FUNCTION/CALL_FUNCTION Que la compréhension du dict doit exécuter est le véritable glissement la performance:

>>> 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

Plus de 0,1 μs pour créer un objet fonction avec un argument et l'appeler (avec un supplément LOAD_CONST Pour la valeur None que nous transmettons)! Et c'est à peu près la différence entre les timings en boucle et de compréhension pour 3 paires clé-valeur.

Vous pouvez comparer cela à la surprise qu'un homme avec une pelle puisse creuser un petit trou plus rapidement qu'une pelle rétrocaveuse. La pelle rétro peut certainement creuser vite, mais un homme avec une pelle peut démarrer plus rapidement si vous avez besoin de démarrer la pelle rétro et de la placer en premier!

Au-delà de quelques paires clé-valeur (creuser un trou plus grand), la fonction crée et le coût des appels s’efface dans le néant. À ce stade, la compréhension du dict et la boucle explicite font fondamentalement la même chose:

  • prenez la paire clé-valeur suivante, insérez-les dans la pile
  • appelez le hook dict.__setitem__ via une opération de bytecode avec les deux premiers éléments de la pile (soit STORE_SUBSCR ou MAP_ADD. Ceci ne compte pas comme un "appel de fonction", tous gérés en interne dans la boucle d'interprétation.

Ceci est différent d’une compréhension de liste dans laquelle la version de boucle simple devrait utiliser list.append(), impliquant une recherche d’attribut et un appel de fonction à chaque itération de boucle . L'avantage de la vitesse de compréhension de la liste provient de cette différence; voir compréhension de la liste Python chère

Ce que la compréhension d'une dictée ajoute, c'est que le nom du dictionnaire cible n'a besoin d'être recherché qu'une seule fois, lors de la liaison de b à l'objet dictionnaire final. Si le dictionnaire cible est global au lieu d'une variable locale, la compréhension gagne, haut la main:

>>> 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

Utilisez donc simplement une compréhension dictée. La différence avec <30 éléments à traiter est trop petite pour que vous puissiez vous en soucier, et au moment où vous générez un élément global ou avez plus d'éléments, la compréhension dictée l'emporte quand même.

70
Martijn Pieters

Cette question, à certains égards, est assez similaire à Pourquoi une compréhension de liste est-elle tellement plus rapide que de l'ajouter à une liste? à laquelle j'ai déjà répondu il y a longtemps. Cependant, la raison pour laquelle ce comportement vous surprend est évidemment parce que votre dictionnaire est trop petit pour surmonter le coût de création d'un nouveau cadre de fonction et d'insertion/extraction dans la pile. Pour mieux comprendre cela, allons sous la peau des extraits de remorques que vous avez:

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

Au second code désassemblé (compréhension de dict), vous avez un MAKE_FUNCTION opcode qui, comme indiqué dans la documentation pousse un nouvel objet fonction sur la pile. et plus tard CALL_FUNCTION qui (Appelle un objet appelable avec des arguments de position. et ensuite:

supprime tous les arguments et l'objet appelable de la pile, appelle l'objet appelable avec ces arguments et envoie la valeur de retour renvoyée par l'objet appelable.

Toutes ces opérations ont des coûts, mais lorsque le dictionnaire s'agrandit, le coût de l'affectation des éléments de valeur clé au dictionnaire devient plus important que la création d'une fonction sous le capot. En d’autres termes, coût d’appel du __setitem__ la méthode du dictionnaire à partir d’un certain point dépassera le coût de la création et de la suspension à la volée d’un objet du dictionnaire.

Notez également qu’il existe certainement de nombreuses autres opérations (OP_CODES dans ce cas) qui jouent un rôle crucial dans ce jeu, ce qui mérite d’être approfondi et d’envisager ce que je vais vous faire vivre comme une pratique;).

16
Kasrâmvd