Eu estava trabalhando em uma classe simples que se estende dict
e percebi que a pesquisa e o uso de chaves pickle
são muito lentos.
Eu pensei que era um problema com a minha classe, então fiz alguns benchmarks triviais:
(venv) marco@buzz:~/sources/python-frozendict/test$ python --version
Python 3.9.0a0
(venv) marco@buzz:~/sources/python-frozendict/test$ sudo pyperf system tune --affinity 3
[sudo] password for marco:
Tune the system configuration to run benchmarks
Actions
=======
CPU Frequency: Minimum frequency of CPU 3 set to the maximum frequency
System state
============
CPU: use 1 logical CPUs: 3
Perf event: Maximum sample rate: 1 per second
ASLR: Full randomization
Linux scheduler: No CPU is isolated
CPU Frequency: 0-3=min=max=2600 MHz
CPU scaling governor (intel_pstate): performance
Turbo Boost (intel_pstate): Turbo Boost disabled
IRQ affinity: irqbalance service: inactive
IRQ affinity: Default IRQ affinity: CPU 0-2
IRQ affinity: IRQ affinity: IRQ 0,2=CPU 0-3; IRQ 1,3-17,51,67,120-131=CPU 0-2
Power supply: the power cable is plugged
Advices
=======
Linux scheduler: Use isolcpus=<cpu list> kernel parameter to isolate CPUs
Linux scheduler: Use rcu_nocbs=<cpu list> kernel parameter (with isolcpus) to not schedule RCU on isolated CPUs
(venv) marco@buzz:~/sources/python-frozendict/test$ python -m pyperf timeit --rigorous --affinity 3 -s '
x = {0:0, 1:1, 2:2, 3:3, 4:4}
' 'x[4]'
.........................................
Mean +- std dev: 35.2 ns +- 1.8 ns
(venv) marco@buzz:~/sources/python-frozendict/test$ python -m pyperf timeit --rigorous --affinity 3 -s '
class A(dict):
pass
x = A({0:0, 1:1, 2:2, 3:3, 4:4})
' 'x[4]'
.........................................
Mean +- std dev: 60.1 ns +- 2.5 ns
(venv) marco@buzz:~/sources/python-frozendict/test$ python -m pyperf timeit --rigorous --affinity 3 -s '
x = {0:0, 1:1, 2:2, 3:3, 4:4}
' '5 in x'
.........................................
Mean +- std dev: 31.9 ns +- 1.4 ns
(venv) marco@buzz:~/sources/python-frozendict/test$ python -m pyperf timeit --rigorous --affinity 3 -s '
class A(dict):
pass
x = A({0:0, 1:1, 2:2, 3:3, 4:4})
' '5 in x'
.........................................
Mean +- std dev: 64.7 ns +- 5.4 ns
(venv) marco@buzz:~/sources/python-frozendict/test$ python
Python 3.9.0a0 (heads/master-dirty:d8ca2354ed, Oct 30 2019, 20:25:01)
[GCC 9.2.1 20190909] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from timeit import timeit
>>> class A(dict):
... def __reduce__(self):
... return (A, (dict(self), ))
...
>>> timeit("dumps(x)", """
... from pickle import dumps
... x = {0:0, 1:1, 2:2, 3:3, 4:4}
... """, number=10000000)
6.70694484282285
>>> timeit("dumps(x)", """
... from pickle import dumps
... x = A({0:0, 1:1, 2:2, 3:3, 4:4})
... """, number=10000000, globals={"A": A})
31.277778962627053
>>> timeit("loads(x)", """
... from pickle import dumps, loads
... x = dumps({0:0, 1:1, 2:2, 3:3, 4:4})
... """, number=10000000)
5.767975459806621
>>> timeit("loads(x)", """
... from pickle import dumps, loads
... x = dumps(A({0:0, 1:1, 2:2, 3:3, 4:4}))
... """, number=10000000, globals={"A": A})
22.611666693352163
Os resultados são realmente uma surpresa. Enquanto a pesquisa de teclas é 2x mais lenta, pickle
é 5x mais lenta.
Como isso pode ser? Outros métodos, como get()
, __eq__()
e __init__()
, e iteração terminam keys()
, values()
e items()
são tão rápidos quanto dict
.
Edição : dei uma olhada no código fonte do Python 3.9, e Objects/dictobject.c
parece que o __getitem__()
método é implementado por dict_subscript()
. E dict_subscript()
diminui a velocidade das subclasses apenas se a chave estiver faltando, pois a subclasse pode ser implementada __missing__()
e tenta ver se existe. Mas a referência foi com uma chave existente.
Mas notei algo: __getitem__()
é definido com a bandeira METH_COEXIST
. E também __contains__()
, o outro método que é 2x mais lento, tem a mesma bandeira. A partir da documentação oficial :
O método será carregado no lugar das definições existentes. Sem METH_COEXIST, o padrão é pular definições repetidas. Como os wrappers de slot são carregados antes da tabela de métodos, a existência de um slot sq_contains, por exemplo, geraria um método empacotado chamado contains () e impediria o carregamento de uma PyCFunction correspondente com o mesmo nome. Com o sinalizador definido, o PyCFunction será carregado no lugar do objeto wrapper e coexistirá com o slot. Isso é útil porque as chamadas para PyCFunctions são otimizadas mais do que as chamadas de objeto de wrapper.
Então, se eu entendi direito, em teoria, METH_COEXIST
deveria acelerar as coisas, mas parece ter o efeito oposto. Por quê?
EDIT 2 : Eu descobri algo mais.
__getitem__()
e __contains()__
são sinalizados como METH_COEXIST
, porque são declarados em PyDict_Type duas vezes.
Ambos estão presentes, uma vez, no slot tp_methods
, onde são explicitamente declarados como __getitem__()
e __contains()__
. Mas a documentação oficial diz que nãotp_methods
são herdadas pelas subclasses.
Portanto, uma subclasse de dict
não chama __getitem__()
, mas chama o sub-lote mp_subscript
. De fato, mp_subscript
está contido no slot tp_as_mapping
, que permite que uma subclasse herde seus sub-lotes.
O problema é que ambos __getitem__()
e mp_subscript
usam a mesma função dict_subscript
,. É possível que seja apenas a maneira como foi herdada que a torna mais lenta?
fonte
dict
e, nesse caso, chama a implementação C diretamente, em vez de procurar o__getitem__
método em a classe do objeto. Seu código, portanto, faz duas pesquisas de ditado, a primeira para a chave'__getitem__'
no dicionário dosA
membros da classe , portanto, pode-se esperar que seja duas vezes mais lento. Apickle
explicação é provavelmente bastante semelhante.len()
, por exemplo, não é 2x mais lento, mas tem a mesma velocidade?len
deveria ter um caminho rápido para os tipos de sequência internos. Eu não acho que sou capaz de dar uma resposta adequada à sua pergunta, mas é uma boa, então espero que alguém com mais conhecimento sobre o interior do Python do que eu possa responder.__contains__
implementação explícita está bloqueando a lógica usada para herdarsq_contains
.Respostas:
A indexação
in
é mais lenta nasdict
subclasses devido a uma interação ruim entre umadict
otimização e as subclasses lógicas usadas para herdar slots C. Isso deve ser corrigível, embora não do seu fim.A implementação do CPython possui dois conjuntos de ganchos para sobrecargas do operador. Existem métodos no nível Python como
__contains__
e__getitem__
, mas também há um conjunto separado de slots para ponteiros de função C no layout de memória de um objeto de tipo. Normalmente, o método Python será um wrapper em torno da implementação C ou o slot C conterá uma função que procura e chama o método Python. É mais eficiente para o slot C implementar a operação diretamente, pois o slot C é o que o Python realmente acessa.Os mapeamentos escritos em C implementam os slots C
sq_contains
emp_subscript
fornecemin
e indexam. Normalmente, o nível__contains__
e os__getitem__
métodos do Python seriam gerados automaticamente como wrappers em torno das funções C, mas adict
classe possui implementações explícitas de__contains__
e__getitem__
, porque as implementações explícitas são um pouco mais rápidas que os wrappers gerados:(Na verdade, a
__getitem__
implementação explícita tem a mesma função que amp_subscript
implementação, apenas com um tipo diferente de wrapper.)Normalmente, uma subclasse herdaria as implementações de ganchos de nível C de seus pais como
sq_contains
emp_subscript
, e a subclasse seria tão rápida quanto a superclasse. No entanto, a lógicaupdate_one_slot
procura a implementação pai tentando localizar os métodos de wrapper gerados por meio de uma pesquisa MRO.dict
não têm invólucros gerados parasq_contains
emp_subscript
, porque fornece explícita__contains__
e__getitem__
implementações.Em vez de herdar
sq_contains
emp_subscript
,update_one_slot
acaba dando a subclassesq_contains
emp_subscript
implementações que executar a busca por uma MRO para__contains__
e__getitem__
e chamar aqueles. Isso é muito menos eficiente do que herdar os slots C diretamente.Corrigir isso exigirá alterações na
update_one_slot
implementação.Além do que eu descrevi acima,
dict_subscript
também procura__missing__
subclasses de dict, portanto, corrigir o problema de herança de slot não tornará as subclasses completamente iguaisdict
para a velocidade de pesquisa, mas deve aproximá-las bastante.Quanto à decapagem, por
dumps
outro lado, a implementação da decapagem possui um caminho rápido dedicado para dictos, enquanto a subclasse dict segue um caminho mais indireto através deobject.__reduce_ex__
esave_reduce
.Por
loads
outro lado, a diferença de horário é principalmente dos opcodes e pesquisas extras para recuperar e instanciar a__main__.A
classe, enquanto os dictos têm um opcode pickle dedicado para fazer um novo dict. Se compararmos a desmontagem dos picles:vemos que a diferença entre os dois é que o segundo pickle precisa de um monte de opcodes para procurá-lo
__main__.A
e instancia-lo, enquanto o primeiro pickle apenas fazEMPTY_DICT
para obter um ditado vazio. Depois disso, os dois pickles pressionam as mesmas chaves e valores na pilha de operandos pickle e executamSETITEMS
.fonte
__contains__()
e de__getitem()
uma maneira que possa ser herdada pelas subclasses? Na documentação oficial detp_methods
, está escrito issomethods are inherited through a different mechanism
, então parece possível.__contains__
e__getitem__
são herdados, mas o problema é essesq_contains
emp_subscript
não são.__contains__
e__getitem__
estão no slottp_methods
que, para os documentos oficiais, não são herdados pelas subclasses. E como você disse,update_one_slot
não usasq_contains
emp_subscript
.contains
e o resto não pode ser simplesmente movido para outro slot, que é herdado pelas subclasses?tp_methods
não é herdado, mas os objetos do método Python gerados a partir dele são herdados no sentido de que a pesquisa MRO padrão para acesso a atributos os encontrará.