espace mémoire des objets Python

a marqué ce sujet comme résolu.

Bonjour,

En essayant de trouver une méthode pour évaluer la mémoire prise par mes objets Python, j’ai trouvé deux outils :

  • sys.getsizeof(obj)
  • pympler.asizeof.asizeof(obj)

Mystérieusement, pour un même objet, ces deux fonctions ne me retournent pas toujours le même résultat (par exemple, pour [0,1,2,3,4,5,6,7,8,9] le module sys me donne 136 octets et pympler 456 octets…).

Du coup, je ne sais pas trop à quelle méthode me fier…

Merci d’avance,

@flopy78

Salut,

Comme indiqué dans la doc, sys.getsizeof n’est pas récursif. Autrement dit, il donne la taille de la liste elle-même (le conteneur), mais ne prend pas en compte ses éléments. La différence de 320 octets correspond aux 32 octets (!) que chaque int prend en mémoire.

+0 -0

Il y a 320 octets d’écart.

Essaie avec un tableau 100 fois plus grand, tous les nombres jusqu’à 1000. Soit il y a toujours ces 320 mêmes octets d’écart, soit l’écart change. Et tu pourras normalement en tirer les conclusions utiles.

Edit : adri1 a été plus rapide, et surtout plus complet que moi. J’en conclue donc que pour 1000 nombres, la première méthode donnera toujours 136 octets. A toi de le vérifier.

J’en conclue donc que pour 1000 nombres, la première méthode donnera toujours 136 octets.

C’est pas forcément le cas, ça dépend des détails d’implémentation de list. Je serais pas étonné qu’il y ait un conteneur internalisé susceptible de grandir dans la list lorsque le nombre d’éléments augmente (de la même façon que les int grandissent aussi lors de dépassements). La taille d’un objet en Python n’est pas statique.

+1 -0

Il y avait un détail qui me chiffonnait dans ma toute première réponse, j’avais en mémoire qu’un int en Python prend 28 octets lorsque la valeur rentre dans un entier de 32 bits, la valeur de 32 octets me paraissait donc un peu étrange même si pas complètement à côté de la plaque.

Je viens de prendre un moment pour vérifier ça, ainsi que le fait que la list grandit.

Avec ce code

import sys

from pympler.asizeof import asizeof

for i in range(10):
    l = list(range(i))
    gso = sys.getsizeof(l)
    aso = asizeof(l)
    diff = aso - gso
    print(i, gso, aso, diff, diff / i if i else None)

print(sys.getsizeof(0), asizeof(0))
print(sys.getsizeof(int(1e10)), asizeof(int(1e10)))
print(sys.getsizeof(int(1e100)), asizeof(int(1e100)))
print(sys.getsizeof([int(1e100)]), asizeof([int(1e100)]))

J’obtiens la sortie suivante :

0 56 56 0 None
1 72 104 32 32.0
2 72 136 64 32.0
3 88 184 96 32.0
4 88 216 128 32.0
5 104 264 160 32.0
6 104 296 192 32.0
7 120 344 224 32.0
8 120 376 256 32.0
9 136 424 288 32.0
28 32
32 32
72 72
64 136

Avec la boucle, on voit deux choses :

  • comme je le suspectai, la taille d’une liste augmente linéairement (enfin presque, par pas de 16 sur des listes de cette taille) avec le nombre d’éléments pour pouvoir stocker les pointeurs (8 octets par pointeur sur mon système x64) ;
  • la différence entre les deux mesures de tailles est bien directement proportionnelle à la taille avec 32 bits par éléments (aussi confirmé par la toute dernière ligne qui compare deux listes qui contiennent un entier très large).

Avec les autres lignes, on peut mettre en évidence un "bug" de pympler : il considère (peut être parce que je suis sur un système 64 bits) que les entiers sont au moins 64 bits en back end même lorsqu’ils rentrent dans 32 bits, surestimant la taille des petits entiers de 4 octets. Les tailles sont bonnes pour des entiers plus larges. Cela dit, si on est à 4 octets prêt, c’est probablement que Python n’est pas le bon outil pour ce qu’on fait. La doc de pympler précise d’ailleurs que les tailles peuvent êtres approximatives :

Function asizeof calculates the combined (approximate) size in bytes of one or several Python objects.

C’est assez logique, on ne peut pas accéder aux tailles d’objets arbitraires sans passer par un outil beaucoup plus lourd comme valgrind.

+2 -0

Bonjour,

Merci beaucoup !

Si j’ai bien compris ce que vous m’avez dit, on pourrait implémenter la fonction suivante pour déterminer la taille de notre liste :

import sys

def get_liste_size(liste):
    size = sys.getsizeof(liste) #la taille de la liste sans prendre en compte les éléments
    for element in liste:
        size += sys.getsizeof(element) #on rajoute la taille de chacun des éléments
    return size

A moins que je ne sois encore passé à côté d’autre chose…

Merci beaucoup,

@flopy78

Presque. Tu devrais lire les docs de ces fonctions (comme souvent avec tes questions, la réponse est dans la doc… :-° ), parce qu’elles expliquent d’autres subtilités (avec notamment un lien vers une implémentation récursive moins naïve). Notamment :

  • si le même objet est présent deux fois dans la liste, ta méthode va le compter deux fois alors que l’objet n’est pas dupliqué en mémoire ;
  • getsizeof n’est pas infaillible. Elle est généralement incorrecte pour les objets exposés via une extension qui passe par l’API C. Par curiosité, j’ai testé sur un objet que j’expose via Rust. getsizeof renvoie 24 (probablement un pointeur, un refcount, et je ne suis pas sûr d’où vient le troisième octet, peut être un pointeur vers une vtable ?), ce qui ne correspond pas du tout à la vraie taille en mémoire de l’objet derrière le pointeur, et pympler renvoie carrément 0.
