¿Por qué dos listas idénticas tienen una huella de memoria diferente?

155

Creé dos listas l1y l2, pero cada una con un método de creación diferente:

import sys

l1 = [None] * 10
l2 = [None for _ in range(10)]

print('Size of l1 =', sys.getsizeof(l1))
print('Size of l2 =', sys.getsizeof(l2))

Pero la salida me sorprendió:

Size of l1 = 144
Size of l2 = 192

La lista creada con una comprensión de la lista es de mayor tamaño en la memoria, pero las dos listas son idénticas en Python de lo contrario.

¿Porqué es eso? ¿Es esto algo interno de CPython, o alguna otra explicación?

Andrej Kesely
fuente
2
Probablemente, el operador de repetición invocará alguna función que dimensione exactamente la matriz subyacente. Tenga en cuenta que 144 == sys.getsizeof([]) + 8*10)donde 8 es el tamaño de un puntero.
juanpa.arrivillaga
1
Tenga en cuenta que si cambia 10a 11, la [None] * 11lista tiene tamaño 152, pero la comprensión de la lista todavía tiene tamaño 192. La pregunta vinculada anteriormente no es un duplicado exacto, pero es relevante para entender por qué sucede esto.
Patrick Haugh el

Respuestas:

162

Cuando escribe [None] * 10, Python sabe que necesitará una lista de exactamente 10 objetos, por lo que asigna exactamente eso.

Cuando utiliza una lista de comprensión, Python no sabe cuánto necesitará. Por lo tanto, gradualmente crece la lista a medida que se agregan elementos. Para cada reasignación, asigna más espacio del que se necesita inmediatamente, de modo que no tiene que reasignarse para cada elemento. Es probable que la lista resultante sea algo más grande de lo necesario.

Puede ver este comportamiento al comparar listas creadas con tamaños similares:

>>> sys.getsizeof([None]*15)
184
>>> sys.getsizeof([None]*16)
192
>>> sys.getsizeof([None for _ in range(15)])
192
>>> sys.getsizeof([None for _ in range(16)])
192
>>> sys.getsizeof([None for _ in range(17)])
264

Puede ver que el primer método asigna exactamente lo que se necesita, mientras que el segundo crece periódicamente. En este ejemplo, asigna suficiente para 16 elementos, y tuvo que reasignarse al llegar al 17.

interjay
fuente
1
Sí, eso tiene sentido. Probablemente sea mejor crear listas con *cuando sepa el tamaño al frente.
Andrej Kesely
27
@AndrejKesely Solo utilícelo [x] * ncon inmutable xen su lista. La lista resultante contendrá referencias al objeto idéntico.
schwobaseggl
55
@schwobaseggl bueno, eso puede ser lo que quieres, pero es bueno entender eso.
juanpa.arrivillaga
19
@ juanpa.arrivillaga Cierto, puede ser. Pero por lo general no lo es y particularmente SO está lleno de carteles que se preguntan por qué todos sus datos cambiaron simultáneamente: D
schwobaseggl
50

Como se señaló en esta pregunta, la comprensión de la lista se utiliza list.appenddebajo del capó, por lo que llamará al método de cambio de tamaño de la lista, que se sobreasigna.

Para demostrarte esto a ti mismo, puedes usar el disdesensamblador:

>>> code = compile('[x for x in iterable]', '', 'eval')
>>> import dis
>>> dis.dis(code)
  1           0 LOAD_CONST               0 (<code object <listcomp> at 0x10560b810, file "", line 1>)
              2 LOAD_CONST               1 ('<listcomp>')
              4 MAKE_FUNCTION            0
              6 LOAD_NAME                0 (iterable)
              8 GET_ITER
             10 CALL_FUNCTION            1
             12 RETURN_VALUE

Disassembly of <code object <listcomp> at 0x10560b810, file "", line 1>:
  1           0 BUILD_LIST               0
              2 LOAD_FAST                0 (.0)
        >>    4 FOR_ITER                 8 (to 14)
              6 STORE_FAST               1 (x)
              8 LOAD_FAST                1 (x)
             10 LIST_APPEND              2
             12 JUMP_ABSOLUTE            4
        >>   14 RETURN_VALUE
>>>

Observe el LIST_APPENDcódigo de operación en el desmontaje del <listcomp>objeto de código. De los documentos :

LIST_APPEND (i)

Llamadas list.append(TOS[-i], TOS). Se usa para implementar listas de comprensión.

Ahora, para la operación de repetición de lista, tenemos una pista sobre lo que está sucediendo si consideramos:

>>> import sys
>>> sys.getsizeof([])
64
>>> 8*10
80
>>> 64 + 80
144
>>> sys.getsizeof([None]*10)
144

Entonces, parece ser capaz de asignar exactamente el tamaño. Mirando el código fuente , vemos que esto es exactamente lo que sucede:

