list () usa un poco más de memoria que la comprensión de listas

79

Entonces, estaba jugando con listobjetos y encontré una pequeña cosa extraña que, si listse crea con list(), usa más memoria que la comprensión de listas. Estoy usando Python 3.5.2

In [1]: import sys
In [2]: a = list(range(100))
In [3]: sys.getsizeof(a)
Out[3]: 1008
In [4]: b = [i for i in range(100)]
In [5]: sys.getsizeof(b)
Out[5]: 912
In [6]: type(a) == type(b)
Out[6]: True
In [7]: a == b
Out[7]: True
In [8]: sys.getsizeof(list(b))
Out[8]: 1008

De los documentos :

Las listas se pueden construir de varias formas:

  • Usando un par de corchetes para denotar la lista vacía: []
  • El uso de corchetes, que separa los elementos con comas: [a],[a, b, c]
  • Usando una lista de comprensión: [x for x in iterable]
  • Usando el constructor de tipos: list()olist(iterable)

Pero parece que usarlo list()consume más memoria.

Y cuanto más listgrande, la brecha aumenta.

Diferencia en la memoria

¿Por qué sucede esto?

ACTUALIZACIÓN # 1

Prueba con Python 3.6.0b2:

Python 3.6.0b2 (default, Oct 11 2016, 11:52:53) 
[GCC 5.4.0 20160609] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> sys.getsizeof(list(range(100)))
1008
>>> sys.getsizeof([i for i in range(100)])
912

ACTUALIZACIÓN # 2

Prueba con Python 2.7.12:

Python 2.7.12 (default, Jul  1 2016, 15:12:24) 
[GCC 5.4.0 20160609] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> sys.getsizeof(list(xrange(100)))
1016
>>> sys.getsizeof([i for i in xrange(100)])
920
vishes_shell
fuente
3
Esa es una pregunta muy interesante. Puedo reproducir el fenómeno en Python 3.4.3. Aún más interesante: en Python 2.7.5 sys.getsizeof(list(range(100)))es 1016, getsizeof(range(100))es 872 y getsizeof([i for i in range(100)])es 920. Todos tienen el tipo list.
Sven Festersen
Es interesante que esta diferencia también existe en Python 2.7.10 (aunque los números reales son diferentes de Python 3). También hay en 3.5 y 3.6b.
cdarke
Obtengo los mismos números para Python 2.7.6 que @SvenFestersen, también cuando uso xrange.
RemcoGerlich
2
Hay una posible explicación aquí: stackoverflow.com/questions/7247298/size-of-list-in-memory . Si uno de los métodos crea la lista usando append(), puede haber una sobreasignación de memoria. Supongo que la única forma de aclarar esto es echar un vistazo a las fuentes de Python.
Sven Festersen
Solo un 10% más (realmente no dices eso en ninguna parte). Reformularía el título como "un poco más".
smci

Respuestas:

61

Creo que está viendo patrones de asignación excesiva, esta es una muestra de la fuente :


Al imprimir los tamaños de listas por comprensión de longitudes 0-88, puede ver las coincidencias de patrones:

# create comprehensions for sizes 0-88
comprehensions = [sys.getsizeof([1 for _ in range(l)]) for l in range(90)]

# only take those that resulted in growth compared to previous length
steps = zip(comprehensions, comprehensions[1:])
growths = [x for x in list(enumerate(steps)) if x[1][0] != x[1][1]]

# print the results:
for growth in growths:
    print(growth)

Resultados (el formato es (list length, (old total size, new total size))):

(0, (64, 96)) 
(4, (96, 128))
(8, (128, 192))
(16, (192, 264))
(25, (264, 344))
(35, (344, 432))
(46, (432, 528))
(58, (528, 640))
(72, (640, 768))
(88, (768, 912))

La sobreasignación se realiza por motivos de rendimiento, lo que permite que las listas crezcan sin asignar más memoria con cada crecimiento (mejor rendimiento amortizado ).

Una razón probable de la diferencia con el uso de la comprensión de listas es que la comprensión de listas no puede calcular de manera determinista el tamaño de la lista generada, pero list()sí. Esto significa que las comprensiones harán crecer continuamente la lista a medida que la llene usando una sobreasignación hasta que finalmente la llene.

Es posible que no aumente el búfer de sobreasignación con nodos asignados no utilizados una vez que se haya hecho (de hecho, en la mayoría de los casos, eso anularía el propósito de sobreasignación).

list(), sin embargo, puede agregar algo de búfer sin importar el tamaño de la lista, ya que conoce el tamaño final de la lista de antemano.


