7 days of WordPress plugins, themes & templates - for free!* Unlimited asset downloads! Start 7-Day Free Trial
Advertisement
  1. Code
  2. Python

Comprende la cantidad de memoria que usan tus objetos Python

Scroll to top
Read Time: 11 mins

Spanish (Español) translation by Andrea Jiménez (you can also view the original English article)

Python es un lenguaje de programación fantástico. También es conocido por ser bastante lento, debido principalmente a su enorme flexibilidad y características dinámicas. Para muchas aplicaciones y dominios no es un problema debido a sus requisitos y diversas técnicas de optimización. Es menos conocido que los gráficos de objetos de Python (diccionarios anidados de listas y tuplas y tipos primitivos) ocupan una cantidad significativa de memoria. Esto puede ser un factor limitante mucho más severo debido a sus efectos sobre el almacenamiento en caché, la memoria virtual, multitenencia con otros programas y en general el agotamiento más rápido de la memoria disponible, que es un recurso escaso y costoso.

Resulta que no es trivial averiguar cuánta memoria se consume realmente. En este artículo, te guiaré a través de las complejidades de la administración de la memoria de los objetos Python y te mostraré cómo medir la memoria consumida con precisión.

En este artículo me concentro únicamente en CPython, la implementación principal del lenguaje de programación Python. Aquí los experimentos y conclusiones no se aplican a otras implementaciones de Python como IronPython, Jython y PyPy.

Además, ejecuté los números en Python 2.7 de 64 bits. En Python 3, los números a veces son un poco diferentes (especialmente para cadenas que siempre son Unicode), pero los conceptos son los mismos.

Exploración práctica del uso de memoria de Python

Primero, exploremos un poco y obtengamos una idea concreta del uso real de la memoria de los objetos Python.

La función integrada sys.getsizeof()

El módulo sys de la biblioteca estándar proporciona la función getsizeof(). Esa función acepta un objeto (y el valor predeterminado opcional), llama al método sizeof() del objeto y devuelve el resultado, por lo que también puedes hacer que los objetos se puedan inspeccionar.

Midiendo la memoria de objetos Python

Comencemos con algunos tipos numéricos:

Interesante. Un entero ocupa 24 bytes.

Mmm... un float también ocupa 24 bytes.

Wow. ¡80 bytes! Esto realmente te hace pensar si quieres representar una gran cantidad de números reales como floats o decimales.

Pasemos a las cadenas y a las colecciones:

De acuerdo. Una cadena vacía ocupa 37 bytes y cada carácter adicional agrega otro byte. Eso dice mucho sobre las ventajas y desventajas de mantener varias cadenas cortas en las que pagarás la sobrecarga de 37 bytes por cada una vs. una sola cadena larga en la que pagas la sobrecarga solo una vez.

Las cadenas Unicode se comportan de forma similar, excepto que la sobrecarga es de 50 bytes y cada carácter adicional agrega 2 bytes. Eso es algo a tener en cuenta si usas bibliotecas que devuelven cadenas Unicode, pero tu texto se puede representar como cadenas simples.

Por cierto, en Python 3, las cadenas siempre son Unicode y la sobrecarga es de 49 bytes (guardaron un byte en algún lugar). El objeto bytes tiene una sobrecarga de solo 33 bytes. Si tienes un programa que procesa muchas cadenas cortas en la memoria y te importa el rendimiento, ten en cuenta Python 3.

¿Qué está pasando? Una lista vacía ocupa 72 bytes, pero cada número entero adicional agrega solo 8 bytes, donde el tamaño de un número entero es de 24 bytes. Una lista que contiene una cadena larga ocupa solo 80 bytes.

La respuesta es simple. La lista no contiene los objetos de números enteros. Solo contiene un puntero de 8 bytes (en versiones de 64 bits de CPython) al objeto real de número entero. Lo que eso significa es que la función getsizeof() no devuelve la memoria real de la lista y todos los objetos que contiene, sino solo la memoria de la lista y los punteros a sus objetos. En la siguiente sección presentaré la función deep_getsizeof() que aborda este problema.

La historia es similar para las tuplas. La sobrecarga de una tupla vacía es de 56 bytes vs. los 72 de una lista. Nuevamente, esta diferencia de 16 bytes por secuencia es muy fácil si tienes una estructura de datos con muchas secuencias pequeñas e inmutables.

Los conjuntos y diccionarios aparentemente no crecen en absoluto cuando agregas elementos, pero ten en cuenta la enorme sobrecarga.

La conclusión es que los objetos de Python tienen una enorme sobrecarga fija. Si tu estructura de datos está compuesta por una gran cantidad de objetos de colección como cadenas, listas y diccionarios que contienen una pequeña cantidad de elementos cada uno, pagas un alto precio.

La función deep_getsizeof()

Ya que te asusté mucho y también demostré que sys.getsizeof() solo puede decirte cuánta memoria ocupa un objeto primitivo, veamos una solución más adecuada. La función deep_getsizeof() desglosa recursivamente y calcula el uso real de memoria de un gráfico de objetos Python.

Hay varios aspectos interesantes en esta función. Tiene en cuenta los objetos a los que se hace referencia varias veces y los cuenta solo una vez al realizar un seguimiento de los identificadores de objetos. La otra característica interesante de la implementación es que aprovecha al máximo las clases base abstractas del módulo de colecciones. Esto permite a la función controlar de forma muy concisa cualquier colección que implemente las clases base Mapping o Container en lugar de tratar directamente con innumerables tipos de colección como: string, Unicode, bytes, list, tupla, dict, frozendict, OrderedDict, set, frozenset, entre otros.

