¿Por qué [] es más rápido que list()?
Comparé las velocidades de procesamiento de []
y list()
en Python 3.11
$ python -m timeit '[]'
20000000 loops, best of 5: 11.3 nsec per loop
$ python -m timeit 'list()'
10000000 loops, best of 5: 26.1 nsec per loop
y se sorprendió al descubrir que []
corre aproximadamente dos veces más rápido que list()
. Obtuve resultados muy similares para {}
ydict()
$ python -m timeit '{}'
20000000 loops, best of 5: 11.6 nsec per loop
$ python -m timeit 'dict()'
10000000 loops, best of 5: 27.1 nsec per loop
¿Por qué es esto? ¿ []
Y {}
(y probablemente ()
y ''
también) devuelven inmediatamente copias de algún literal de stock vacío mientras sus contrapartes nombradas explícitamente ( ,,, list()
) se dedican a crear un objeto, tengan o no elementos?dict()
tuple()
str()
Porque []
y {}
son sintaxis literal . Python puede crear código de bytes solo para crear la lista o los objetos del diccionario:
>>> import dis
>>> dis.dis(compile('[]', '', 'eval'))
1 0 BUILD_LIST 0
3 RETURN_VALUE
>>> dis.dis(compile('{}', '', 'eval'))
1 0 BUILD_MAP 0
3 RETURN_VALUE
list()
y dict()
son objetos separados. Sus nombres deben resolverse, la pila debe participar para enviar los argumentos, el marco debe almacenarse para recuperarse más tarde y se debe realizar una llamada. Todo eso lleva más tiempo.
Para el caso vacío, eso significa que tiene al menos a LOAD_NAME
(que debe buscar en el espacio de nombres global así como en el builtins
módulo ) seguido de a CALL_FUNCTION
, que debe preservar el marco actual:
>>> dis.dis(compile('list()', '', 'eval'))
1 0 LOAD_NAME 0 (list)
3 CALL_FUNCTION 0
6 RETURN_VALUE
>>> dis.dis(compile('dict()', '', 'eval'))
1 0 LOAD_NAME 0 (dict)
3 CALL_FUNCTION 0
6 RETURN_VALUE
Puede cronometrar la búsqueda de nombres por separado con timeit
:
>>> import timeit
>>> timeit.timeit('list', number=10**7)
0.30749011039733887
>>> timeit.timeit('dict', number=10**7)
0.4215109348297119
La discrepancia horaria probablemente se debe a una colisión de hash del diccionario. Resta esos tiempos de los tiempos para llamar a esos objetos y compara el resultado con los tiempos para usar literales:
>>> timeit.timeit('[]', number=10**7)
0.30478692054748535
>>> timeit.timeit('{}', number=10**7)
0.31482696533203125
>>> timeit.timeit('list()', number=10**7)
0.9991960525512695
>>> timeit.timeit('dict()', number=10**7)
1.0200958251953125
Por lo tanto, tener que llamar al objeto lleva segundos adicionales 1.00 - 0.31 - 0.30 == 0.39
por cada 10 millones de llamadas.
Puede evitar el costo de búsqueda global al asignar un alias a los nombres globales como locales (usando una timeit
configuración, todo lo que vincula a un nombre es local):
>>> timeit.timeit('_list', '_list = list', number=10**7)
0.1866450309753418
>>> timeit.timeit('_dict', '_dict = dict', number=10**7)
0.19016098976135254
>>> timeit.timeit('_list()', '_list = list', number=10**7)
0.841480016708374
>>> timeit.timeit('_dict()', '_dict = dict', number=10**7)
0.7233691215515137
pero nunca podrás superar ese CALL_FUNCTION
costo.
list()
requiere una búsqueda global y una llamada a función, pero []
se compila en una sola instrucción. Ver:
Python 2.7.3
>>> import dis
>>> dis.dis(lambda: list())
1 0 LOAD_GLOBAL 0 (list)
3 CALL_FUNCTION 0
6 RETURN_VALUE
>>> dis.dis(lambda: [])
1 0 BUILD_LIST 0
3 RETURN_VALUE
Porque list
es una función para convertir, por ejemplo, una cadena en un objeto de lista, mientras que []
se usa para crear una lista desde el principio. Pruebe esto (puede que tenga más sentido para usted):
x = "wham bam"
a = list(x)
>>> a
["w", "h", "a", "m", ...]
Mientras
y = ["wham bam"]
>>> y
["wham bam"]
Le brinda una lista real que contiene todo lo que puso en ella.
Las respuestas aquí son excelentes, van al grano y cubren completamente esta pregunta. Bajaré un paso más desde el código de bytes para aquellos interesados. Estoy usando el repositorio más reciente de CPython; Las versiones anteriores se comportan de manera similar a este respecto, pero es posible que se realicen ligeros cambios.
Aquí hay un desglose de la ejecución de cada uno de estos, BUILD_LIST
para []
y CALL_FUNCTION
para list()
.
La BUILD_LIST
instrucción:
Deberías ver el horror:
PyObject *list = PyList_New(oparg);
if (list == NULL)
goto error;
while (--oparg >= 0) {
PyObject *item = POP();
PyList_SET_ITEM(list, oparg, item);
}
PUSH(list);
DISPATCH();
Terriblemente complicado, lo sé. Así de sencillo es:
- Cree una nueva lista con
PyList_New
(esto principalmente asigna la memoria para un nuevo objeto de lista),oparg
indicando el número de argumentos en la pila. Directo al grano. - Comprueba que no salió nada mal
if (list==NULL)
. - Agregue cualquier argumento (en nuestro caso, esto no se ejecuta) ubicado en la pila con
PyList_SET_ITEM
(una macro).
¡No es de extrañar que sea rápido! Está hecho a medida para crear listas nuevas, nada más :-)
La CALL_FUNCTION
instrucción:
Esto es lo primero que ves cuando echas un vistazo al manejo del código CALL_FUNCTION
:
PyObject **sp, *res;
sp = stack_pointer;
res = call_function(&sp, oparg, NULL);
stack_pointer = sp;
PUSH(res);
if (res == NULL) {
goto error;
}
DISPATCH();
Parece bastante inofensivo, ¿verdad? Bueno, no, desafortunadamente no, call_function
no es un tipo sencillo que llame a la función inmediatamente, no puede hacerlo. En cambio, toma el objeto de la pila, toma todos los argumentos de la pila y luego cambia según el tipo de objeto; Es una:
PyCFunction_Type
? No, lo eslist
,list
no es de tipo.PyCFunction
PyMethodType
? No, ver anterior.PyFunctionType
? No, ver anterior.
Estamos llamando al list
tipo, el argumento pasado call_function
es PyList_Type
. CPython ahora tiene que llamar a una función genérica para manejar cualquier objeto invocable llamado _PyObject_FastCallKeywords
, sí, más llamadas a funciones.
Esta función vuelve a realizar algunas comprobaciones para ciertos tipos de funciones (que no puedo entender por qué) y luego, después de crear un dict para kwargs si es necesario , continúa llamando _PyObject_FastCallDict
.
_PyObject_FastCallDict
¡Por fin nos lleva a alguna parte! Después de realizar aún más comprobaciones, agarra la tp_call
ranura de latype
que type
hemos pasado, es decir, agarra type.tp_call
. Luego procede a crear una tupla a partir de los argumentos pasados _PyStack_AsTuple
y, finalmente, ¡ finalmente se puede realizar una llamada !
tp_call
, que coincide type.__call__
toma el control y finalmente crea el objeto de lista. Llama a las listas __new__
que corresponden PyType_GenericNew
y le asigna memoria PyType_GenericAlloc
: Esta es en realidad la parte donde finalmente se pone al díaPyList_New
. Todo lo anterior es necesario para manejar objetos de forma genérica.
Al final, type_call
llama list.__init__
e inicializa la lista con los argumentos disponibles, luego regresamos por donde vinimos. :-)
Finalmente, recuerden LOAD_NAME
, ese es otro tipo que contribuye aquí.
Es fácil ver que, cuando se trata de nuestra entrada, Python generalmente tiene que pasar por obstáculos para encontrar la C
función adecuada para hacer el trabajo. No tiene la cortesía de llamarlo inmediatamente porque es dinámico, alguien podría enmascararse list
( y mucha gente lo hace ) y hay que tomar otro camino.
Aquí es donde list()
se pierde mucho: Python debe explorar para descubrir qué diablos debe hacer.
La sintaxis literal, por otra parte, significa exactamente una cosa; no se puede cambiar y siempre se comporta de una manera predeterminada.
Nota al pie: Todos los nombres de funciones están sujetos a cambios de una versión a otra. El punto sigue vigente y muy probablemente seguirá vigente en versiones futuras: es la búsqueda dinámica la que ralentiza las cosas.