Por que a.insert (0,0) é muito mais lento que um [0: 0] = [0]?

61

Usar a insertfunção de uma lista é muito mais lento do que obter o mesmo efeito usando a atribuição de fatia:

> 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

(Observe que a=[]é apenas a configuração, portanto, acomeça vazio, mas depois aumenta para 100.000 elementos.)

No começo, pensei que talvez fosse a consulta de atributo ou a sobrecarga de chamada de função, mas inserir no final mostra que isso é insignificante:

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

Por que a função "inserir elemento único" presumivelmente mais simples é muito mais lenta?

Também posso reproduzi-lo em 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)

Eu uso o Python 3.8.1 de 32 bits no Windows 10 de 64 bits.
O repl.it usa o Python 3.8.1 de 64 bits no Linux de 64 bits.

Estouro de pilha
fonte
Interessante notar que a=[]; a[0:0]=[0]faz o mesmo quea=[]; a[100:200]=[0]
smac89
Existe alguma razão pela qual você está testando isso com apenas uma lista vazia?
MisterMiyagi 29/02
@MisterMiyagi Bem, eu tenho que começar com alguma coisa . Observe que ele está vazio somente antes da primeira inserção e cresce para 100.000 elementos durante o benchmark.
Heap Overflow
@ smac89 a=[1,2,3];a[100:200]=[4]está anexando 4ao final da lista ainteressante.
Ch3steR 29/02
11
@ smac89 Embora isso seja verdade, isso realmente não tem a ver com a pergunta e eu temo que isso possa induzir alguém a pensar que eu estou fazendo benchmarking a=[]; a[0:0]=[0]ou que a[0:0]=[0]faz o mesmo que a[100:200]=[0]...
Heap Overflow

Respostas:

57

Eu acho que é provavelmente apenas que se esqueceram de usar memmoveno list.insert. Se você der uma olhada no código list.insert usado para mudar elementos, poderá ver que é apenas um loop manual:

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

enquanto list.__setitem__no caminho de atribuição de fatia usamemmove :

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

memmove normalmente, há muita otimização, como tirar proveito das instruções SSE / AVX.

user2357112 suporta Monica
fonte
5
Obrigado. Criou um problema referenciando isso.
Heap Overflow
7
Se o intérprete foi construído com a -O3auto-vetorização ativada, esse loop manual pode compilar com eficiência. Mas, a menos que o compilador reconheça o loop como sendo um memmove e o compile em uma chamada real para memmove, ele poderá aproveitar apenas as extensões do conjunto de instruções ativadas no momento da compilação. (Ótimo se você estiver construindo o seu próprio -march=native, não muito para binários de distribuição criados com a linha de base). E o GCC não desenrola os loops por padrão, a menos que você use o PGO ( -fprofile-generate/ run / ...-use)
Peter Cordes
@PeterCordes Entendo corretamente que, se o compilador o compilar em uma memmovechamada real , ele poderá tirar proveito de todas as extensões presentes no tempo de execução?
Heap Overflow
11
@HeapOverflow: Sim. No GNU / Linux, por exemplo, a glibc sobrecarrega a resolução do símbolo do vinculador dinâmico com uma função que escolhe a melhor versão asm escrita à mão do memmove para esta máquina com base nos resultados de detecção de CPU salvos. (por exemplo, no x86, uma função glibc init usa cpuid). O mesmo para várias outras funções mem / str. Assim, as distros podem ser compiladas apenas -O2para criar binários executados em qualquer lugar, mas pelo menos o memcpy / memmove use um loop AVX desenrolado carregando / armazenando 32 bytes por instrução. (Ou até o AVX512 nos poucos processadores em que é uma boa ideia; acho que apenas Xeon Phi.)
Peter Cordes
11
@HeapOverflow: Não, várias memmoveversões estão lá na libc.so, a biblioteca compartilhada. Para cada função, o despacho acontece uma vez, durante a resolução do símbolo (ligação antecipada ou na primeira chamada com ligação lenta tradicional). Como eu disse, apenas sobrecarrega / conecta como a vinculação dinâmica acontece, não envolvendo a função em si. (especificamente pelo mecanismo ifunc do GCC: code.woboq.org/userspace/glibc/sysdeps/x86_64/multiarch/… ). Relacionado: para memset, a escolha usual em CPUs modernas é __memset_avx2_unaligned_erms ver esta
Peter Cordes