Mémoization d’une fonction Python 11


La mémoization est une forme de mise en cache, elle consiste à cacher le résultat d’une fonction afin que les appels successifs avec des paramètres identiques utilisent le cache plutôt que de calculer à nouveau les données.

En gros:

>>> fonction_super_lente(True, '127.0.0.1') # calcule
>>> fonction_super_lente(True, '127.0.0.1') # ne calcule pas, retourne le cache
>>> fonction_super_lente(True, '192.168.0.1') # calcule car les paramètres sont différents

En Python on peut le faire de plusieurs manières, malgré ce que prétend la philosophie du langage.

La version avec dico externe

resultats = {}
def fonction_super_lente(le_faire, sur_quoi):
 
    if not (le_faire, sur_quoi) in resultats:
        resultats[(le_faire, sur_quoi)] = # balancer le calcul lent ici
 
    return resultats[(le_faire, sur_quoi)]

Cela marche parceque resultats est gardé dans une closure et donc accessible dans la fonction. Comme il est défini avant, et que les dictionnaires sont mutables, on a toujours la même référence au même dico à chaque appel. On a juste a vérifier qu’un résultat pour ces param existent (les tuples peuvent être des clés de dictionnaires), et si non, on remplit le cache.

Avantages:

  • c’est facile à comprendre;
  • on peut manipuler le cache depuis l’extérieur.

Inconvénient:

  • le namespace du module est pollué;
  • on risque d’importer le cache par erreur.

La version avec paramètre additionel

def fonction_super_lente(le_faire, sur_quoi, _resultats={}):
 
    if not (le_faire, sur_quoi) in resultats:
        _resultats[(le_faire, sur_quoi)] = # balancer le calcul lent ici
 
    return _resultats[(le_faire, sur_quoi)]

Même chose que précédement, mais la différence est que le cache est stocké dans un paramètre. Cela marche car les paramètres sont initialisés à la déclaration en Python, et une seule fois.

Notez le underscore devant le nom de paramètre qui est une convention pour désigner un paramètre qui ne fait pas partie de l’API publique.

Avantages:

  • c’est encore pas trop dur à comprendre;
  • le namespace est clean.

Inconvénient:

  • la signature de la fonction possède un argument artificiel.

La version avec attribut

def fonction_super_lente(le_faire, sur_quoi):
 
    resultats = getattr(function_super_lente, '_resultats', {})
    if not (le_faire, sur_quoi) in resultats:
        resultats[(le_faire, sur_quoi)] = # balancer le calcul lent ici
        function_super_lente._resultats = resultats
 
    return resultats[(le_faire, sur_quoi)]

Cela marche car les fonctions en Python sont des objets. On peut leur rajouter des attributs à la volées comme pour n’importe quel objet :-)

Notez l’usage du troisième paramètre de getattr() qui nous permet d’avoir une valeur par défaut même si l’attribut n’existe pas encore.

Avantages:

  • pas de polution du namespace ni de la signature.

Inconvénient:

  • votre collègue va surement vous demander: WTF ?

La version avec décorateur

Il existe pas mal de versions de décorateurs de mémoization disponibles sur le Net. Ca s’utilise généralement comme ça :

from malib import memoized
 
@memoized
def fonction_super_lente(le_faire, sur_quoi):
 
    return # le truc normalement

Avantages:

  • y a pas plus propre;
  • y a pas plus facile.

Inconvénient:

  • une dépendance de plus;
  • chaque implémentation possède une caractéristique (souvent une limite): il faut donc comprendre des codes parfois complexes pour les utiliser en toute tranquilité d’esprit.

Petit rappel

Cette technique, de par sa nature, implique de tout stocker en mémoire vive. Donc attention à votre RAM, et vérifiez bien que ça vaut le coup d’échanger la charge CPU contre celle de vos barettes. Ensuite, le bénéfice est d’autant plus grand que le code tourne longtemps. Si c’est un script lancé de nombreuses fois, avec quelques appels à la fonction, le gain est faible: entre chaque initialisation de la VM Python, le cache disparait. Dans ce cas il vaut mieux se tourner vers une solution telle que Redis.

