Pourquoi deux listes identiques ont-elles une empreinte mémoire différente?

155

J'ai créé deux listes l1et l2, mais chacune avec une méthode de création différente:

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

Mais la sortie m'a surpris:

Size of l1 = 144
Size of l2 = 192

La liste créée avec une compréhension de liste est une plus grande taille en mémoire, mais les deux listes sont identiques en Python sinon.

Pourquoi donc? Est-ce une chose interne à CPython, ou une autre explication?

Andrej Kesely
la source
2
Probablement, l'opérateur de répétition appellera une fonction qui dimensionne exactement le tableau sous-jacent. Notez que 144 == sys.getsizeof([]) + 8*10)où 8 est la taille d'un pointeur.
juanpa.arrivillaga
1
Notez que si vous passez 10à 11, la [None] * 11liste a une taille 152, mais la compréhension de la liste a toujours une taille 192. La question précédemment liée n'est pas une copie exacte, mais elle est pertinente pour comprendre pourquoi cela se produit.
Patrick Haugh

Réponses:

162

Lorsque vous écrivez [None] * 10, Python sait qu'il aura besoin d'une liste d'exactement 10 objets, donc il alloue exactement cela.

Lorsque vous utilisez une compréhension de liste, Python ne sait pas de combien il aura besoin. Ainsi, la liste augmente progressivement au fur et à mesure que des éléments sont ajoutés. Pour chaque réallocation, il alloue plus d'espace que ce qui est immédiatement nécessaire, de sorte qu'il n'a pas à réallouer pour chaque élément. La liste résultante sera probablement un peu plus longue que nécessaire.

Vous pouvez voir ce comportement lorsque vous comparez des listes créées avec des tailles similaires:

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

Vous pouvez voir que la première méthode alloue juste ce qui est nécessaire, tandis que la seconde se développe périodiquement. Dans cet exemple, il alloue assez pour 16 éléments, et a dû réallouer en atteignant le 17e.

interjay
la source
1
Oui, cela a du sens. Il vaut probablement mieux créer des listes avec *quand je connais la taille devant.
Andrej Kesely
27
@AndrejKesely Utilisez uniquement [x] * navec immuable xdans votre liste. La liste résultante contiendra des références à l'objet identique.
schwobaseggl
5
@schwobaseggl bien, c'est peut- être ce que vous voulez, mais il est bon de comprendre cela.
juanpa.arrivillaga
19
@ juanpa.arrivillaga C'est peut-être vrai. Mais en général, ce n'est pas le cas et en particulier SO est plein d'affiches se demandant pourquoi toutes leurs données ont changé simultanément: D
schwobaseggl
50

Comme indiqué dans cette question, la compréhension de la liste utilise list.appendsous le capot, elle appellera donc la méthode list-resize, qui surutilisera.

Pour vous le démontrer, vous pouvez réellement utiliser le disdissasembler:

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

Notez l' LIST_APPENDopcode dans le désassemblage de l' <listcomp>objet code. À partir de la documentation :

LIST_APPEND (i)

Appels list.append(TOS[-i], TOS). Utilisé pour implémenter des compréhensions de liste.

Maintenant, pour l'opération de répétition de liste, nous avons un indice sur ce qui se passe si nous considérons:

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

Donc, il semble être en mesure d' allouer exactement la taille. En regardant le code source , nous voyons que c'est exactement ce qui se passe:

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

A savoir, ici: size = Py_SIZE(a) * n;. Le reste des fonctions remplit simplement le tableau.

juanpa.arrivillaga
la source
"Comme indiqué dans cette question, la compréhension de la liste utilise list.append sous le capot" Je pense qu'il est plus juste de dire qu'elle utilise .extend().
Accumulation le
@Acccumulation pourquoi le croyez-vous?
juanpa.arrivillaga
Parce qu'il ne s'agit pas d'ajouter des éléments un par un. Lorsque vous ajoutez des éléments à une liste, vous créez vraiment une nouvelle liste, avec une nouvelle allocation de mémoire, et vous placez la liste dans cette nouvelle allocation de mémoire. Les compréhensions de listes, par contre, mettent en mémoire la plupart des nouveaux éléments qui ont déjà été alloués, et quand elles sont à court de mémoire allouée, elles allouent un autre bloc de mémoire, pas juste assez pour le nouvel élément.
Accumulation le
7
@Acccumulation C'est incorrect. list.appendest une opération à temps constant amorti car lorsqu'une liste se redimensionne, elle surutilisera. Toutes les opérations d'ajout ne donnent donc pas lieu à un nouveau tableau alloué. En tout cas , la question que je vous montre lié à dans le code source qui en fait, compréhensions liste font usage list.append,. Je serai de retour à mon ordinateur portable dans un instant et je peux vous montrer le bytecode démonté pour une compréhension de la liste et l' LIST_APPENDopcode correspondant
juanpa.arrivillaga
3

