Por que duas listas idênticas têm uma pegada de memória diferente?

155

Criei duas listas l1e l2, cada uma com um método de criação 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))

Mas a saída me surpreendeu:

Size of l1 = 144
Size of l2 = 192

A lista criada com uma compreensão da lista tem um tamanho maior na memória, mas as duas listas são idênticas no Python.

Por que é que? Isso é alguma coisa interna do CPython, ou alguma outra explicação?

Andrej Kesely
fonte
2
Provavelmente, o operador de repetição chamará alguma função que dimensione exatamente a matriz subjacente. Observe que 144 == sys.getsizeof([]) + 8*10)onde 8 é do tamanho de um ponteiro.
Juanpa.arrivillaga
1
Observe que, se você alterar 10para 11, a [None] * 11lista terá tamanho 152, mas a compreensão da lista ainda terá tamanho 192. A pergunta anteriormente vinculada não é uma duplicata exata, mas é relevante para entender por que isso acontece.
Patrick Haugh

Respostas:

162

Quando você escreve [None] * 10, o Python sabe que precisará de uma lista de exatamente 10 objetos, portanto, aloca exatamente isso.

Quando você usa uma compreensão de lista, o Python não sabe quanto será necessário. Por isso, aumenta gradualmente a lista à medida que os elementos são adicionados. Para cada realocação, ele aloca mais espaço do que o necessário imediatamente, para que não precise realocar para cada elemento. A lista resultante provavelmente será um pouco maior que o necessário.

Você pode ver esse comportamento ao comparar listas criadas com tamanhos semelhantes:

>>> 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

Você pode ver que o primeiro método aloca exatamente o necessário, enquanto o segundo cresce periodicamente. Neste exemplo, ele aloca o suficiente para 16 elementos e precisou ser realocado ao atingir o 17º.

interjay
fonte
1
Sim, isso faz sentido. Provavelmente é melhor criar listas *quando sei o tamanho à frente.
Andrej Kesely
27
@AndrejKesely Use apenas [x] * ncom imutável xna sua lista. A lista resultante conterá referências ao objeto idêntico.
22418 schwobaseggl
5
@schwobaseggl Bem, isso pode ser o que você deseja, mas é bom entender isso.
Juanpa.arrivillaga
19
@ juanpa.arrivillaga É verdade que pode ser. Mas geralmente não é e particularmente o SO está cheio de pôsteres imaginando por que todos os dados foram alterados simultaneamente: D
schwobaseggl
50

Conforme observado nesta pergunta, a compreensão da lista usa list.appendsob o capô, portanto, ele chamará o método de redimensionamento de lista, que atribui globalmente.

Para demonstrar isso, você pode realmente usar o disdesmontador:

>>> 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 o LIST_APPENDcódigo de operação na desmontagem do <listcomp>objeto de código. Dos documentos :

LIST_APPEND (i)

Chamadas list.append(TOS[-i], TOS). Usado para implementar a compreensão da lista.

Agora, para a operação de repetição de lista, temos uma dica sobre o que está acontecendo se considerarmos:

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

Portanto, parece poder alocar exatamente o tamanho. Observando o código fonte , vemos que é exatamente isso que acontece:

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);

Ou seja, aqui: size = Py_SIZE(a) * n;. O restante das funções simplesmente preenche a matriz.

juanpa.arrivillaga
fonte
"Como observado nesta pergunta, a compreensão da lista usa list.append sob o capô" Eu acho que é mais preciso dizer que ele usa .extend().
Acccumulation
@ Acumulação Por que você acredita?
Juanpa.arrivillaga
Porque não é anexar elementos um por um. Quando você anexa elementos a uma lista, está realmente criando uma nova lista, com uma nova alocação de memória e colocando a lista nessa nova alocação de memória. As compreensões de lista, por outro lado, colocam na memória a maioria dos novos elementos que já foram alocados e, quando ficam sem memória alocada, alocam outra porção de memória, não apenas o suficiente para o novo elemento.
Acccumulation
7
@ Accumulation Isso está incorreto. list.appendé uma operação de tempo constante amortizada porque, quando uma lista é redimensionada, ela é atribuída globalmente. Portanto, nem toda operação de acréscimo resulta em uma matriz recém-alocada. Em qualquer caso, a pergunta que eu ligado a mostra-lo no código-fonte que, na verdade, compreensões lista fazer uso list.append,. Volto para o meu laptop em um momento e eu posso mostrar-lhe o bytecode desmontado para uma compreensão da lista e do correspondente LIST_APPENDcódigo de operação
juanpa.arrivillaga
3