Le cas typique d’usage pertinent est celui d’un daemon faisant des appels à une API WEB: en cachant les résultats, vous économisez une requête HTTP.

11 thoughts on “Mémoization d’une fonction Python

  • ObnoxiousJul

    Si vous voulez voir un comparatif de caches: récit d’un panouillage avec memoize
    J’ai testé pour vous :

    * memoize avec dict;
    * memoize avec un dict à taille fixe mimine 1.0;
    * repoze.lru (LRU) cache à taille fixe et éventuellement à temps d’expiration;
    * beaker.cache (fait tout mémoire, disk, memcached, taille fixe…)

    À le problème d’un mémoize c’est que si on rappelle la fonction souvent on peut pas vider le cache donc repoze.lru propose un fournisseur de cache nommé que l’on peut vider, qui a mon grand dam n’est pas documenté.

    Si vous voulez le beurre l’argent du beurre, et marier la crémière je conseille beaker.cache.

  • Sam Post author

    Excellent article. Le benchmarking est toujours un exercice trollesque, mais c’est très bien traité.

    Le commentaire de Marius Gedminas à lui tout seul est important.

  • ObnoxiousJul

    Ça raconte surtout comment je m’ai fail comme le noob que je suis.

    Avant d’optimiser, faut bien mesurer le gain que l’on souhaite, et faire gaffe aux propriétés des fonctions que l’on cache.

    Sinon, j’insiste beaker.cache c’est la solution costard, cravatte, canne et monocle. Ai je précisé que comme repoze.lru c’est thread safe? La seule différence entre repoze.lru et beaker.cache c’est pour beaker il y avait pas besoin de soumettre un patch pour qu’elle soit complète.

  • Réchèr

    Ouahou, j’ai toujours fait ce genre de truc manuellement, sans penser que y’en a qui y aurait réfléchi dessus avec un vrai cerveau.

    Penser à remplacer le “if” par un “if not” dans la version avec attribut. Sinon y’a pas que votre collègue qui va vous demander WTF, y’a l’interpréteur python aussi.

    À part ça, les tampons, c’est top feng shui, mais des fois ça cache le texte. Peut-être que vous vous en êtes aperçus et que vous préférez le laisser comme ça, parce que ça fait artiste.

    “Ouuaaaiis t’vois, l’important, c’est pas les caractères, mec. C’est ce qu’ils veulent dire au fond d’eux mêmes, t’vois.”

  • Max

    non justement je viens juste de régler l’opacity :)
    Là ils devraient permettre de voir correctement ce qu’il y a dessous.

    Ayant une vision supermanesque j’avais complètement oublié que les mortels que vous êtes ne pouvaient voir ce qui était écrit en dessous de ces merveilleux petits tampounets…

    Pensez à faire un F5

  • Tiger-222

    Une question maître !
    Lorsque tu dis “les paramètres sont initialisés à la déclaration en Python, et une seule fois“, cela est-il vrai aussi dans ce cas là ?

    data = [fonction_super_lente(n, _resultats={}) for n in range(10000)]

    J’ai mis en place ce système pour le module MSS, et il s’avère que ça a plombé le bousin.
    Par contre, en déclarant resultats juste avant la fonction, j’ai pu constater une amélioration flagrante.

    Sinon, merci pour l’article ;)

  • kontre

    @Tiger: Pas dans ton cas, puisque tu repasses un nouveau dictionnaire à chaque appel de fonction. Dans ta ligne tu ne déclare pas la fonction, tu l’utilises !
    En déclarant le dictionnaire avant, tu utilises bien toujours le même et ça marche.

    Tu devrais avoir un truc plutôt comme ça:

    def fonction_super_lente(n, _resultats={}):
        # bal bla bla
     
    data = [fonction_super_lente(n) for n in range(10000)]

    Au passage, depuis python3.2 il y a un cache dans la lib standard: functools.lru_cache. Dans quelques années ça sera être un standard, qui sait ? En attendant une version compatible pour python < 3.2 est là: http://code.activestate.com/recipes/578078-py26-and-py30-backport-of-python-33s-lru-cache/

Leave a comment

Des questions Python sans rapport avec l'article ? Posez-les sur IndexError.