Estaba trabajando en una clase simple que se extiende dict
y me di cuenta de que la búsqueda y el uso de claves pickle
son muy lentos.
Pensé que era un problema con mi clase, así que hice algunos puntos de referencia triviales:
(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
Los resultados son realmente una sorpresa. Mientras que la búsqueda de claves es 2 veces más lenta, pickle
es 5 veces más lenta.
¿Cómo puede ser esto? Otros métodos, como get()
, __eq__()
y __init__()
, e iteración keys()
, values()
y items()
son tan rápidos como dict
.
EDITAR : Eché un vistazo al código fuente de Python 3.9, y Objects/dictobject.c
parece que el __getitem__()
método está implementado por dict_subscript()
. Y dict_subscript()
ralentiza las subclases solo si falta la clave, ya que la subclase puede implementar __missing__()
e intenta ver si existe. Pero el punto de referencia fue con una clave existente.
Pero noté algo: __getitem__()
se define con la bandera METH_COEXIST
. Y también __contains__()
, el otro método que es 2 veces más lento, tiene la misma bandera. De la documentación oficial :
El método se cargará en lugar de las definiciones existentes. Sin METH_COEXIST, el valor predeterminado es omitir definiciones repetidas. Dado que los contenedores de ranuras se cargan antes de la tabla de métodos, la existencia de una ranura sq_contains, por ejemplo, generaría un método envuelto llamado contiene () y evitaría la carga de una PyCFunction correspondiente con el mismo nombre. Con la bandera definida, la función PyCFunction se cargará en lugar del objeto contenedor y coexistirá con la ranura. Esto es útil porque las llamadas a PyCFunctions se optimizan más que las llamadas a objetos de contenedor.
Entonces, si entendí correctamente, en teoría METH_COEXIST
debería acelerar las cosas, pero parece tener el efecto contrario. ¿Por qué?
EDIT 2 : descubrí algo más.
__getitem__()
y __contains()__
se marcan como METH_COEXIST
, porque se declaran en PyDict_Type dos veces.
Ambos están presentes, una vez, en la ranura tp_methods
, donde se declaran explícitamente como __getitem__()
y __contains()__
. Pero la documentación oficial dice que notp_methods
son heredadas por las subclases.
Entonces, una subclase de dict
no llama __getitem__()
, sino que llama al subslot mp_subscript
. De hecho, mp_subscript
está contenido en la ranura tp_as_mapping
, que permite que una subclase herede sus sublotas.
El problema es que ambos __getitem__()
y mp_subscript
usan la misma función dict_subscript
,. ¿Es posible que sea solo la forma en que se heredó lo que lo ralentiza?
fuente
dict
así, llama a la implementación de C directamente en lugar de buscar el__getitem__
método desde La clase del objeto. Por lo tanto, su código realiza dos búsquedas de dict, la primera para la clave'__getitem__'
en el diccionario deA
los miembros de la clase , por lo que se puede esperar que sea aproximadamente el doble de lenta. Lapickle
explicación es probablemente bastante similar.len()
por qué , por ejemplo, no es 2 veces más lento pero tiene la misma velocidad?len
debería tener una ruta rápida para los tipos de secuencia incorporados. No creo que pueda dar una respuesta adecuada a su pregunta, pero es buena, por lo que espero que alguien más conocedor de Python que yo responda.__contains__
implementación explícita está bloqueando la lógica utilizada para heredarsq_contains
.Respuestas:
La indexación y
in
lasdict
subclases son más lentas debido a una mala interacción entre unadict
optimización y las subclases lógicas utilizadas para heredar las ranuras C. Esto debería ser reparable, aunque no de su parte.La implementación de CPython tiene dos conjuntos de ganchos para sobrecargas del operador. Existen métodos a nivel de Python como
__contains__
y__getitem__
, pero también hay un conjunto separado de ranuras para punteros de función C en el diseño de memoria de un objeto tipo. Por lo general, el método Python será un envoltorio alrededor de la implementación de C o la ranura C contendrá una función que busca y llama al método Python. Es más eficiente que la ranura C implemente la operación directamente, ya que la ranura C es a lo que Python realmente accede.Las asignaciones escritas en C implementan las ranuras C
sq_contains
ymp_subscript
proporcionanin
e indexan. Normalmente, el nivel de Python__contains__
y los__getitem__
métodos se generarían automáticamente como envoltorios alrededor de las funciones de C, pero ladict
clase tiene implementaciones explícitas de__contains__
y__getitem__
, porque las implementaciones explícitas son un poco más rápidas que los envoltorios generados:(En realidad, la
__getitem__
implementación explícita es la misma función que lamp_subscript
implementación, solo que con un tipo diferente de envoltorio).Por lo general, una subclase heredaría las implementaciones de sus padres de ganchos de nivel C como
sq_contains
ymp_subscript
, y la subclase sería tan rápida como la superclase. Sin embargo, la lógicaupdate_one_slot
busca la implementación principal al tratar de encontrar los métodos de contenedor generados a través de una búsqueda MRO.dict
no tienen envolturas generados parasq_contains
ymp_subscript
, debido a que proporciona explícita__contains__
e__getitem__
implementaciones.En lugar de heredar
sq_contains
ymp_subscript
,update_one_slot
termina dando la subclasesq_contains
ymp_subscript
las implementaciones que realizar una búsqueda de MRO__contains__
y__getitem__
y llamar a aquellos. Esto es mucho menos eficiente que heredar las ranuras C directamente.Arreglar esto requerirá cambios en la
update_one_slot
implementación.Además de lo que describí anteriormente,
dict_subscript
también busca__missing__
subclases dict, por lo que solucionar el problema de herencia de ranura no hará que las subclases estén completamente a la pardict
para la velocidad de búsqueda, pero debería acercarlas mucho más.En cuanto al encurtido, por un
dumps
lado, la implementación de encurtido tiene una ruta rápida dedicada para los dictados, mientras que la subclase dict toma un camino más indirecto a través deobject.__reduce_ex__
ysave_reduce
.Por
loads
otro lado, la diferencia horaria se debe principalmente a los__main__.A
códigos de operación y las búsquedas adicionales para recuperar e instanciar la clase, mientras que los dictos tienen un código de operación de pickle dedicado para hacer un nuevo dict. Si comparamos el desmontaje de los encurtidos:Vemos que la diferencia entre los dos es que el segundo encurtido necesita un montón de códigos de operación para mirar hacia arriba
__main__.A
e instanciarlo, mientras que el primer encurtido solo lo haceEMPTY_DICT
para obtener un dict vacío. Después de eso, ambos pepinillos empujan las mismas teclas y valores en la pila de operandos de pepinillos y se ejecutanSETITEMS
.fuente
__contains__()
y__getitem()
de tal manera que pueda ser heredada por las subclases? En la documentación oficial detp_methods
, está escrito esomethods are inherited through a different mechanism
, por lo que parece posible.__contains__
y__getitem__
se heredan, pero el problema es esosq_contains
ymp_subscript
no lo son.__contains__
y__getitem__
están en la ranuratp_methods
, que para los documentos oficiales no son heredados por las subclases. Y como dijiste,update_one_slot
no usasq_contains
ymp_subscript
.contains
pocas palabras, y el resto no se puede mover simplemente a otra ranura, que es heredada por las subclases.tp_methods
no se hereda, pero los objetos del método Python generados a partir de él se heredan en el sentido de que la búsqueda estándar de MRO para acceso a atributos los encontrará.