Veámoslo en acción:

Una cadena de longitud 7 ocupa 44 bytes (37 sobrecarga + 7 bytes para cada carácter).

Una lista vacía ocupa 72 bytes (solo sobrecarga).

python deep_getsizeof([x], set()) 124

Una lista que contiene la cadena x ocupa 124 bytes (72 + 8 + 44).

Una lista que contiene la cadena x 5 veces ocupa 156 bytes (72 + 5 * 8 + 44).

El último ejemplo muestra que deep_getsizeof() cuenta las referencias al mismo objeto (la cadena x) una sola vez, pero se cuenta el puntero de cada referencia.

Golosinas o trucos

Resulta que Python tiene varios trucos bajo la manga, por lo que los números que obtienes de deep_getsizeof() no representan completamente el uso de memoria de un programa Python.

Recuento de referencias

Python administra la memoria mediante la semántica de recuento de referencias. Una vez que ya no se hace referencia a un objeto, su memoria se desasigna. Pero mientras haya una referencia, el objeto no se desasignará. Cosas como las referencias cíclicas pueden desagradarte mucho.

Objetos pequeños

CPython administra objetos pequeños (menos de 256 bytes) en agrupaciones especiales en límites de 8 bytes. Hay grupos de 1-8 bytes, 9-16 bytes y hasta 249-256 bytes. Cuando se asigna un objeto de tamaño 10, se asigna desde el grupo de 16 bytes para objetos de 9-16 bytes de tamaño. Entonces, aunque contiene solo 10 bytes de datos, costará 16 bytes de memoria. Si asignas 1.000.000 de objetos de tamaño 10, en realidad usas 16.000.000 de bytes y no 10.000.000 de bytes, como supones. Obviamente, este 60% de sobrecarga no es trivial.

Enteros

CPython mantiene una lista global de todos los enteros en el rango [-5, 256]. Esta estrategia de optimización tiene sentido porque aparecen pequeños enteros por todas partes, y dado que cada entero ocupa 24 bytes, ahorra mucha memoria para un programa típico.

También significa que CPython preasigna 266 * 24 = 6384 bytes para todos estos números enteros, incluso si no usas la mayoría de ellos. Puedes verificarlo utilizando la función id() que le da el puntero al objeto real. Si llamas al id(x) multiple para cualquier x en el rango [-5, 256], cada vez obtendrás el mismo resultado (para el mismo entero). Pero si lo pruebas con números enteros fuera de este rango, cada uno será diferente (cada vez se crea un nuevo objeto sobre la marcha).

Estos son algunos ejemplos dentro del rango:

Estos son algunos ejemplos fuera del rango:

Memoria de Python vs. Memoria del sistema

CPython es un poco posesivo. En muchos casos, cuando ya no se hace referencia a objetos de memoria del programa, no se devuelven al sistema (por ejemplo, los objetos pequeños). Esto es bueno para tu programa si asignas y desasignas muchos objetos (que pertenecen al mismo grupo de 8 bytes) porque Python no tiene que molestar al sistema, el cual es relativamente caro. Pero no es tan bueno si tu programa normalmente usa X bytes y bajo alguna condición temporal usa 100 veces más (por ejemplo, analizar y procesar un archivo de configuración grande solo cuando se inicia).

Ahora, esa memoria 100X puede quedar atrapada inútilmente en tu programa, para no volver a usarse nunca más y negarle al sistema que la asigne a otros programas. La ironía es que si utilizas el módulo de procesamiento para ejecutar varias instancias del programa, limitarás severamente el número de instancias que puedes ejecutar en una máquina determinada.

Generador de perfiles de memoria

Para calibrar y medir el uso de memoria real de tu programa, puedes usar el módulo memory_profiler. Jugué con él un poco y no estoy seguro de confiar en los resultados. Usarlo es muy simple. Decoras una función (podría ser la principal (función 0) con @profiler decorator, y cuando el programa sale, el generador de perfiles de memoria imprime en la salida estándar un informe útil que muestra el total y los cambios en la memoria para cada línea. Este es un programa de ejemplo que ejecuté bajo el generador de perfiles:

Esta es la salida:

Como puedes ver, hay 22,9 MB de sobrecarga de memoria. La razón por la que la memoria no aumenta cuando se suman enteros tanto dentro como fuera del rango [-5, 256] y también cuando se agrega la cadena es que se usa un solo objeto en todos los casos. No está claro por qué el primer ciclo de rango (100000) en la línea 8 agrega 4,2 MB, mientras que el segundo en la línea 10 agrega solo 0,4 MB y el tercer ciclo en la línea 12 agrega 0,8 MB. Finalmente, al eliminar las listas a, b y c, se liberan -0,6 MB para a y c, pero para b se agregan 0,2 MB. No puedo entender mucho estos resultados.

Conclusión

CPython usa mucha memoria para sus objetos. Utiliza varios trucos y optimizaciones para la gestión de la memoria. Al realizar un seguimiento del uso de memoria de tu objeto y al conocer el modelo de administración de memoria, puedes reducir significativamente la huella de memoria de tu programa.

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Code tutorials. Never miss out on learning about the next big thing.
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.