¿Qué pasa con el caché de enteros dentro de Python?
caching code-analysis (1)
Python almacena en caché enteros en el rango [-5, 256]
, por lo que se espera que los enteros en ese rango también sean idénticos.
Lo que ves es el compilador de Python que optimiza literales idénticos cuando es parte del mismo texto.
Al escribir en el shell de Python cada línea es una declaración completamente diferente, analizada en un momento diferente, por lo tanto:
>>> a = 257
>>> b = 257
>>> a is b
False
Pero si pones el mismo código en un archivo:
$ echo ''a = 257
> b = 257
> print a is b'' > testing.py
$ python testing.py
True
Esto sucede siempre que el analizador tiene la posibilidad de analizar dónde se usan los literales, por ejemplo, al definir una función en el intérprete interactivo:
>>> def test():
... a = 257
... b = 257
... print a is b
...
>>> dis.dis(test)
2 0 LOAD_CONST 1 (257)
3 STORE_FAST 0 (a)
3 6 LOAD_CONST 1 (257)
9 STORE_FAST 1 (b)
4 12 LOAD_FAST 0 (a)
15 LOAD_FAST 1 (b)
18 COMPARE_OP 8 (is)
21 PRINT_ITEM
22 PRINT_NEWLINE
23 LOAD_CONST 0 (None)
26 RETURN_VALUE
>>> test()
True
>>> test.func_code.co_consts
(None, 257)
Observe cómo el código compilado contiene una sola constante para el 257
.
En conclusión, el compilador de bytecode de Python no puede realizar optimizaciones masivas (como idiomas de tipos estáticos), pero hace más de lo que cree. Una de estas cosas es analizar el uso de literales y evitar duplicarlos.
Tenga en cuenta que esto no tiene que ver con la memoria caché, ya que también funciona para flotantes, que no tienen una memoria caché:
>>> a = 5.0
>>> b = 5.0
>>> a is b
False
>>> a = 5.0; b = 5.0
>>> a is b
True
Para literales más complejos, como tuplas, "no funciona":
>>> a = (1,2)
>>> b = (1,2)
>>> a is b
False
>>> a = (1,2); b = (1,2)
>>> a is b
False
Pero los literales dentro de la tupla son compartidos:
>>> a = (257, 258)
>>> b = (257, 258)
>>> a[0] is b[0]
False
>>> a[1] is b[1]
False
>>> a = (257, 258); b = (257, 258)
>>> a[0] is b[0]
True
>>> a[1] is b[1]
True
En cuanto a por qué ves que se crean dos PyInt_Object
, supongo que esto se hace para evitar la comparación literal. por ejemplo, el número 257
puede ser expresado por múltiples literales:
>>> 257
257
>>> 0x101
257
>>> 0b100000001
257
>>> 0o401
257
El analizador tiene dos opciones:
- Convierta los literales en una base común antes de crear el entero, y vea si los literales son equivalentes. luego crea un único objeto entero.
- Cree los objetos enteros y vea si son iguales. En caso afirmativo, conserve solo un valor y asígnelo a todos los literales; de lo contrario, ya tiene los enteros para asignar.
Probablemente, el analizador de Python utiliza el segundo enfoque, que evita reescribir el código de conversión y también es más fácil de extender (por ejemplo, también funciona con flotadores).
Al leer el archivo Python/ast.c
, la función que analiza todos los números es parsenumber
, que llama a PyOS_strtoul
para obtener el valor entero (para intgers) y finalmente llama a PyLong_FromString
:
x = (long) PyOS_strtoul((char *)s, (char **)&end, 0);
if (x < 0 && errno == 0) {
return PyLong_FromString((char *)s,
(char **)0,
0);
}
Como puede ver aquí, el analizador no verifica si ya encontró un número entero con el valor dado y esto explica por qué ve que se crean dos objetos int, y esto también significa que mi conjetura fue correcta: el analizador primero crea las constantes y solo después optimiza el bytecode para usar el mismo objeto para constantes iguales.
El código que hace esta comprobación debe estar en algún lugar de Python/compile.c
o Python/compile.c
, ya que estos son los archivos que transforman el AST en bytecode.
En particular, la función compiler_add_o
parece ser la que lo hace. Hay este comentario en compiler_lambda
:
/* Make None the first constant, so the lambda can''t have a
docstring. */
if (compiler_add_o(c, c->u->u_consts, Py_None) < 0)
return 0;
Así que parece que compiler_add_o
se usa para insertar constantes para funciones / lambdas, etc. La función compiler_add_o
almacena las constantes en un objeto dict
, y de esto sigue inmediatamente que las constantes iguales caerán en la misma ranura, lo que da como resultado una única constante en el final bytecode.
Después de bucear en el código fuente de Python, descubro que mantiene una matriz de PyInt_Object
s que van desde int (-5) a int (256) (@ src / Objects / intobject.c)
Un pequeño experimento lo prueba:
>>> a = 1
>>> b = 1
>>> a is b
True
>>> a = 257
>>> b = 257
>>> a is b
False
Pero si ejecuto esos códigos juntos en un archivo py (o los combino con puntos y comas), el resultado es diferente:
>>> a = 257; b = 257; a is b
True
Tengo curiosidad por saber por qué siguen siendo el mismo objeto, por lo que profundizo en el árbol de sintaxis y el compilador, se me ocurrió una jerarquía de llamadas que se enumeran a continuación:
PyRun_FileExFlags()
mod = PyParser_ASTFromFile()
node *n = PyParser_ParseFileFlagsEx() //source to cst
parsetoke()
ps = PyParser_New()
for (;;)
PyTokenizer_Get()
PyParser_AddToken(ps, ...)
mod = PyAST_FromNode(n, ...) //cst to ast
run_mod(mod, ...)
co = PyAST_Compile(mod, ...) //ast to CFG
PyFuture_FromAST()
PySymtable_Build()
co = compiler_mod()
PyEval_EvalCode(co, ...)
PyEval_EvalCodeEx()
Luego agregué un código de depuración en PyInt_FromLong
y antes / después de PyAST_FromNode
, y ejecuté un test.py:
a = 257
b = 257
print "id(a) = %d, id(b) = %d" % (id(a), id(b))
la salida se ve así:
DEBUG: before PyAST_FromNode
name = a
ival = 257, id = 176046536
name = b
ival = 257, id = 176046752
name = a
name = b
DEBUG: after PyAST_FromNode
run_mod
PyAST_Compile ok
id(a) = 176046536, id(b) = 176046536
Eval ok
Significa que durante la transformación cst
to ast
, se crean dos PyInt_Object
s diferentes (en realidad se realiza en la función ast_for_atom()
), pero luego se fusionan.
Me resulta difícil comprender la fuente en PyAST_Compile
y PyEval_EvalCode
, por lo que estoy aquí para pedir ayuda, lo agradeceré si alguien da una pista?