static PyObject *
list_repeat(PyListObject *a, Py_ssize_t n)
{
    Py_ssize_t i, j;
    Py_ssize_t size;
    PyListObject *np;
    PyObject **p, **items;
    PyObject *elem;
    if (n < 0)
        n = 0;
    if (n > 0 && Py_SIZE(a) > PY_SSIZE_T_MAX / n)
        return PyErr_NoMemory();
    size = Py_SIZE(a) * n;
    if (size == 0)
        return PyList_New(0);
    np = (PyListObject *) PyList_New(size);

Es decir, aquí: size = Py_SIZE(a) * n;. El resto de las funciones simplemente llena la matriz.

juanpa.arrivillaga
fuente
"Como se señaló en esta pregunta, la comprensión de la lista usa list.append under the hood" Creo que es más exacto decir que usa .extend().
Acumulación
@Acumulación ¿por qué crees eso?
juanpa.arrivillaga
Porque no está agregando elementos uno por uno. Cuando agrega elementos a una lista, realmente está creando una nueva lista, con una nueva asignación de memoria, y colocando la lista en esa nueva asignación de memoria. Las comprensiones de listas, por otro lado, colocan la mayoría de los elementos nuevos en la memoria que ya ha sido asignada, y cuando se quedan sin memoria asignada, asignan otra tirada de memoria, no solo lo suficiente para el nuevo elemento.
Acumulación
77
@ Acumulación Eso es incorrecto. list.appendes una operación amortizada de tiempo constante porque cuando una lista cambia de tamaño, se sobreasigna. No todas las operaciones de agregar, por lo tanto, dan como resultado una matriz recién asignada. En cualquier caso, la pregunta a la que he vinculado le muestra en el código fuente que, de hecho, las comprensiones de listas usan list.append. Regresaré a mi computadora portátil en un momento y puedo mostrarles el bytecode desmontado para una comprensión de la lista y el LIST_APPENDcódigo de operación correspondiente
juanpa.arrivillaga
3

Ninguno es un bloque de memoria, pero no es un tamaño especificado previamente. Además de eso, hay un espacio adicional en una matriz entre los elementos de la matriz. Puede ver esto usted mismo ejecutando:

for ele in l2:
    print(sys.getsizeof(ele))

>>>>16
16
16
16
16
16
16
16
16
16

Lo que no totaliza el tamaño de l2, sino que es menor.

print(sys.getsizeof([None]))
72

Y esto es mucho mayor que una décima parte del tamaño de l1.

Sus números deben variar según los detalles de su sistema operativo y los detalles del uso de memoria actual en su sistema operativo. El tamaño de [Ninguno] nunca puede ser mayor que la memoria adyacente disponible donde la variable está configurada para ser almacenada, y la variable puede tener que moverse si luego se asigna dinámicamente para que sea más grande.

StevenJD
fuente
1
Noneen realidad no se almacena en la matriz subyacente, lo único que se almacena es un PyObjectpuntero (8 bytes). Todos los objetos de Python se asignan en el montón. Nonees un singleton, por lo que tener una lista con muchos nones simplemente creará una matriz de punteros PyObject para el mismo Noneobjeto en el montón (y no usará memoria adicional en el proceso por cada adicional None). No estoy seguro de lo que quiere decir con "Ninguno no tiene un tamaño predeterminado", pero eso no suena correcto. Finalmente, su ciclo con getsizeofcada elemento no demuestra lo que parece pensar que está demostrando.
juanpa.arrivillaga
Si, como usted dice, es cierto, el tamaño de [Ninguno] * 10 debería ser el mismo que el tamaño de [Ninguno]. Pero claramente esto no es así: se ha agregado algo de almacenamiento adicional. De hecho, el tamaño de [Ninguno] repetido diez veces (160) también es menor que el tamaño de [Ninguno] multiplicado por diez. Como señala, claramente el tamaño del puntero a [Ninguno] es menor que el tamaño de [Ninguno] en sí (16 bytes en lugar de 72 bytes). Sin embargo, 160 + 32 es 192. Tampoco creo que la respuesta anterior resuelva el problema por completo. Está claro que se asigna una cantidad extra pequeña de memoria (quizás dependiente del estado de la máquina).
StevenJD
"Si, como usted dice, es cierto, el tamaño de [Ninguno] * 10 debería ser el mismo que el tamaño de [Ninguno]" ¿qué estoy diciendo que podría implicar eso? Nuevamente, parece que se está concentrando en el hecho de que el búfer subyacente está sobreasignado, o que el tamaño de la lista incluye más que el tamaño del búfer subyacente (por supuesto que sí), pero ese no es el punto de esta pregunta. Una vez más, el uso por parte gestsizeofde cada uno elede los l2induce a error porque getsizeof(l2) no tiene en cuenta el tamaño de los elementos en el interior del contenedor .
juanpa.arrivillaga
Para demostrar a sí mismo que la última afirmación, hacer l1 = [None]; l2 = [None]*100; l3 = [l2]a continuación print(sys.getsizeof(l1), sys.getsizeof(l2), sys.getsizeof(l3)). obtendrá un resultado como: 72 864 72. Eso es, respectivamente, 64 + 1*8, 64 + 100*8, y 64 + 1*8, de nuevo, suponiendo un sistema de 64 bits con 8 bytes tamaño del puntero.
juanpa.arrivillaga
1
Como he dicho, sys.getsizeof* no tiene en cuenta el tamaño de los artículos en el contenedor. De los documentos : "Solo se contabiliza el consumo de memoria directamente atribuido al objeto, no el consumo de memoria de los objetos a los que se refiere ... Vea el tamaño recursivo de la receta para ver un ejemplo del uso de getsizeof () recursivamente para encontrar el tamaño de los contenedores y todos sus contenidos ".
juanpa.arrivillaga