+1 -0

Bonjour,

Merci beaucoup !

Quand j’ai commencé à m’intéresser à la question, je voulais comparer l’espace occupé par une liste et celui occupé par son équivalent sous forme de générateur.

Normalement, je ne passe pas par l’API C ou une autre API relative à un autre langage dans ce cas de figure (même si effectivement ça pourrait devenir gênant dans d’autres contextes).

Effectivement, il faut que je fasse attention aux doublons ! (peut-être itérer sur set(liste) au lieu de le faire directement sur liste ?)

Merci beaucoup,

@flopy78

Quand j’ai commencé à m’intéresser à la question, je voulais comparer l’espace occupé par une liste et celui occupé par son équivalent sous forme de générateur.

Ça dépend de ce que tu appelles "son équivalent sous forme de générateur". Si tu parles du générateur iter([1, 2, 3, ...]) (i.e. la liste existe déjà), le générateur prend un peu plus de place puisqu’il wrappe la liste. Si tu parles d’un générateur qui n’a pas toute la liste en mémoire (comme iter(range(25))), il y a de fortes chances pour que le générateur prenne moins de la place que la liste complète.

Mais cette question me semble assez curieuse. Si tu as des problèmes de consommation mémoire tels que le coût en espace d’un simple générateur est une question pertinente, c’est que Python n’est pas le bon outil pour ce que tu essayes de faire, les chiffres exacts à l’octet près n’auront aucune importance.

Effectivement, il faut que je fasse attention aux doublons ! (peut-être itérer sur set(liste) au lieu de le faire directement sur liste ?)

Ça marcherait avec des entiers (quoique pas sûr que pour des entiers larges, il y ait une quelconque garantie qu’ils ne sont pas dupliqués en mémoire), mais pas en général. La question de surveiller la consommation mémoire d’un langage aussi dynamique que Python n’est pas simple, il est quasi impossible de pouvoir faire quoique ce soit de fiable sans passer par un profiler type vtune (où la sortie sera pas facile à lire) si on ne sait pas déjà un peu où l’on va et que l’on sait où aller chercher les problèmes potentiels.

+0 -0

J’ai fait un petit test pour certaines grandeurs d’entiers:

from sys import getsizeof
p = -1
for n in range(1000):
    c = getsizeof(n)
    if c != p:
        print(f"pour {n}, {c}")
    p = c
p = -1
for e in range(256):
    c = getsizeof(2**e)
    if c != p:
        print(f"Pour 2**{e}, {c}")
    p = c

Ce qui donne:

pour 0, 24                                                                                                              
pour 1, 28                                                                                                              
Pour 2**0, 28                                                                                                           
Pour 2**30, 32                                                                                                          
Pour 2**60, 36                                                                                                          
Pour 2**90, 40                                                                                                          
Pour 2**120, 44                                                                                                         
Pour 2**150, 48                                                                                                         
Pour 2**180, 52                                                                                                         
Pour 2**210, 56                                                                                                         
Pour 2**240, 60                                                                                                          

@flopy78:

Ta fonction est correcte si tu mesures des listes de scalaires, mais tu devras la rendre récursive pour des listes de listes de listes, par exemple.

Si les éléments de ta liste sont plus complexes comme des dictionnaires de toutes sortes d’objets, ça devient encore plus compliqué à évaluer.

Presque. Tu devrais lire les docs de ces fonctions (comme souvent avec tes questions, la réponse est dans la doc… :-° ), parce qu’elles expliquent d’autres subtilités (avec notamment un lien vers une implémentation récursive moins naïve).

adri1

J’ajoute aussi que sa fonction get_liste_size n’est pas récursive, donc ne gère pas les listes imbriquées.

Bonjour,

Effectivement ma fonction ne gère pas les listes à plusieurs dimensions…

Le générateur auquel je m’intéressais était plutôt (ce générateur ne fonctionne que pour ma liste du début, bien entendu) :

def genarator():
   for i in range(10):
       yield i

Mon objectif initial était d’avoir un ordre de grandeur en tête concernant la place que l’on gagne en utilisant le générateur plutôt que la liste, quand le contexte de l’algorithme le permet (bien sûr, dès que l’on doit faire de l’indiçage ou autre, ça devient très compliqué avec les générateurs).

Au bout du compte, c’est vrai que Python n’est pas spécialement un langage où on est à l’octet près au niveau de la mémoire…

Bonne journée,

@flopy78

Si tu as un itérable (appelons-le iterable), construire un générateur autour comme tu le fais

def generator(iterable):
    for item in iterable:
        yield item

est absurde. iter(iterable) fera la même chose en pratique, de manière plus efficace et surtout beaucoup plus lisible. Dans la plupart des cas, les API attendent un itérable et donc tu n’as même pas besoin de wrapper iterable.


Pour répondre à ta question sur les ordres de grandeur, il n’y a pas besoin d’aller chercher bien loin. Le coût mémoire d’un iter(range(N)) ou de ta fonction peut être considéré constant en pratique, puisque tu as essentiellement une machine à état de taille constante qui wrappe un range qui est lui même de taille constante (essentiellement 3 ints). En vrai, la taille d’un range(N) est en log(N)\log(N) (parce que la taille des int est en log(N)\log(N)), mais avec un préfacteur tout petit et la plupart des applications seront sur des NN petits où cet effet sera invisible ou complètement négligeable.

Le coût mémoire de construire list(range(N)) sera linéaire en NN (en fait, en Nlog(N)N\log(N) comme les int sont log(N)\log(N), mais encore une fois négligeable en pratique).

+0 -0
Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte