Course
Caché de Python: Dos métodos sencillos
En este artículo, aprenderemos sobre el almacenamiento en caché en Python. Entenderemos qué es y cómo utilizarlo eficazmente.
El almacenamiento en caché es una técnica utilizada para mejorar el rendimiento de las aplicaciones, almacenando temporalmente los resultados obtenidos por el programa para reutilizarlos si se necesitan más adelante.
En este tutorial, aprenderemos diferentes técnicas de almacenamiento en caché en Python, incluidos los decoradores @lru_cache
y @cache
del módulo functools
.
Para los que tengáis prisa, empecemos con una implementación muy breve de la caché y luego continuemos con más detalles.
Respuesta corta: Implementación de caché en Python
Para crear una caché en Python, podemos utilizar el decorador @cache
del módulo functools
. En el código siguiente, observa que la función print()
sólo se ejecuta una vez:
import functools
@functools.cache
def square(n):
print(f"Calculating square of {n}")
return n * n
# Testing the cached function
print(square(4)) # Output: Calculating square of 4 \n 16
print(square(4)) # Output: 16 (cached result, no recalculation)
Calculating square of 4
16
16
¿Qué es el almacenamiento en caché en Python?
Supongamos que tenemos que resolver un problema matemático y dedicamos una hora a obtener la respuesta correcta. Si tuviéramos que resolver el mismo problema al día siguiente, sería útil reutilizar nuestro trabajo anterior en lugar de empezar de nuevo.
El almacenamiento en caché en Python sigue un principio similar: almacena valores cuando se calculan dentro de llamadas a funciones para reutilizarlos cuando vuelvan a necesitarse. Este tipo de almacenamiento en caché también se denomina memoización.
Veamos un breve ejemplo que calcula dos veces la suma de un gran intervalo de números:
output = sum(range(100_000_001))
print(output)
output = sum(range(100_000_001))
print(output)
5000000050000000
5000000050000000
El programa tiene que calcular la suma cada vez. Podemos confirmarlo cronometrando las dos llamadas:
import timeit
print(
timeit.timeit(
"sum(range(100_000_001))",
globals=globals(),
number=1,
)
)
print(
timeit.timeit(
"sum(range(100_000_001))",
globals=globals(),
number=1,
)
)
1.2157779589979327
1.1848394999979064
La salida muestra que ambas llamadas tardan aproximadamente lo mismo (dependiendo de nuestra configuración, podemos obtener tiempos de ejecución más rápidos o más lentos).
Sin embargo, podemos utilizar una caché para evitar calcular el mismo valor más de una vez. Podemos redefinir el nombre sum
utilizando la función cache()
del módulo incorporado functools
:
import functools
import timeit
sum = functools.cache(sum)
print(
timeit.timeit(
"sum(range(100_000_001))",
globals=globals(),
number=1,
)
)
print(
timeit.timeit(
"sum(range(100_000_001))",
globals=globals(),
number=1,
)
)
1.2760689580027247
2.3330067051574588e-06
La segunda llamada tarda ahora un par de microsegundos en lugar de más de un segundo, porque el resultado de hallar la suma de los números de 0 a 100.000.000 ya se ha calculado y almacenado en caché: la segunda llamada utiliza el valor que se calculó y almacenó anteriormente.
Arriba, utilizamos el decorador functools.cache()
para incluir una caché a la función incorporada sum()
. Como nota al margen, un decorador en Python es una función que modifica el comportamiento de otra función sin cambiar permanentemente su código. Puedes aprender más sobre los decoradores en este tutorial sobre decoradores de Python.
El decorador functools.cache()
se añadió a Python en la versión 3.9, pero podemos utilizar functools.lru_cache()
para versiones anteriores. En la siguiente sección, exploraremos estas dos formas de crear una caché, incluyendo el uso de la notación de decorador más utilizada, como @cache
.
Caché en Python: Diferentes métodos
El módulo functools
de Python tiene dos decoradores para aplicar la caché a las funciones. Exploremos functools.lru_cache()
y functools.cache()
con un ejemplo.
Escribamos una función sum_digits()
que tome una secuencia de números y devuelva la suma de los dígitos de esos números. Por ejemplo, si utilizamos la tupla (23, 43, 8)
como entrada, entonces
- La suma de los dígitos de
23
es cinco. - La suma de los dígitos de
43
es siete. - La suma de los dígitos de
8
es ocho. - Por tanto, la suma total es 20.
Ésta es una forma de escribir nuestra función sum_digits()
:
def sum_digits(numbers):
return sum(
int(digit) for number in numbers for digit in str(number)
)
numbers = 23, 43, 8
print(sum_digits(numbers))
20
Utilicemos esta función para explorar distintas formas de crear una caché.
Caché manual Python
Primero vamos a crear la caché manualmente. Aunque también podríamos automatizarlo fácilmente, crear una caché manualmente nos ayuda a comprender el proceso.
Vamos a crear un diccionario y a añadir pares clave-valor cada vez que llamemos a la función con un nuevo valor para almacenar los resultados. Si llamamos a la función con un valor que ya está almacenado en este diccionario, la función devolverá el valor almacenado sin elaborarlo de nuevo:
import random
import timeit
def sum_digits(numbers):
if numbers not in sum_digits.my_cache:
sum_digits.my_cache[numbers] = sum(
int(digit) for number in numbers for digit in str(number)
)
return sum_digits.my_cache[numbers]
sum_digits.my_cache = {}
numbers = tuple(random.randint(1, 1000) for _ in range(1_000_000))
print(
timeit.timeit(
"sum_digits(numbers)",
globals=globals(),
number=1
)
)
print(
timeit.timeit(
"sum_digits(numbers)",
globals=globals(),
number=1
)
)
0.28875587500078836
0.0044607500021811575
La segunda llamada a sum_digits(numbers)
es mucho más rápida que la primera porque utiliza el valor almacenado en caché.
Ahora vamos a explicar el código anterior con más detalle. En primer lugar, fíjate en que creamos el diccionario sum_digits.my_cache
después de definir la función, aunque lo utilicemos en la definición de la función.
La función sum_digits()
comprueba si el argumento pasado a la función es ya una de las claves del diccionario sum_digits.my_cache
. La suma de todos los dígitos sólo se evalúa si el argumento no está ya en la caché.
Como el argumento que utilizamos al llamar a la función sirve como clave en el diccionario, debe ser un tipo de dato hashable. Una lista no es hashable, por lo que no podemos utilizarla como clave en un diccionario. Por ejemplo, intentemos sustituir numbers
por una lista en lugar de una tupla, lo que provocará un TypeError
:
# ...
numbers = [random.randint(1, 1000) for _ in range(1_000_000)]
# ...
Traceback (most recent call last):
...
TypeError: unhashable type: 'list'
Crear una caché manualmente está muy bien para aprender, pero ahora vamos a explorar formas más rápidas de hacerlo.
Caché en Python con functools.lru_cache()
Python dispone del decorador lru_cache()
desde la versión 3.2. El "lru" al principio del nombre de la función significa "menos utilizado recientemente". Podemos pensar en la caché como una caja para almacenar cosas que se utilizan con frecuencia: cuando se llena, la estrategia LRU tira el elemento que no hemos utilizado en más tiempo para dejar espacio a algo nuevo.
Vamos a decorar nuestra función sum_digits()
con @functools.lru_cache
:
import functools
import random
import timeit
@functools.lru_cache
def sum_digits(numbers):
return sum(
int(digit) for number in numbers for digit in str(number)
)
numbers = tuple(random.randint(1, 1000) for _ in range(1_000_000))
print(
timeit.timeit(
"sum_digits(numbers)",
globals=globals(),
number=1
)
)
print(
timeit.timeit(
"sum_digits(numbers)",
globals=globals(),
number=1
)
)
0.28326129099878017
0.002184917000704445
Gracias al almacenamiento en caché, la segunda llamada tarda mucho menos en ejecutarse.
Por defecto, la caché almacena los 128 primeros valores calculados. Una vez que las 128 plazas están llenas, el algoritmo borra el valor utilizado menos recientemente (LRU) para dejar espacio a nuevos valores.
Podemos establecer un tamaño máximo de caché diferente cuando decoramos la función utilizando el parámetro maxsize
:
import functools
import random
import timeit
@functools.lru_cache(maxsize=5)
def sum_digits(numbers):
return sum(
int(digit) for number in numbers for digit in str(number)
)
# ...
En este caso, la caché sólo almacena cinco valores. También podemos establecer el argumento maxsize
en None
si no queremos limitar el tamaño de la caché.
Caché en Python con functools.cache()
Python 3.9 incluye un decorador de caché más sencillo y rápido-functools.cache()
. Este decorador tiene dos características principales:
- No tiene un tamaño máximo: es similar a llamar a
functools.lru_cache(maxsize=None)
. - Almacena todas las llamadas a funciones y sus resultados (no utiliza la estrategia LRU). Esto es adecuado para funciones con salidas relativamente pequeñas o cuando no necesitamos preocuparnos por las limitaciones de tamaño de la caché.
Utilicemos el decorador @functools.cache
en la función sum_digits()
:
import functools
import random
import timeit
@functools.cache
def sum_digits(numbers):
return sum(
int(digit) for number in numbers for digit in str(number)
)
numbers = tuple(random.randint(1, 1000) for _ in range(1_000_000))
print(
timeit.timeit(
"sum_digits(numbers)",
globals=globals(),
number=1
)
)
print(
timeit.timeit(
"sum_digits(numbers)",
globals=globals(),
number=1
)
)
0.16661812500024098
0.0018135829996026587
Decorar sum_digits()
con @functools.cache
equivale a asignar sum_digits
a functools.cache()
:
# ...
def sum_digits(numbers):
return sum(
int(digit) for number in numbers for digit in str(number)
)
sum_digits = functools.cache(sum_digits)
Ten en cuenta que también podemos utilizar un estilo de importación diferente:
from functools import cache
De este modo, podemos decorar nuestras funciones utilizando sólo @cache
.
Otras estrategias de almacenamiento en caché
Las propias herramientas de Python implementan la estrategia de caché LRU, en la que las entradas utilizadas menos recientemente se eliminan para dejar espacio a los nuevos valores.
Veamos otras estrategias de almacenamiento en caché:
- Primero en entrar, primero en salir (FIFO): Cuando la caché está llena, el primer elemento añadido se elimina para dejar espacio a los nuevos valores. La diferencia entre LRU y FIFO es que LRU mantiene en la caché los elementos utilizados recientemente, mientras que FIFO descarta el elemento más antiguo independientemente de su uso.
- Último en entrar, primero en salir (LIFO): El elemento añadido más recientemente se elimina cuando la caché está llena. Imagina una pila de platos en una cafetería. La placa que hemos puesto en la pila más recientemente (última en entrar) es la que quitaremos primero (primera en salir).
- Utilizado más recientemente (MRU): El valor que se ha utilizado más recientemente se descarta cuando se necesita espacio en la caché.
- Reemplazo aleatorio (RR): Esta estrategia descarta aleatoriamente un objeto para dejar espacio a uno nuevo.
Estas estrategias también pueden combinarse con medidas de la vida útil válida, que se refiere al tiempo que un dato de la caché se considera válido o relevante. Imagina un artículo en una caché. Puede que se acceda a ella con frecuencia (LRU la mantendría), pero al cabo de una semana, la noticia podría estar obsoleta.
Caché en Python: Casos de uso habituales
Hasta ahora, hemos utilizado ejemplos simplistas con fines didácticos. Sin embargo, el almacenamiento en caché tiene muchas aplicaciones en el mundo real.
En la ciencia de datos, a menudo ejecutamos operaciones repetidas en grandes conjuntos de datos. El uso de resultados almacenados en caché reduce el tiempo y el coste asociados a la realización repetida de los mismos cálculos en los mismos conjuntos de datos.
También podemos utilizar la caché para guardar recursos externos, como páginas web o bases de datos. Veamos un ejemplo y guardemos en caché un artículo de DataCamp. Pero antes tendremos que instalar el módulo de terceros requests
ejecutando la siguiente línea en el terminal:
$ python -m pip install requests
Una vez instalado requests
, podemos probar el siguiente código, que intenta obtener el mismo artículo de DataCamp dos veces utilizando el decorador @lru_cache
:
import requests
from functools import lru_cache
@lru_cache(maxsize=10)
def get_article(url):
print(f"Fetching article from {url}")
response = requests.get(url)
return response.text
print(get_article("https://www.datacamp.com/tutorial/decorators-python"))
print(get_article("https://www.datacamp.com/tutorial/decorators-python"))
Fetching article from https://www.datacamp.com/tutorial/decorators-python
<!DOCTYPE html><html lang="en-US"><head><title>Just a moment...</title>...
<!DOCTYPE html><html lang="en-US"><head><title>Just a moment...</title>...
Como nota al margen, hemos truncado la salida porque es muy larga. Observa, sin embargo, que sólo la primera llamada a get_article()
imprime la frase Fetching article from {url}
.
Esto se debe a que sólo se accede a la página web la primera vez que se realiza la llamada. El resultado se almacena en la caché de la función. Cuando solicitamos la misma página web por segunda vez, se devuelven en su lugar los datos almacenados en la caché.
El almacenamiento en caché garantiza que no se produzcan retrasos innecesarios al obtener los mismos datos repetidamente. Las API externas también suelen tener límites de velocidad y costes asociados a la obtención de datos. El almacenamiento en caché reduce los costes de las API y la probabilidad de llegar a los límites de velocidad.
Otro caso de uso común es el de las aplicaciones de aprendizaje automático , en las que es necesario repetir varios cálculos costosos. Por ejemplo, si necesitamos tokenizar y vectorizar un texto antes de utilizarlo en un modelo de aprendizaje automático, podemos almacenar el resultado en una caché. De este modo no tendremos que repetir las operaciones de alto coste computacional.
Desafíos comunes al almacenar en caché en Python
Hemos aprendido las ventajas del almacenamiento en caché en Python. También hay que tener en cuenta algunos retos e inconvenientes al implantar una caché:
- Invalidación y coherencia de la caché: Los datos pueden cambiar con el tiempo. Por lo tanto, también puede ser necesario actualizar o eliminar los valores almacenados en una caché.
- Gestión de la memoria: Almacenar grandes cantidades de datos en una caché requiere memoria, y esto puede causar problemas de rendimiento si la caché crece indefinidamente.
- Complejidad: Añadir cachés introduce complejidad en el sistema al crear y mantener la caché. A menudo, los beneficios superan a estos costes, pero esta mayor complejidad podría dar lugar a errores difíciles de encontrar y corregir.
Conclusión
Podemos utilizar la memoria caché para optimizar el rendimiento cuando se repiten operaciones de cálculo intensivo sobre los mismos datos.
Python dispone de dos decoradores para crear una caché al llamar a funciones: @lru_cache
y @cache
en el módulo functools
.
Sin embargo, debemos asegurarnos de que mantenemos actualizada la caché y gestionamos adecuadamente la memoria.
Si quieres aprender más sobre caché y Python, echa un vistazo a este curso de seis cursos sobre programación en Python.
¡Aprende Python para la ciencia de datos!
Course
Caja de herramientas Python
Course
Practicar preguntas de entrevista sobre codificación en Python
tutorial
Tutorial de funciones de Python
tutorial
Tutorial y Ejemplos de Funciones y Métodos de Listas en Python
tutorial
Tutorial sobre cómo trabajar con módulos en Python
Nishant Kumar
8 min
tutorial
21 herramientas esenciales de Python
tutorial
Tutorial de comprensión del diccionario Python
tutorial