Nenhum é um bloco de memória, mas não é um tamanho pré-especificado. Além disso, há algum espaçamento extra em uma matriz entre os elementos da matriz. Você pode ver isso executando:

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

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

O que não totaliza o tamanho de l2, mas é menor.

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

E isso é muito maior que um décimo do tamanho de l1.

Seus números devem variar de acordo com os detalhes do seu sistema operacional e os detalhes do uso atual da memória no seu sistema operacional. O tamanho de [None] nunca pode ser maior que a memória adjacente disponível onde a variável está configurada para ser armazenada e a variável pode precisar ser movida se posteriormente for alocada dinamicamente para ser maior.

StevenJD
fonte
1
Nonenão é realmente armazenado na matriz subjacente, a única coisa que é armazenada é um PyObjectponteiro (8 bytes). Todos os objetos Python são alocados no heap. Noneé um singleton, portanto, ter uma lista com muitos nomes simplesmente criará uma matriz de ponteiros PyObject para o mesmo Noneobjeto na pilha (e não usará memória adicional no processo por adicional None). Não sei ao certo o que você quer dizer com "Nenhum não tem um tamanho pré-especificado", mas isso não parece correto. Finalmente, seu loop com getsizeofcada elemento não está demonstrando o que você parece pensar que está demonstrando.
Juanpa.arrivillaga 31/07
Se, como você diz, for verdade, o tamanho de [Nenhum] * 10 deve ser igual ao tamanho de [Nenhum]. Mas claramente isso não é verdade - foi adicionado algum armazenamento extra. De fato, o tamanho de [Nenhum] repetido dez vezes (160) também é menor que o tamanho de [Nenhum] multiplicado por dez. Como você aponta, claramente o tamanho do ponteiro para [Nenhum] é menor que o tamanho de [Nenhum] (16 bytes em vez de 72 bytes). No entanto, 160 + 32 é 192. Também não acho que a resposta anterior resolva o problema completamente. É claro que uma quantidade extra pequena de memória (talvez dependente do estado da máquina) está alocada.
StevenJD
"Se, como você diz, é verdade, o tamanho de [None] * 10 deve ser igual ao tamanho de [None]", o que estou dizendo que poderia implicar isso? Novamente, você parece estar se concentrando no fato de que o buffer subjacente está superalocado ou que o tamanho da lista inclui mais do que o tamanho do buffer subjacente (é claro que sim), mas esse não é o ponto de essa questão. Mais uma vez, o seu uso gestsizeofem cada elede l2é enganosa, porque getsizeof(l2) não leva em conta o tamanho dos elementos dentro do recipiente .
Juanpa.arrivillaga
Para provar a si mesmo essa última reivindicação, faça l1 = [None]; l2 = [None]*100; l3 = [l2]-o print(sys.getsizeof(l1), sys.getsizeof(l2), sys.getsizeof(l3)). você vai ter um resultado como: 72 864 72. Isto é, respectivamente, 64 + 1*8, 64 + 100*8, e 64 + 1*8, novamente, assumindo um sistema de 64 bits com tamanho ponteiro de 8 bytes.
Juanpa.arrivillaga
1
Como afirmei, sys.getsizeof* não é responsável pelo tamanho dos itens no contêiner. Na documentação : "Somente o consumo de memória diretamente atribuído ao objeto é contabilizado, não o consumo de memória dos objetos aos quais ele se refere ... Consulte receita recursiva de sizeof para obter um exemplo de uso de getsizeof () recursivamente para encontrar o tamanho de contêineres e todo o seu conteúdo ".
Juanpa.arrivillaga