web-dev-qa-db-fra.com

Pourquoi le code utilisant des variables intermédiaires est-il plus rapide que le code sans?

J'ai rencontré ce comportement étrange et je n'ai pas réussi à l'expliquer. Ce sont les repères:

py -3 -m timeit "Tuple(range(2000)) == Tuple(range(2000))"
10000 loops, best of 3: 97.7 usec per loop
py -3 -m timeit "a = Tuple(range(2000));  b = Tuple(range(2000)); a==b"
10000 loops, best of 3: 70.7 usec per loop

Comment se fait-il que la comparaison avec l'affectation des variables soit plus rapide que l'utilisation d'une ligne avec des variables temporaires de plus de 27%?

Par les documents Python, la récupération de place est désactivée pendant le temps donc il ne peut pas en être ainsi. Est-ce une sorte d'optimisation?

Les résultats peuvent également être reproduits dans Python 2.x mais dans une moindre mesure.

Sous Windows 7, CPython 3.5.1, Intel i7 3,40 GHz, OS 64 bits et Python. On dirait une machine différente que j'ai essayé d'exécuter sur Intel i7 3,60 GHz avec Python 3.5.0 ne reproduit pas les résultats.


L'exécution à l'aide du même processus Python avec timeit.timeit() @ 10000 boucles a produit 0,703 et 0,804 respectivement. Toujours affiché bien que dans une moindre mesure. (~ 12,5%)

76
Bharel

Mes résultats étaient similaires aux vôtres: le code utilisant des variables intermédiaires était assez régulièrement au moins 10 à 20% plus rapide dans Python 3.4. Cependant, lorsque j'utilisais IPython sur le même Python 3.4 interprète, j'ai obtenu ces résultats:

In [1]: %timeit -n10000 -r20 Tuple(range(2000)) == Tuple(range(2000))
10000 loops, best of 20: 74.2 µs per loop

In [2]: %timeit -n10000 -r20 a = Tuple(range(2000));  b = Tuple(range(2000)); a==b
10000 loops, best of 20: 75.7 µs per loop

Notamment, je n'ai jamais réussi à me rapprocher des 74,2 µs pour le premier lorsque j'ai utilisé -mtimeit À partir de la ligne de commande.

Donc, ce Heisenbug s'est avéré être quelque chose de très intéressant. J'ai décidé d'exécuter la commande avec strace et en effet il se passe quelque chose de louche:

% strace -o withoutvars python3 -m timeit "Tuple(range(2000)) == Tuple(range(2000))"
10000 loops, best of 3: 134 usec per loop
% strace -o withvars python3 -mtimeit "a = Tuple(range(2000));  b = Tuple(range(2000)); a==b"
10000 loops, best of 3: 75.8 usec per loop
% grep mmap withvars|wc -l
46
% grep mmap withoutvars|wc -l
41149

Maintenant, c'est une bonne raison de la différence. Le code qui n'utilise pas de variables fait que l'appel système mmap est appelé presque 1000 fois plus que celui qui utilise des variables intermédiaires.

withoutvars est plein de mmap/munmap pour une région de 256k; ces mêmes lignes se répètent encore et encore:

