Luis Medel

Cosas de dict.update() que un pythonista debería saber

Ayer estuve ayudando a un amigo a resolver un problema de rendimiento en un API con bastante tráfico y me fijé en la forma de actualizar diccionarios en algunos puntos calientes del código.

Esto que vas a leer no fue la causa raíz del problema, pero, como verás, todo suma a escala.

A continuación pongo un ejemplo que ilustra perfectamente lo que vi:

    item = obtengo_un_diccionario_de_mi_bbdd()

    # hacemos algunas cosas con `item`...
    # ...
    # ...y ahora queremos actualizarlo

    item.update({
        'updated': True,
        'last_update': datetime.now()
    })

Curiosamente es algo que también observé en Tinybird. En su día dejé en el Basecamp interno un post hablando de por qué, en mi opinión, esa forma de actualizar los diccionarios no es la mejor en este caso específico.

Primero lo primero. ¿Cómo actualizaría yo ese diccionario?

De la forma más directa:

    item['updated'] = True
    item['last_update'] = datetime.now()

La utilidad de dict.update() es fusionar dos diccionarios (A y B), recorriendo todos los valores en B y copiándolos a A.

Así, cuando escribimos esto:

    A.update({
        'updated': True,
        'last_update': datetime.now()
    })

En realidad, el intérprete de Python está haciendo todo esto por debajo:

    B = {
        'updated': True,
        'last_update': datetime.now()
    }

    for k, v in B.items():
        A[k] = v

Bueno, ese bucle de ahí arriba es una simplificación para ilustrar mi punto. Aquí tienes la implementación real.

Es decir, estamos creando un diccionario B efímero, sólo para inmediatamente recorrer sus claves, actualizar A con esos valores y dejarlo morir.

Un par de apuntes:

¿Significa esto que no se debe usar dict.update()? Todo lo contrario. Se debe usar cuando se necesite. Y ese caso es cuando queremos fusionar dos diccionarios con claves arbitrarias. No tiene sentido usarlo cuando sabemos previamente las dos, tres o cuatro claves que queremos actualizar.

Por supuesto, si el número de claves a actualizar es muy elevado, el coste de crear el diccionario temporal probablemente quede amortizado, pero estamos hablando de un número tan, pero tan elevado, que simplemente es absurdo plantearse escribirlas. Es más, aunque el número fuera sólo de 10 claves yo también soy partidario de usar dict.update() por pura ergonomía a la hora de escribir mi código.

Pero cuando el número de claves es pequeño y, repito, previamente conocido, siempre va a ser más rápido y eficiente acceder a las claves que se necesitan actualizar.

Pongámoslo a prueba

He preparado un par de ejemplos para ver si esto que digo es sólo teoría o es posible visualizarlo.

Usando dict.update()

def dato_chungo_1():
    return 1

def dato_chungo_2():
    return 2

def cosas_chulas(diccionario):
    # Hacemos cosas super chulas con nuestro backend
    # y actualizamos el diccionario
    # ...

    c = dato_chungo_1()
    d = dato_chungo_2()

    diccionario.update({
        "c": c,
        "d": d,
    })

    # ... blablabla

Pues bien, éste es el código que la máquina virtual de Python ejecuta cuando llamamos a cosas_chulas:

Si tienes curiosidad por saber cómo lo he obtenido échale un ojo al módulo dis.

 15           0 RESUME                   0

# Llama a `dato_chungo_1` y lo guarda en `c`
 19           2 LOAD_GLOBAL              1 (NULL + dato_chungo_1)
             12 CALL                     0
             20 STORE_FAST               1 (c)

# Llamada a `dato_chungo_2` y lo guarda en `d`
 20          22 LOAD_GLOBAL              3 (NULL + dato_chungo_2)
             32 CALL                     0
             40 STORE_FAST               2 (d)

# Deja en la pila la referencia a `diccionario.update` "pa luego"
 22          42 LOAD_FAST                0 (diccionario)
             44 LOAD_ATTR                5 (NULL|self + update)

# Crea el diccionario efímero y lo deja en la pila
 23          64 LOAD_FAST                1 (c)
 24          66 LOAD_FAST                2 (d)
 22          68 LOAD_CONST               1 (('c', 'd'))
             70 BUILD_CONST_KEY_MAP      2

