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:
- Python es muy rápido (para definiciones algo laxas del término rápido) creando diccionarios. Creo haber leído en algún sitio que Guido se trajo esa parte del código del intérprete original de Lua, pero no consigo encontrar la fuente ahora. En cualquier caso, que algo sea relativamente rápido no significa que sea completamente gratis: Estamos creando objetos efímeros que consumen tiempo y añaden presión al recolector de basura. También guarreamos el heap, pero ese impacto es residual.
- Si bien es verdad que la complejidad promedio del acceso por clave a los diccionarios es
O(1)
, cualquier operación que se describa como "recorrer todos los valores" tiene, como poco, complejidadO(n)
. Y este es el caso condict.update()
.
¿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:
185
vs56.6
nanosegundos por loop.dict.update()
ha tardado 3.26 veces más que el acceso por clave.5.603.328
vs5.505.024
bytes de memoria.dict.update()
ha consumido ~1.0% más de memoria.
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.