No soy programador, pero programo en mi trabajo. Sobre todo para nuestra herramienta in-house de Azure y también para automatizar tareas repetitivas (fuera con el toil!). El software resultante tiene un número bastante reducido de usuarios: el resto de mi equipo o los demás equipos en el área de Operaciones e Ingeniería. Algunas veces el único usuario soy yo. Aún así, dada la complejidad habitual del software que manejo -un par de cientos de líneas si exceptuamos la herramienta in house- no parece rentable preocuparse por la eficiencia tanto como lo es preocuparse por que funcione y haga el trabajo. Sin embargo, como ya he dejado caer, a mí me gusta que lo que hago sea eficiente o al menos no tan ineficiente. Creo que si un software puede tardar 10ms y ocupar 500 bytes no tiene ninguna excusa para tardar 100ms y ocupar 1000 bytes. Aún si nos sobra tiempo y memoria.

Así que llevaba tiempo preguntándome sobre el impacto de __slots__ en Python. ¿Resultaría significativo, me valdría la pena? La teoría, las discusiones online y la documentación oficial dicen claramente que sí. Pero quería animarme a verlo por mi mismo y a probarlo para tratar de responder a la pregunta que da título a este post “¿Vale la pena preocuparse?”. Aquí está la humilde prueba resultante, que me ha servido como excusa para iniciarme un poco en el memory profiling de Python.

Slots, la teoria

__slots__ permite declarar explícitamente los atributos de una clase en Python. Lo hace más parecido a lenguajes tipo C, dónde si no declaramos una variable con anterioridad va a quejarse con un “UndefinedVariable” de alguna clase. Sin embargo esto es totalmente legal en Python:

class Example:
    def __init__(self, number):
        self.number = number

    def do_stuff(self):
      if (self.number % 2 == 0):
        return (self.number)
      else:
        self.new_num = 1 + self.number
        return (self.new_num)

El ejemplo anterior funciona perfectamente en Python. La variable new_num no es declarada como atributo de instancia (los que van en el constructor) sino directamente en el else. No hay problema. Y no lo hay porque los objetos de Python tienen el diccionario __dict__ para mapear sus atributos de manera dinámica. Esto da flexibilidad a la hora de escribir código, al precio de overhead en memoria y tiempo de acceso.

Utilizar __slots__ en una clase nos permite denegar la creación de ese __dict__, obligándonos a declarar todos los atributos que va a tener cada objeto de la clase. Por cierto que eso también hace que ya no vayamos a tener objetos de la misma clase con unos u otros atributos segun lo que ocurra en tiempo de ejecución.

class Example:
    __slots__ = ('number')
    def __init__(self, number):
        self.number = number

    def do_stuff(self):
      if (self.number % 2 == 0):
        return (self.number)
      else:
        self.new_num = 1 + self.number
        return (self.new_num)

Esto ya no funcionará en Python. new_num no está declarado en __slots__ así que el objeto no puede tener un atributo que no sea number. Tendremos un error:

AttributeError: 'Example' object has no attribute 'new_num'.

Para arreglarlo, se le declara, ¿a que es bastante C-like?

class Example:
    __slots__ = ('number', 'new_num')
    def __init__(self, number):
        self.number = number

    def do_stuff(self):
      if (self.number % 2 == 0):
        return (self.number)
      else:
        self.new_num = 1 + self.number
        return (self.new_num)

También podríamos simplemente hacer new_num una variable local del método y no un atributo de la instancia (lo declaramos sin self.) y funcionaría porque __slots__ lo que “restringe” son los atributos de una instancia. Sólo quería mencionarlo, auque no va de eso este ejemplo.

¿Para qué este cambio de comportamiento que trae el uso de __slots__? No es para restringir nada, eso ya lo aclaró hace tiempo el BDFL Guido van Rossun

Some people mistakenly assume that the intended purpose of slots is to increase code safety (by restricting the attribute names). In reality, my ultimate goal was performance.

Es una cuestión de performance. Debido a que el acceso a los atributos declarados en __dict__ es más lento, y además el propio __dict__ tiene una mayor memory footprint. No quiero entrar mucho más en esto para no alargar el post, en la explicación de Guido linkeada antes él entre en más detalle y con más conocimiento de causa.

La prueba

La manera en la que me he propuesto comparar el performance es intuitiva:

  1. Una clase de ejemplo. Dos versiones, una que use __slots__ y otra que no.
  2. Medir el uso de memoria de objetos de ambas versiones.
  3. Comparar el uso de una y otra para responder a: ¿hay beneficio? ¿en qué proporción?

Para la prueba diseñé una clase básica que se encarga de multiplicar un rango de números de 0 a max por un multiplicador, y que también almacena el nombre del “caller” porque quería que la clase almacenase un string. Lo sé, no soy demasiado creativo con los ejemplos. La segunda es la misma clase pero aprovechando __slots__ para poder ver las diferencias.

class MyTestClass:
    def __init__(self, mult, rang, caller):
        self.multiplier = mult
        self.list = []
        self.caller = caller
        for number in range(0, rang):
            print(f"Adding {number} x {self.multiplier}")
            self.list.append(number*self.multiplier)

class MySTestClass:
    __slots__ = ('multiplier', 'list', 'caller')
    def __init__(self, mult, rang, caller):
        self.multiplier = mult
        self.list = []
        self.caller = caller
        for number in range(0, rang):
            print(f"Adding {number} x {self.multiplier}")
            self.list.append(number*self.multiplier)

