Accéder au contenu principal

Cache Python : Deux méthodes simples

Apprenez à utiliser des décorateurs comme @functools.lru_cache ou @functools.cache pour mettre en cache des fonctions en Python.
Actualisé 14 nov. 2024  · 12 min de lecture

Dans cet article, nous allons nous familiariser avec la mise en cache en Python. Nous allons comprendre ce que c'est et comment l'utiliser efficacement.

La mise en cache est une technique utilisée pour améliorer les performances d'une application en stockant temporairement les résultats obtenus par le programme afin de les réutiliser en cas de besoin ultérieur.

Dans ce tutoriel, nous allons apprendre différentes techniques de mise en cache en Python, notamment les décorateurs @lru_cache et @cache du module functools.

Pour ceux d'entre vous qui sont pressés, commençons par une très courte mise en œuvre de la mise en cache, puis poursuivons avec plus de détails.

Réponse courte : Implémentation de la mise en cache en Python

Pour créer un cache en Python, nous pouvons utiliser le décorateur @cache du module functools. Dans le code ci-dessous, vous remarquerez que la fonction print() n'est exécutée qu'une seule fois :

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'est-ce que la mise en cache en Python ?

Supposons que nous devions résoudre un problème mathématique et que nous passions une heure à trouver la bonne réponse. Si nous devions résoudre le même problème le lendemain, il serait utile de réutiliser notre travail précédent plutôt que de tout recommencer.

La mise en cache dans Python suit un principe similaire : elle stocke les valeurs lorsqu'elles sont calculées dans les appels de fonction afin de les réutiliser en cas de besoin. Ce type de mise en cache est également appelé mémorisation.

Voyons un petit exemple qui permet de calculer deux fois la somme d'une large gamme de nombres :

output = sum(range(100_000_001))
print(output)
output = sum(range(100_000_001))
print(output)
5000000050000000
5000000050000000

Le programme doit calculer la somme à chaque fois. Nous pouvons le confirmer en chronométrant les deux appels :

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

Le résultat montre que les deux appels prennent à peu près le même temps (en fonction de notre configuration, nous pouvons obtenir des temps d'exécution plus rapides ou plus lents).

Cependant, nous pouvons utiliser un cache pour éviter de calculer plusieurs fois la même valeur. Nous pouvons redéfinir le nom sum en utilisant la fonction cache() dans le module intégré 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

Le deuxième appel prend maintenant quelques microsecondes au lieu de plus d'une seconde parce que le résultat de la recherche de la somme des nombres de 0 à 100 000 000 a déjà été calculé et mis en cache - le deuxième appel utilise la valeur qui a été calculée et stockée plus tôt.

Ci-dessus, nous utilisons le décorateur functools.cache() pour inclure un cache à la fonction intégrée sum(). Pour la petite histoire, un décorateur en Python est une fonction qui modifie le comportement d'une autre fonction sans en changer le code de façon permanente. Vous pouvez en apprendre davantage sur les décorateurs dans ce tutoriel sur les décorateurs Python.

Le décorateur functools.cache() a été ajouté à Python dans la version 3.9, mais nous pouvons utiliser functools.lru_cache() pour les versions plus anciennes. Dans la section suivante, nous explorerons ces deux façons de créer un cache, y compris en utilisant la notation de décorateur plus fréquemment utilisée, telle que @cache.

Python Caching : Différentes méthodes

Le module Python functools dispose de deux décorateurs permettant d'appliquer la mise en cache aux fonctions. Explorons functools.lru_cache() et functools.cache() à l'aide d'un exemple.

Écrivons une fonction sum_digits() qui prend une séquence de nombres et renvoie la somme des chiffres de ces nombres. Par exemple, si nous utilisons le tuple (23, 43, 8) comme entrée, alors.. :

  • La somme des chiffres de 23 est de cinq.
  • La somme des chiffres de 43 est de sept.
  • La somme des chiffres de 8 est de huit.
  • La somme totale est donc de 20.

C'est l'une des façons d'écrire notre fonction 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

Utilisons cette fonction pour explorer les différentes manières de créer un cache.

Mise en cache manuelle par Python

Commençons par créer le cache manuellement. Bien que nous puissions facilement automatiser cette opération, la création manuelle d'un cache nous aide à comprendre le processus.

Créons un dictionnaire et ajoutons des paires clé-valeur à chaque fois que nous appelons la fonction avec une nouvelle valeur pour stocker les résultats. Si nous appelons la fonction avec une valeur déjà stockée dans ce dictionnaire, la fonction renverra la valeur stockée sans la retravailler :

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

Le deuxième appel à sum_digits(numbers) est beaucoup plus rapide que le premier car il utilise la valeur mise en cache.

Expliquons maintenant le code ci-dessus plus en détail. Tout d'abord, remarquez que nous créons le dictionnaire sum_digits.my_cache après avoir défini la fonction, même si nous l'utilisons dans la définition de la fonction.

La fonction sum_digits() vérifie si l'argument transmis à la fonction est déjà l'une des clés du dictionnaire sum_digits.my_cache. La somme de tous les chiffres n'est évaluée que si l'argument n'est pas déjà dans le cache.

Étant donné que l'argument utilisé lors de l'appel de la fonction sert de clé dans le dictionnaire, il doit s'agir d'un type de données hachable. Une liste n'est pas hachable, nous ne pouvons donc pas l'utiliser comme clé dans un dictionnaire. Par exemple, essayons de remplacer numbers par une liste au lieu d'un tuple, ce qui entraînera une erreur de TypeError:

# ...

numbers = [random.randint(1, 1000) for _ in range(1_000_000)]

# ...
Traceback (most recent call last):
...
TypeError: unhashable type: 'list'

La création manuelle d'un cache est très utile à des fins d'apprentissage, mais nous allons maintenant explorer des moyens plus rapides de le faire.

Mise en cache Python avec functools.lru_cache()

Python dispose du décorateur lru_cache() depuis la version 3.2. Le "lru" au début du nom de la fonction signifie "least recently used" (le moins récemment utilisé). Nous pouvons considérer la mémoire cache comme une boîte dans laquelle sont stockés les objets fréquemment utilisés. Lorsqu'elle se remplit, la stratégie LRU jette l'objet que nous n'avons pas utilisé depuis le plus longtemps pour faire de la place à un nouvel objet.

Décorons notre fonction sum_digits() avec @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

Grâce à la mise en cache, l'exécution du deuxième appel prend beaucoup moins de temps.

Par défaut, le cache stocke les 128 premières valeurs calculées. Lorsque les 128 places sont occupées, l'algorithme supprime la valeur la moins récemment utilisée (LRU) pour faire de la place aux nouvelles valeurs.

Nous pouvons définir une taille de cache maximale différente lorsque nous décorons la fonction à l'aide du paramètre 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)
    )