Aucun n'est un bloc de mémoire, mais ce n'est pas une taille pré-spécifiée. En plus de cela, il y a un espacement supplémentaire dans un tableau entre les éléments du tableau. Vous pouvez le voir vous-même en exécutant:

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

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

Ce qui ne totalise pas la taille de l2, mais est plutôt moindre.

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

Et c'est beaucoup plus d'un dixième de la taille de l1 .

Vos chiffres doivent varier en fonction des détails de votre système d'exploitation et des détails de l'utilisation actuelle de la mémoire dans votre système d'exploitation. La taille de [Aucun] ne peut jamais être plus grande que la mémoire adjacente disponible où la variable est définie pour être stockée, et la variable peut devoir être déplacée si elle est allouée dynamiquement plus tard pour être plus grande.

StevenJD
la source
1
Nonen'est pas réellement stocké dans le tableau sous-jacent, la seule chose qui est stockée est un PyObjectpointeur (8 octets). Tous les objets Python sont alloués sur le tas. Noneest un singleton, donc avoir une liste avec de nombreux nones est simplement créer un tableau de pointeurs PyObject vers le même Noneobjet sur le tas (et ne pas utiliser de mémoire supplémentaire dans le processus par supplémentaire None). Je ne suis pas sûr de ce que vous entendez par "Aucun n'a une taille pré-spécifiée", mais cela ne semble pas correct. Enfin, votre boucle avec getsizeofchaque élément ne montre pas ce que vous semblez penser qu'elle démontre.
juanpa.arrivillaga
Si comme vous le dites est vrai, la taille de [None] * 10 doit être la même que celle de [None]. Mais ce n'est clairement pas le cas - un espace de stockage supplémentaire a été ajouté. En fait, la taille de [Aucun] répétée dix fois (160) est également inférieure à la taille de [Aucun] multipliée par dix. Comme vous le faites remarquer, il est clair que la taille du pointeur vers [Aucun] est inférieure à la taille de [Aucun] lui-même (16 octets au lieu de 72 octets). Cependant, 160 + 32 est 192. Je ne pense pas que la réponse précédente résout complètement le problème non plus. Il est clair qu'une petite quantité de mémoire supplémentaire (peut-être dépendante de l'état de la machine) est allouée.
StevenJD
"Si comme vous le dites est vrai, la taille de [Aucun] * 10 devrait être la même que celle de [Aucun]" que dis-je qui pourrait éventuellement impliquer cela? Encore une fois, vous semblez vous concentrer sur le fait que le tampon sous-jacent est suralloué, ou que la taille de la liste comprend plus que la taille du tampon sous-jacent (c'est bien sûr le cas), mais ce n'est pas le but de cette question. Encore une fois, votre utilisation de gestsizeofsur chacun elede l2est trompeuse car getsizeof(l2) ne prend pas en compte la taille des éléments à l'intérieur du conteneur .
juanpa.arrivillaga
Pour vous prouver cette dernière revendication, faites l1 = [None]; l2 = [None]*100; l3 = [l2]alors print(sys.getsizeof(l1), sys.getsizeof(l2), sys.getsizeof(l3)). vous obtiendrez un résultat comme: 72 864 72. C'est, respectivement, 64 + 1*8, 64 + 100*8et 64 + 1*8, encore une fois, en supposant un système 64 bits avec la taille du pointeur de 8 octets.
juanpa.arrivillaga
1
Comme je l'ai dit, sys.getsizeof* ne tient pas compte de la taille des articles dans le conteneur. D'après la documentation : "Seule la consommation de mémoire directement attribuée à l'objet est prise en compte, pas la consommation de mémoire des objets auxquels il se réfère ... Voir la recette récursive sizeof pour un exemple d'utilisation récursive de getsizeof () pour trouver la taille des conteneurs et tout leur contenu. "
juanpa.arrivillaga