La primera pregunta importante es, ¿cómo medimos el uso de memoria de un objeto en Python?. No se puede utilizar simplemente sys.getsizeof() ya que nos devolverá el tamaño del objeto pero no de los objetos a los que haga referencia. Es decir, como mi objeto contiene referencias a otros no va a devolver resultados fiables. La demostración de esto es trivial: crea una clase que contenga una lista, instancia dos objetos de la clase, agrega a uno 10 elementos y a otro 100 y getsizeof() te devolverá el mismo valor para ambos objetos.

import sys

class testClass:
    def __init__(self, ran):        
        self.ran = ran
        self.lst = []
        for element in range(0, self.ran):
            self.lst.append(element)

small = testClass(10)
big = testClass(400)

print(f"Size of small is {sys.getsizeof(small)}")
print(f"Size of big is {sys.getsizeof(big)}")

El output nos muestra esta limitación de getsizeof()

Size of small is 48
Size of big is 48

La propia documentación referencia un sizeof recursivo, pero recurrí a un profiler third-party llamado Pympler cuya utilización es sencilla y me sirve para lo que quiero. El nivel de memory profiling al que quiero llegar, además, es bastante básico así que no tenía tiempo para “reinventar la rueda”, por divertido que sea.

Sin más, aquí el ejemplo de uso con Pympler.

import pympler.asizeof as pasizeof

class MyTestClass:
    def __init__(self, mult, rang, caller):
        self.multiplier = mult
        self.list = []
        self.caller = caller
        for number in range(0, rang):
            print(f"Adding {number} x {self.multiplier}")
            self.list.append(number*self.multiplier)

class MySTestClass:
    __slots__ = ('multiplier', 'list', 'caller')
    def __init__(self, mult, rang, caller):
        self.multiplier = mult
        self.list = []
        self.caller = caller
        for number in range(0, rang):
            print(f"Adding {number} x {self.multiplier}")
            self.list.append(number*self.multiplier)

Una vez está listo todo para las pruebas, queda probar casos. Y aquí es dónde puede verse la influencia de __slots__. El valor de rang es lo único que va a variar, ya que representa la cantidad de memoria dinámica que quiero que el objeto consuma. Para preparar el output he decidido referirme a los objetos que usan __slots__ como “sobjet” no es ni una convención, es sólo la manera rápida de diferenciarlos al hacer print de su memory footprint.

Test 1: Uno pequeño y uno grande:

test_subject = MyTestClass(2.5, 5, "John")
test_subject2 = MyTestClass(2.5, 1000, "Kelly")

stest_subject = MySTestClass(2.5, 5, "John")
stest_subject2 = MySTestClass(2.5, 1000, "Kelly")

print(f"First object size is {pasizeof.asizeof(test_subject)}B")
print(f"Second object size is {pasizeof.asizeof(test_subject2)}B")

print(f"First sobject size is {pasizeof.asizeof(stest_subject)}B")
print(f"Second sobject size is {pasizeof.asizeof(stest_subject2)}B")

print(f"First object takes {pasizeof.asizeof(test_subject) / pasizeof.asizeof(stest_subject)} times the memory of First sobject")
print(f"Second object takes {pasizeof.asizeof(test_subject2) / pasizeof.asizeof(stest_subject2)} times the memory of Second sobject")

El resultado (el verbose output de print(f"Adding {number} x {self.multiplier}") está recortado):

[...]
Adding 998 x 2.5
Adding 999 x 2.5
First object size is 648B
Second object size is 33264B
First sobject size is 376B
Second sobject size is 32992B
First object takes 1.7234042553191489 times the memory of First sobject
Second object takes 1.0082444228903977 times the memory of Second sobject

En el primer caso, el ahorro en memoria es significativo. El uso en memoria del objeto de la clase que no usa __slots__ es más de 1.723 veces el de la que si lo usa. En el segundo caso la diferencia es mucho menor: 1.008, vale, no es tan impresionante, aunque estamos ahorrando 272 bytes, que nunca está mal. El ahorro de haber usado __slots__ pierde impacto según crece el de la lista. El uso de slots va a tener un impacto más significativo si instanciamos muchos objetos de uso ligero en memoria frente a uno sólo que contenga collections.

Veamos el output si cambiamos el rang al instanciar los objetos de 5 y 1000 a 100 y 10000:

[...]
Adding 9998 x 2.5
Adding 9999 x 2.5
First object size is 3728B
Second object size is 325584B
First sobject size is 3456B
Second sobject size is 325312B
First object takes 1.0787037037037037 times the memory of First sobject
Second object takes 1.0008361204013378 times the memory of Second sobject

Ningún resultado sorprendente. Sigue habiendo un ahorro de memoria pero es menos significativo en el total. Así que…

Entonces, ¿vale la pena preocuparse?

Para mí la respuesta es: Sí, PERO.

Hemos visto que el ahorro de memoria puede llegar a ser muy significativo, tal y como nos promete la documentación oficial. Ganar eficiencia en memoria y velocidad de acceso de una manera tan sencilla no es para dejarla pasar. No obstante creo que es algo que debe hacerse de manera diferente según como estemos trabajando:

  1. Si tenemos tiempo para una fase de diseño ordenada y pausada (no es algo que se nos dé muchas veces a los SRE, por razones de tiempo y carga de trabajo), podemos ir aprovechando __slots__ desde el principio.
  2. Si es parte de un proceso de desarrollo más “adhoc” yo esperaría a que el script o software ya está maduro. Es decir, mientras probamos y vamos a ir creando atributos etc, no vale la pena volverse loco agregando a __slots__ ya que perdemos agilidad. Pero una vez tenemos claro qué vamos a necesitar, ¿por qué no?

En ambos casos vamos a minimizar tiempo de acceso y a quitarnos de encima unos cuantos (potencialmente bastantes) bytes. Eso siempre está bien.