# ...

Dans ce cas, le cache ne contient que cinq valeurs. Nous pouvons également fixer l'argument maxsize à None si nous ne voulons pas limiter la taille du cache.

Mise en cache Python avec functools.cache()

Python 3.9 inclut un décorateur de mise en cache plus simple et plus rapide :functools.cache(). Ce décorateur présente deux caractéristiques principales :

  • Il n'y a pas de taille maximale - c'est un peu comme si vous appeliez functools.lru_cache(maxsize=None).
  • Il stocke tous les appels de fonction et leurs résultats (il n'utilise pas la stratégie LRU). Cette méthode convient aux fonctions dont les résultats sont relativement faibles ou lorsqu'il n'y a pas lieu de s'inquiéter des limites de la taille de la mémoire cache.

Utilisons le décorateur @functools.cache sur la fonction 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

Décorer sum_digits() avec @functools.cache équivaut à assigner sum_digits à 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)

Notez que nous pouvons également utiliser un style d'importation différent :

from functools import cache

De cette façon, nous pouvons décorer nos fonctions en utilisant seulement @cache.

Autres stratégies de mise en cache

Les outils propres à Python mettent en œuvre la stratégie de mise en cache LRU, dans laquelle les entrées les moins récemment utilisées sont supprimées pour faire de la place aux nouvelles valeurs.

Examinons quelques autres stratégies de mise en cache :

  • Premier entré, premier sorti (FIFO): Lorsque le cache est plein, le premier élément ajouté est supprimé pour faire de la place aux nouvelles valeurs. La différence entre LRU et FIFO est que LRU conserve les éléments récemment utilisés dans le cache, tandis que FIFO rejette l'élément le plus ancien, quelle que soit son utilisation.
  • Dernier entré, premier sorti (DEPS): L'élément le plus récemment ajouté est supprimé lorsque le cache est plein. Imaginez une pile d'assiettes dans une cafétéria. La plaque que nous avons placée le plus récemment sur la pile (last in) est celle que nous retirerons en premier (first out).
  • Le plus souvent utilisé (MRU): La valeur qui a été utilisée le plus récemment est écartée lorsque de l'espace est nécessaire dans le cache.
  • Remplacement aléatoire (RR): Cette stratégie consiste à se débarrasser aléatoirement d'un objet pour faire de la place à un nouvel objet.