mmap(NULL, 262144, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f32e56de000
munmap(0x7f32e56de000, 262144)          = 0
mmap(NULL, 262144, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f32e56de000
munmap(0x7f32e56de000, 262144)          = 0
mmap(NULL, 262144, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f32e56de000
munmap(0x7f32e56de000, 262144)          = 0

L'appel mmap semble provenir de la fonction _PyObject_ArenaMmap De Objects/obmalloc.c; obmalloc.c contient également la macro ARENA_SIZE, qui est #define d pour être (256 << 10) (c'est-à-dire 262144); de même, le munmap correspond au _PyObject_ArenaMunmap de obmalloc.c.

obmalloc.c Dit que

Avant Python 2.5, les arènes n'ont jamais été free() 'ed. À partir de Python 2.5, nous essayons de free() arenas, et utiliser des stratégies heuristiques douces pour augmenter la probabilité que les arènes soient éventuellement libérées.

Ainsi, ces heuristiques et le fait que Python libère ces arènes libres dès qu'elles sont vidées conduisent à python3 -mtimeit 'Tuple(range(2000)) == Tuple(range(2000))' déclenchant un comportement pathologique où une zone de mémoire de 256 kiB est re -alloué et publié à plusieurs reprises; et cette allocation se produit avec mmap/munmap, ce qui est relativement coûteux car ce sont des appels système - en outre, mmap avec MAP_ANONYMOUS nécessite que les pages nouvellement mappées soient mises à zéro - même si Python ne s'en soucierait pas.

Le comportement n'est pas présent dans le code qui utilise des variables intermédiaires, car il utilise légèrement plus de mémoire et aucune arène mémoire ne peut être libérée car certains objets sont encore alloué en elle. C'est parce que timeit en fera une boucle semblable à

for n in range(10000)
    a = Tuple(range(2000))
    b = Tuple(range(2000))
    a == b

Maintenant, le comportement est que a et b resteront liés jusqu'à ce qu'ils soient * réaffectés, donc dans la deuxième itération, Tuple(range(2000)) allouera un 3ème tuple, et le assignation a = Tuple(...) diminuera le nombre de références de l'ancien Tuple, provoquant sa libération, et augmentera le nombre de références du nouveau Tuple; alors la même chose se produit pour b. Par conséquent, après la première itération, il y a toujours au moins 2 de ces tuples, sinon 3, de sorte que le thrashing ne se produit pas.

Plus particulièrement, il ne peut pas être garanti que le code utilisant des variables intermédiaires est toujours plus rapide - en effet, dans certaines configurations, il se peut que l'utilisation de variables intermédiaires entraîne des appels supplémentaires de mmap, tandis que le code qui compare directement les valeurs de retour peut être correct. .


Quelqu'un a demandé pourquoi cela se produit, lorsque timeit désactive la récupération de place. Il est en effet vrai que timeit le fait :

Remarque

Par défaut, timeit() désactive temporairement la récupération de place pendant le chronométrage. L'avantage de cette approche est qu'elle rend les timings indépendants plus comparables. Cet inconvénient est que le GC peut être un élément important des performances de la fonction mesurée. Si tel est le cas, GC peut être réactivé en tant que première instruction de la chaîne de configuration. Par exemple:

Cependant, le garbage collector de Python n'est là que pour récupérer ordures cycliques, c'est-à-dire des collections d'objets dont les références forment des cycles. Ce n'est pas le cas ici; à la place, ces objets sont libérés immédiatement lorsque le nombre de références tombe à zéro.

106
Antti Haapala

La première question ici doit être, est-elle reproductible? Pour certains d'entre nous, au moins, c'est bien que d'autres personnes disent ne pas voir l'effet. Ceci sur Fedora, avec le test d'égalité changé en is car faire une comparaison ne semble pas pertinent pour le résultat, et la plage a poussé jusqu'à 200 000 car cela semble maximiser l'effet:

$ python3 -m timeit "a = Tuple(range(200000));  b = Tuple(range(200000)); a is b"
100 loops, best of 3: 7.03 msec per loop
$ python3 -m timeit "a = Tuple(range(200000)) is Tuple(range(200000))"
100 loops, best of 3: 10.2 msec per loop
$ python3 -m timeit "Tuple(range(200000)) is Tuple(range(200000))"
100 loops, best of 3: 10.2 msec per loop
$ python3 -m timeit "a = b = Tuple(range(200000)) is Tuple(range(200000))"
100 loops, best of 3: 9.99 msec per loop
$ python3 -m timeit "a = b = Tuple(range(200000)) is Tuple(range(200000))"
100 loops, best of 3: 10.2 msec per loop
$ python3 -m timeit "Tuple(range(200000)) is Tuple(range(200000))"
100 loops, best of 3: 10.1 msec per loop
$ python3 -m timeit "a = Tuple(range(200000));  b = Tuple(range(200000)); a is b"
100 loops, best of 3: 7 msec per loop
$ python3 -m timeit "a = Tuple(range(200000));  b = Tuple(range(200000)); a is b"
100 loops, best of 3: 7.02 msec per loop

Je note que les variations entre les exécutions et l'ordre dans lequel les expressions sont exécutées font très peu de différence dans le résultat.

L'ajout d'affectations à a et b dans la version lente ne l'accélère pas. En fait, comme on pourrait s'y attendre, l'attribution aux variables locales a un effet négligeable. La seule chose qui l'accélère est de diviser complètement l'expression en deux. La seule différence que cela devrait faire est qu'elle réduit la profondeur de pile maximale utilisée par Python lors de l'évaluation de l'expression (de 4 à 3).

Cela nous donne l'indice que l'effet est lié à la profondeur de la pile, peut-être que le niveau supplémentaire pousse la pile dans une autre page de mémoire. Si c'est le cas, nous devrions voir que faire d'autres changements qui affectent la pile va changer (probablement tuer l'effet), et en fait c'est ce que nous voyons:

$ python3 -m timeit -s "def foo():
   Tuple(range(200000)) is Tuple(range(200000))" "foo()"
100 loops, best of 3: 10 msec per loop
$ python3 -m timeit -s "def foo():
   Tuple(range(200000)) is Tuple(range(200000))" "foo()"
100 loops, best of 3: 10 msec per loop
$ python3 -m timeit -s "def foo():
   a = Tuple(range(200000));  b = Tuple(range(200000)); a is b" "foo()"
100 loops, best of 3: 9.97 msec per loop
$ python3 -m timeit -s "def foo():
   a = Tuple(range(200000));  b = Tuple(range(200000)); a is b" "foo()"
100 loops, best of 3: 10 msec per loop

Donc, je pense que l'effet est entièrement dû à la quantité Python est consommée pendant le processus de chronométrage. C'est quand même bizarre.

7
Duncan