# Llama a `diccionario.update`
             72 CALL                     1
             80 POP_TOP

# Se acabó
             82 RETURN_CONST             0 (None)

Usando acceso directo

Veamos ahora qué ocurre si utilizamos acceso por clave:

def cosas_chulas2(diccionario):
    # Hacemos cosas super chulas con nuestro backend
    # y actualizamos el diccionario
    # ...

    c = dato_chungo_1()
    d = dato_chungo_2()

    diccionario["c"] = c
    diccionario["d"] = d

    # ... blablabla

Las instrucciones que ejecuta la VM al llamar a cosas_chulas2 son las siguientes:

 31           0 RESUME                   0

# Llama a `dato_chungo_1` y lo guarda en `c`
 35           2 LOAD_GLOBAL              1 (NULL + dato_chungo_1)
             12 CALL                     0
             20 STORE_FAST               1 (c)

# Llama a `dato_chungo_2` y lo guarda en `d`
 36          22 LOAD_GLOBAL              3 (NULL + dato_chungo_2)
             32 CALL                     0
             40 STORE_FAST               2 (d)

# diccionario["c"] = c
 38          42 LOAD_FAST                1 (c)
             44 LOAD_FAST                0 (diccionario)
             46 LOAD_CONST               1 ('c')
             48 STORE_SUBSCR

# diccionario["d"] = d
 39          52 LOAD_FAST                2 (d)
             54 LOAD_FAST                0 (diccionario)
             56 LOAD_CONST               2 ('d')
             58 STORE_SUBSCR

# Se acabó
             62 RETURN_CONST             0 (None)

Sólo con leer los opcodes queda claro que la versión con dict.update() "hace más cosas". Y hacer más cosas suele ser sinónimo de más consumo de recursos. Vamos a intentar comprobarlo con un microbenchmark. No soy especialmente amante de ellos, pero a veces vienen bien para ilustrar estas cosas.

timeit

Vamos a usar una combinación del módulo timeit de Python y el comando time (el que tengas en /usr/bin/, no la basura de alias que trae Zsh). Con timeit forzamos una duración suficiente y medimos los tiempos. Con time medimos el impacto en el sistema.

Midamos dict.update():

$ /usr/bin/time -lhp python -m timeit -s 'diccionario = {}' \
    'diccionario.update({"clave1": "valor1", "clave2": "valor2"})'

2000000 loops, best of 5: 185 nsec per loop
real 2,62
user 2,55
sys 0,02
             8597504  maximum resident set size
                   0  average shared memory size
                   0  average unshared data size
                   0  average unshared stack size
                3637  page reclaims
                   0  page faults
                   0  swaps
                   0  block input operations
                   0  block output operations
                   0  messages sent
                   0  messages received
                   0  signals received
                   0  voluntary context switches
                3474  involuntary context switches
         27597569770  instructions retired
          9514964646  cycles elapsed
             5603328  peak memory footprint

Y ahora el acceso por clave:

$ /usr/bin/time -lhp python -m timeit -s 'diccionario = {}' \
    'diccionario["clave1"] = "valor1"; diccionario["clave2"] = "valor2"'

5000000 loops, best of 5: 56.5 nsec per loop
real 1,97
user 1,91
sys 0,02
             8486912  maximum resident set size
                   0  average shared memory size
                   0  average unshared data size
                   0  average unshared stack size
                3602  page reclaims
                   0  page faults
                   0  swaps
                   0  block input operations
                   0  block output operations
                   0  messages sent
                   0  messages received
                   0  signals received
                   0  voluntary context switches
                2701  involuntary context switches
         22601398737  instructions retired
          7128272919  cycles elapsed
             5505024  peak memory footprint

Tenemos:

Obviamente estos resultados son totalmente artificiales, como no puede ser de otra forma con un microbenchmark.

Si tú también actualizas los diccionarios así, no hace falta que corras a cambiar tu código. Con total seguridad no te está suponiendo un problema. Pero si te encuentras con uno por el camino, siempre puedes ser un buen boyscout. No te va a hacer daño mejorarlo un poco.

Quizás pienses que preocuparse por eso es micro optimizar. Yo no lo veo así, aunque es cierto que no me quita el sueño. Eso sí, recuerda que a escala todo suma.