Ces stratégies peuvent également être combinées avec des mesures de la durée de vie valide, c'est-à-dire la durée pendant laquelle un élément de données dans le cache est considéré comme valide ou pertinent. Imaginez un article de presse dans un cache. Elle peut être fréquemment consultée (LRU la conserverait), mais au bout d'une semaine, l'information pourrait être dépassée.

Python Caching : Cas d'utilisation courants

Jusqu'à présent, nous avons utilisé des exemples simplistes à des fins d'apprentissage. Cependant, la mise en cache a de nombreuses applications dans le monde réel.

Dans le domaine de la science des données, nous exécutons souvent des opérations répétées sur de grands ensembles de données. L'utilisation de résultats mis en cache réduit le temps et le coût associés à l'exécution répétée des mêmes calculs sur les mêmes ensembles de données.

Nous pouvons également utiliser la mise en cache pour sauvegarder des ressources externes telles que des pages web ou des bases de données. Prenons un exemple et mettons en cache un article de DataCamp. Mais d'abord, nous devons installer le module tiers requests en exécutant la ligne suivante dans le terminal :

$ python -m pip install requests

Une fois que requests est installé, nous pouvons essayer le code suivant, qui tente de récupérer deux fois le même article de DataCamp en utilisant le décorateur @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>...

Par ailleurs, nous avons tronqué le résultat parce qu'il est très long. Remarquez toutefois que seul le premier appel à get_article() imprime la phrase Fetching article from {url}.

En effet, la page web n'est accessible que lors du premier appel. Le résultat est stocké dans le cache de la fonction. Lorsque nous demandons la même page web la deuxième fois, les données stockées dans le cache sont renvoyées à la place.

La mise en cache permet d'éviter les retards inutiles lors de la recherche répétée des mêmes données. Les API externes ont souvent des limites de taux et des coûts associés à l'obtention des données. La mise en cache réduit les coûts des API et la probabilité d'atteindre les limites de taux.

Un autre cas d'utilisation courant est celui des applications d'apprentissage automatique où plusieurs calculs coûteux doivent être répétés. Par exemple, si nous devons tokeniser et vectoriser un texte avant de l'utiliser dans un modèle d'apprentissage automatique, nous pouvons stocker le résultat dans un cache. Ainsi, nous n'aurons pas besoin de répéter les opérations coûteuses en termes de calcul.

Défis communs lors de la mise en cache en Python

Nous avons découvert les avantages de la mise en cache en Python. La mise en place d'un cache présente également des difficultés et des inconvénients qu'il convient de garder à l'esprit :

  • Invalidation et cohérence de la mémoire cache: Les données peuvent changer avec le temps. Par conséquent, les valeurs stockées dans un cache peuvent également devoir être mises à jour ou supprimées.
  • Gestion de la mémoire: Le stockage de grandes quantités de données dans un cache nécessite de la mémoire, ce qui peut entraîner des problèmes de performance si le cache s'agrandit indéfiniment.
  • La complexité: L'ajout de caches rend le système plus complexe lors de la création et de la maintenance du cache. Souvent, les avantages l'emportent sur les coûts, mais cette complexité accrue peut entraîner des bogues difficiles à trouver et à corriger.

Conclusion

Nous pouvons utiliser la mise en cache pour optimiser les performances lorsque des opérations à forte intensité de calcul sont répétées sur les mêmes données.

Python dispose de deux décorateurs pour créer un cache lors de l'appel de fonctions : @lru_cache et @cache dans le module functools.

Nous devons toutefois veiller à maintenir le cache à jour et à gérer correctement la mémoire.

Si vous voulez en savoir plus sur la mise en cache et le cursus Python, jetez un coup d'œil à ce parcours de six cours sur la programmation Python.


Photo of Stephen Gruppetta
Author
Stephen Gruppetta
LinkedIn
Twitter

J'ai étudié la physique et les mathématiques au niveau UG à l'université de Malte. J'ai ensuite déménagé à Londres et obtenu un doctorat en physique à l'Imperial College. J'ai travaillé sur de nouvelles techniques optiques pour obtenir des images de la rétine humaine. Aujourd'hui, je me concentre sur l'écriture, la communication et l'enseignement de Python.

Sujets

Apprenez Python pour la science des données !

Certification disponible

cours

Introduction aux fonctions en Python

3 hr
416.6K
Apprenez l'art d'écrire vos propres fonctions en Python, ainsi que des concepts clés tels que le cadrage et la gestion des erreurs.
Afficher les détailsRight Arrow
Commencer Le Cours
Voir plusRight Arrow