Reducir el consumo de memoria en Python

Publicado el 19 noviembre 2018 por Daniel Rodríguez @analyticslane

La disponible memoria en los sistemas informáticos es un recurso limitado. En la implementación de un algoritmo esto se ha de tener en cuenta. Reducir el consumo de la memoria es clave para permitir que el programa se ejecute en sistemas con menos recursos. Además de mejorar el rendimiento en sistemas con más recursos. Para reducir el consumo de memoria en Python es necesario primero medir el primero cuanta memoria ocupa un objeto. Una vez se conocido el dato será posible reducir el tamaño de los programas.

Medir la memoria ocupado por un objeto en Python

Obtener la memoria de un objeto en Python es una tarea algo más complicado de se podría pensar. La primera idea sería utilizar la función getsizeof() que se puede encontrar en la librería sys. Esta función devuelve el tamaño en bytes del objeto que se le hubiese pasado. A modo de ejemplo se puede ver el siguiente código la memoria que requieren diferentes tipos de objetos.

import sys

print(sys.getsizeof(1.0))                  # 24
print(sys.getsizeof(""))                   # 49
print(sys.getsizeof("Hello!"))             # 55
print(sys.getsizeof(dict()))               # 240
print(sys.getsizeof(dict({'A':1, 'B':2}))) # 240

Al ejecutar el código se puede ver que un valor real ocupa 24 bytes, una cadena vacía 49 y la cadena Hello! ocupa 55 bytes. Esto significa que en una cadena cada carácter ocupa un byte. Lo que no parece normal es lo que se observa con los diccionarios. Un diccionario vacío y uno con datos ocupa exactamente lo mismo 240 bytes. Esto es así porque getsizeof() solo tiene en cuenta la memoria del objeto Python y no los objetos a los que hace referencia. Es decir, la función solamente devuelve la memoria ocupada por la escritura, no su contenido. Por lo que solamente es útil para medir la memoria ocupada por los objetos primitivos. En los objetos no primitivos será necesario utilizar algún tipo de función recursiva. Incluso para los tipos contenedores incluidos en el sistema como listas o diccionarios.

Medir la memoria total ocupada por un objeto en Python

La única forma de obtener la memoria total de un objeto en Python es utilizar un método recursivo. Este método ha de recorrer el objeto y sumar la memoria que ocupa cada uno de los objetos que forma parte del primero. La implementación de una función que permita obtener la memoria de un objeto se puede encontrar en la publicación "Measure the Real Size of Any Python Object". La función que se implementa en la entrada es:

def get_size(obj, seen=None):
    """Recursively finds size of objects"""
    size = sys.getsizeof(obj)
    if seen is None:
        seen = set()
    obj_id = id(obj)
    if obj_id in seen:
        return 0
    # Important mark as seen *before* entering recursion to gracefully handle
    # self-referential objects
    seen.add(obj_id)
    if isinstance(obj, dict):
        size += sum([get_size(v, seen) for v in obj.values()])
        size += sum([get_size(k, seen) for k in obj.keys()])
    elif hasattr(obj, '__dict__'):
        size += get_size(obj.__dict__, seen)
    elif hasattr(obj, '__iter__') and not isinstance(obj, (str, bytes, bytearray)):
        size += sum([get_size(i, seen) for i in obj])
    return size

Ahora al utilizar esta función en lugar de la del sistema se puede medir lo que ocupa realmente un objeto. Concretamente para los objetos vistos anteriormente se obtienen los siguientes resultados.

print(get_size(1.0))                  # 24
print(get_size(""))                   # 49
print(get_size("Hello!"))             # 55
print(get_size(dict()))               # 240    
print(get_size(dict({'A':1, 'B':2}))) # 396

Básicamente en los objetos primitivos, los numero o las cadenas, no hay cambios. Pero si se observa una diferencia clara entre un diccionario vacío y un diccionario con contenido. Ahora los resultados obtenidos son más coherentes que lo visto anteriormente.

Reducir el consumo de memoria en Python de los objetos

Ahora que ya sabemos medir el tamaño que ocupa en memoria un objeto podemos ver cómo reducir la memoria ocupada. Por ejemplo, se puede definir la siguiente clase y ver lo que ocupa una instancia.

class Person(object):
    def __init__(self, first_name, last_name, age, gender):
        self.first_name = first_name
        self.last_name = last_name
        self.age = age
        self.gender = gender
        
print(get_size(Person("Gayleen", "Eccleshare", 33, 'Female')))

En este caso concreto la memoria ocupada por una instancia es 590 bytes. En un objeto Python es listado de atributos se guarda en un diccionario, esto permite modificar la cantidad de estos en cualquier momento. Por ejemplo, se puede añadir un atributo a la clase Person.

person = Person("Gayleen", "Eccleshare", 33, 'Female')
person.profession = 'Accounting'

Esto es así porque el listado de atributos de un objeto Python se guarda en un diccionario llamado __slots__. Un diccionario no tiene un número fijo de elementos, por lo que se pueden agregar nuevos. Esto es lo que ha permitido añadir un nuevo atributo en tiempo de ejecución, como se ha visto anteriormente. Una característica del lenguaje que no se utiliza habitualmente.

El diccionario que se guarda en __slots__ ocupa una cantidad de memoria importante. Si se define el listado de atributos al diseñar la clase se puede conseguir un ahorro considerable de memoria. Por ejemplo, se puede ver como afecta esto al objeto Persona definido anteriormente.

class Person(object):
    __slots__ = ['first_name', 'last_name', 'age', 'gender']
    def __init__(self, first_name, last_name, age, gender):
        self.first_name = first_name
        self.last_name = last_name
        self.age = age
        self.gender = gender
        
print(get_size(Person("Gayleen", "Eccleshare", 33, 'Female')))

Ahora el espacio ocupado por una instancia idéntica es de 72 bits. Teniendo en cuenta que anteriormente ocupaba 590 bytes, ahora la instancia ocupa un 12% de la memoria original. Esto es, se ha reducido los requisitos de memoria en un factor de 8. Lo único que se ha perdido es la posibilidad de agregar nuevos atributos al objeto en tiempo de ejecución. Ahora, en caso de que se intente agregar un nuevo atributo se tendrá un error.

Conclusiones

La memoria es un recurso importante que se ha de utilizar de forma eficiente. Por lo que reducir el consumo de memoria en Python, o cualquier otro lenguaje, es clave. En esta entrada se ha visto cómo reducir la memoria que emplea un objeto Python en factor de 8. Simplemente agregando una línea de código en los programas. Utilizar este truco puede reducir los recursos necesario para ejecutar un programa y mejorar su rendimiento.