Pourquoi a.insert (0,0) est-il beaucoup plus lent que a [0: 0] = [0]?

61

L'utilisation de la insertfonction d' une liste est beaucoup plus lente que l'obtention du même effet en utilisant l'attribution de tranche:

> python -m timeit -n 100000 -s "a=[]" "a.insert(0,0)"
100000 loops, best of 5: 19.2 usec per loop

> python -m timeit -n 100000 -s "a=[]" "a[0:0]=[0]"
100000 loops, best of 5: 6.78 usec per loop

(Notez que ce a=[]n'est que la configuration, acommence donc vide, puis augmente à 100 000 éléments.)

Au début, je pensais que c'était peut-être la recherche d'attribut ou la surcharge d'appel de fonction, mais l'insertion près de la fin montre que c'est négligeable:

> python -m timeit -n 100000 -s "a=[]" "a.insert(-1,0)"
100000 loops, best of 5: 79.1 nsec per loop

Pourquoi la fonction dédiée "insérer un seul élément" est-elle tellement plus lente?

Je peux également le reproduire sur repl.it :

from timeit import repeat

for _ in range(3):
  for stmt in 'a.insert(0,0)', 'a[0:0]=[0]', 'a.insert(-1,0)':
    t = min(repeat(stmt, 'a=[]', number=10**5))
    print('%.6f' % t, stmt)
  print()

# Example output:
#
# 4.803514 a.insert(0,0)
# 1.807832 a[0:0]=[0]
# 0.012533 a.insert(-1,0)
#
# 4.967313 a.insert(0,0)
# 1.821665 a[0:0]=[0]
# 0.012738 a.insert(-1,0)
#
# 5.694100 a.insert(0,0)
# 1.899940 a[0:0]=[0]
# 0.012664 a.insert(-1,0)

J'utilise Python 3.8.1 32 bits sur Windows 10 64 bits.
repl.it utilise Python 3.8.1 64 bits sur Linux 64 bits.

Débordement de tas
la source
Il est intéressant de noter que cela a=[]; a[0:0]=[0]fait la même chose quea=[]; a[100:200]=[0]
smac89
Y a-t-il une raison pour laquelle vous testez cela avec juste une liste vide?
MisterMiyagi
@MisterMiyagi Eh bien, je dois commencer par quelque chose . Notez qu'il est vide uniquement avant la première insertion et atteint 100 000 éléments pendant le test de performance.
Débordement de tas le
@ smac89 a=[1,2,3];a[100:200]=[4]est apposent 4à la fin de la liste aintéressante.
Ch3steR
1
@ smac89 Bien que cela soit vrai, cela n'a pas vraiment à voir avec la question et je crains que cela puisse induire quelqu'un en erreur en pensant que je fais des analyses comparatives a=[]; a[0:0]=[0]ou qui a[0:0]=[0]fait la même chose que a[100:200]=[0]...
Débordement de tas

Réponses:

57

Je pense qu'il est probablement juste qu'ils ont oublié d'utiliser memmovedans list.insert. Si vous regardez le code list.insert utilisé pour déplacer les éléments, vous pouvez voir que ce n'est qu'une boucle manuelle:

for (i = n; --i >= where; )
    items[i+1] = items[i];

tandis que list.__setitem__sur le chemin d'affectation de tranche utilisememmove :

memmove(&item[ihigh+d], &item[ihigh],
    (k - ihigh)*sizeof(PyObject *));

memmove a généralement beaucoup d'optimisation, comme tirer parti des instructions SSE / AVX.

user2357112 prend en charge Monica
la source
5
Merci. Création d'un problème faisant référence à cela.
Débordement de tas le
7
Si l'interpréteur a été construit avec -O3la vectorisation automatique activée, cette boucle manuelle pourrait se compiler efficacement. Mais à moins que le compilateur reconnaisse la boucle comme étant un memmove et le compile en un appel réel à memmove, il ne peut que profiter des extensions de jeu d'instructions activées au moment de la compilation. (Très bien si vous construisez le vôtre avec -march=native, pas tant pour les binaires de distribution construits avec la ligne de base). Et GCC ne déroulera pas les boucles par défaut sauf si vous utilisez PGO ( -fprofile-generate/ run / ...-use)
Peter Cordes
@PeterCordes Dois-je bien comprendre que si le compilateur le compile dans un memmoveappel réel , qui peut alors profiter de toutes les extensions présentes au moment de l'exécution?
Débordement de tas
1
@HeapOverflow: Oui. Sur GNU / Linux par exemple, la glibc surcharge la résolution des symboles de l'éditeur de liens dynamique avec une fonction qui sélectionne la meilleure version asm manuscrite de memmove pour cette machine en fonction des résultats de détection de CPU enregistrés. (par exemple sur x86, une fonction d'initialisation glibc utilise cpuid). Idem pour plusieurs autres fonctions mem / str. Ainsi, les distributions peuvent être compilées avec juste -O2pour créer des binaires exécutables n'importe où, mais au moins, memcpy / memmove utilise une boucle AVX non chargée chargeant / stockant 32 octets par instruction. (Ou même AVX512 sur les quelques processeurs où c'est une bonne idée; je pense que Xeon Phi.)
Peter Cordes
1
@HeapOverflow: Non, plusieurs memmoveversions se trouvent là dans libc.so, la bibliothèque partagée. Pour chaque fonction, l'envoi s'effectue une seule fois, lors de la résolution du symbole (liaison anticipée ou lors du premier appel avec liaison paresseuse traditionnelle). Comme je l'ai dit, il ne fait que surcharger / accrocher la façon dont la liaison dynamique se produit, pas en encapsulant la fonction elle-même. (spécifiquement via le mécanisme ifunc de GCC: code.woboq.org/userspace/glibc/sysdeps/x86_64/multiarch/… ). EN RELATION: pour memset, le choix habituel sur les processeurs modernes est de __memset_avx2_unaligned_erms voir ce Q&R
Peter Cordes