Otra evidencia de respaldo, también de la fuente, es que vemos la invocación de listas por comprensiónLIST_APPEND , lo que indica el uso delist.resize , lo que a su vez indica consumir el búfer de preasignación sin saber cuánto se llenará. Esto es consistente con el comportamiento que está viendo.


Para concluir, list()preasignará más nodos en función del tamaño de la lista

>>> sys.getsizeof(list([1,2,3]))
60
>>> sys.getsizeof(list([1,2,3,4]))
64

La comprensión de la lista no conoce el tamaño de la lista, por lo que utiliza operaciones de adición a medida que crece, lo que agota el búfer de preasignación:

# one item before filling pre-allocation buffer completely
>>> sys.getsizeof([i for i in [1,2,3]]) 
52
# fills pre-allocation buffer completely
# note that size did not change, we still have buffered unused nodes
>>> sys.getsizeof([i for i in [1,2,3,4]]) 
52
# grows pre-allocation buffer
>>> sys.getsizeof([i for i in [1,2,3,4,5]])
68
Reut Sharabani
fuente
4
Pero, ¿por qué ocurriría la sobreasignación con uno pero no con el otro?
cdarke
Esto específicamente es de list.resize. No soy un experto en navegar a través de la fuente, pero si uno llama a resize y el otro no, podría explicar la diferencia.
Reut Sharabani
6
Python 3.5.2 aquí. Intente imprimir tamaños de listas de 0 a 35 en bucle. Para la lista que veo 64, 96, 104, 112, 120, 128, 136, 144, 160, 192, 200, 208, 216, 224, 232, 240, 256, 264, 272, 280, 288, 296, 304, 312, 328, 336, 344, 352, 360, 368, 376, 384, 400, 408, 416y para la comprensión 64, 96, 96, 96, 96, 128, 128, 128, 128, 192, 192, 192, 192, 192, 192, 192, 192, 264, 264, 264, 264, 264, 264, 264, 264, 264, 344, 344, 344, 344, 344, 344, 344, 344, 344. Excepto que la comprensión es la que parece preasignar memoria para ser el algoritmo que usa más RAM para ciertos tamaños.
tavo
Yo esperaría lo mismo. Puedo investigarlo más a fondo pronto. Buenos comentarios.
Reut Sharabani
4
en realidad, list()determina de manera determinista el tamaño de la lista, lo que la comprensión de la lista no puede hacer. Esto sugiere que la comprensión de la lista no siempre "desencadena" el "último" crecimiento de la lista. Podría tener sentido.
Reut Sharabani
30

Gracias a todos por ayudarme a comprender ese increíble Python.

No quiero hacer una pregunta tan masiva (por eso estoy publicando la respuesta), solo quiero mostrar y compartir mis pensamientos.

Como @ReutSharabani señaló correctamente: "list () determina determinísticamente el tamaño de la lista". Puedes verlo en ese gráfico.

gráfico de tamaños

Cuando usas la appendcomprensión de listas, siempre tienes algún tipo de límites que se extienden cuando llegas a algún punto. Y list()contigo tienes casi los mismos límites, pero están flotando.

ACTUALIZAR

Así que gracias a @ReutSharabani , @tavo , @SvenFestersen

En resumen: la list()memoria preasignada depende del tamaño de la lista, la comprensión de la lista no puede hacer eso (solicita más memoria cuando la necesita, como .append()). Es por esolist() almacena más memoria.

Un gráfico más, que muestra la list()memoria preasignada. Entonces, la línea verde muestra que se list(range(830))agrega elemento por elemento y durante un tiempo la memoria no cambia.

list () preasigna memoria

ACTUALIZACIÓN 2

Como @Barmar señaló en los comentarios a continuación, list()debo ser más rápido que la comprensión de la lista, así que corrí timeit()con la number=1000longitud de listdesde 4**0hasta 4**10y los resultados son

medidas de tiempo

vishes_shell
fuente
1
La respuesta de por qué la línea roja está arriba de la azul es que cuando el listconstructor puede determinar el tamaño de la nueva lista a partir de su argumento, aún preasignará la misma cantidad de espacio que si el último elemento llegara allí y no hubiera suficiente espacio para él. Al menos eso es lo que tiene sentido para mí.
tavo
@tavo me parece lo mismo, después de un momento quiero mostrarlo en el gráfico.
vishes_shell
2
Entonces, aunque las listas por comprensión usan menos memoria, probablemente sean significativamente más lentas debido a todos los cambios de tamaño que ocurren. Estos a menudo tendrán que copiar la columna vertebral de la lista a una nueva área de memoria.
Barmar
@Barmar en realidad puedo ejecutar algunas mediciones de tiempo con el rangeobjeto (eso podría ser divertido).
vishes_shell
Y hará que tus gráficos sean aún más bonitos. :)
Barmar