26 août 2016

La mise en cache et ses utilisations en Python

Design & Code

Dr. Ruijing

HU

Image homme sur ordinateur.

26 août 2016

La mise en cache et ses utilisations en Python

Design & Code

Dr. Ruijing

HU

Image homme sur ordinateur.

26 août 2016

La mise en cache et ses utilisations en Python

Design & Code

Dr. Ruijing

HU

Image homme sur ordinateur.

Nous allons aborder la mise en cache en Python.

La mise en cache est une technique d'optimisation que vous pouvez utiliser dans vos applications pour conserver les données récentes ou souvent utilisées dans des emplacements mémoire qui sont plus rapides ou moins coûteux en calcul à accéder que leur source. 

Imaginez que vous créez une application de lecture des ordres en tarification du marché financier qui récupère les dernières actualités de différentes sources du monde. Au fur et à mesure que l'utilisateur navigue dans la liste, votre application télécharge les ordres et les affiche à l'écran. Que se passerait-il si l'utilisateur décidait de manipuler les différentes agrégations et filtrages sur ces ordres ? À moins que vous ne mettiez en cache les données, votre application devrait récupérer le même contenu du monde ailleurs à chaque fois ! Cela rendrait le système de votre utilisateur lent et exercerait une pression supplémentaire sur le serveur hébergeant les ordres en tarification. Une meilleure approche serait de stocker le contenu localement après l'avoir récupéré une fois. Ensuite, la prochaine fois que l'utilisateur décidera de gérer ces ordres, votre application pourrait obtenir le contenu à partir d'une copie stockée localement au lieu de revenir à la source externe. En informatique, cette technique s'appelle la mise en cache. 

Les stratégies de mise en cache

Le langage Python est bien utilisé dans le domaine de la finance grâce à son efficacité et sa performance pendant le développement des modèles et outils financiers. L’alimentation des données massives et la réutilisation des données intermédiaires au calcul des modèles deviennent fréquentes dans les applications financières, dont le temps d’une exécution fiable doit être le plus court possible. Python fournit les caches à la base d’un dictionnaire qui contrôle la vie de la donnée par différentes stratégies. C’est une manière efficace d'éviter le débordement de la mémoire. On a besoin de définir une capacité maximale de la mémoire avant d’appeler ces caches. 

Voici les stratégies retrouvées dans les librairies du Python [2] :

  1. Premier entré / premier sorti (FIFO) : il supprime la plus ancienne des entrées, pour que les nouvelles entrées soient les plus susceptibles d'être réutilisées.

  2. Dernier entré / premier sorti (LIFO) : il supprime la dernière des entrées, pour que les anciennes entrées soient les plus susceptibles d'être réutilisées.

  3. Moins récemment utilisé (LRU) : il expulse l'entrée la moins récemment utilisée, pour que les entrées récemment utilisées soient les plus susceptibles d'être réutilisées.

  4. Dernière utilisation (MRU) : il expulse l'entrée la plus récemment utilisée, pour que les entrées les moins récemment utilisées soient les plus susceptibles d'être réutilisées.

  5. La moins fréquemment utilisée (LFU) : il expulse l'entrée la moins souvent consultée, pour que les entrées ayant beaucoup d'appels soient le plus susceptibles d'être réutilisées.

  6. Remplacement aléatoire (RR) : il expulse l'entrée aléatoirement, pour que les entrées soient uniformément susceptibles d'être réutilisées.

Ils présentent les avantages comme la simplicité et l’efficacité de leurs mises en place. Néanmoins, il manque une option pour manipuler la mise à jour de données raffinées lors du déclenchement d'un incident spécifique dans le programme.

Les autres libraires de cache sont à la base des stratégies, qui visent à renvoyer rapidement les mêmes données coûteuses à acquérir. Par exemple, la libraire cached_property [1] est populairement utilisée dans les applications liées aux bases de données et réseaux [4]. Pour être accompagné sur ces sujets, rapprochez-vous de notre expertise Python.

Les nouvelles stratégies raffinées et flexibles

Dans certains contextes, on voudrait garder les informations cachées jusqu’à un démarrage événementiel spécifié dans l’application.

On démontre ici que les implémentations de décorateur en Python, permettent de satisfaire ce besoin où les résultats des appels répétitifs sont mis en cache ou rejetés quand on veut.

1. Cache au niveau de la méthode libre

On conçoit le décorateur de cache en définissant une méthode __call__ qui gère un dictionnaire comme une mémoire locale.

Le dictionnaire prend le nom de la méthode à décorer et les arguments d’entrée comme les clés, alors que la valeur est le retour de la méthode. Du coup, on ne calcule qu’une fois dans la méthode et on répète le même retour à suite, avant le ramasse-miettes (garbage_collect). 

On pourrait effacer les résultats aux trois niveaux quand on veut. Si l’on ne met rien en argument d’entrée pour garbage_collect, on supprime tous les retours cachés. Si on définit l’obj par un ensemble de noms de méthodes qu’on a décoré par le cache, on n’enlève que les résultats calculés de ces méthodes. Sinon, on expulse la valeur obtenue par le nom de la méthode précisé par l’obj.

Le retour d’une méthode pourrait être un objet de type basique ou complexe. On prend en compte dans le cache le générateur et le tee (liste des générateurs) comme le type.

 caches.py
from itertools import tee
from types import FunctionType, GeneratorType
Tee = tee([], 1)[0].__class__
class FunctionCache(object):
    func_cache = {}
    def __init__(self, func: FunctionType):
        self.function = func
        self._func_name = func.__qualname__
    @property
    def func_name(self):
        return self._func_name
    @staticmethod
    def garbage_collect(obj=None):
        if obj is None:
            FunctionCache.func_cache.clear()
            return
        if isinstance(obj, set):
            tmp_cache = {}
            for k, v in FunctionCache.func_cache.items():
                if k[0] in obj:
                    tmp_cache[k] = FunctionCache.func_cache[k]
            FunctionCache.func_cache.clear()
            FunctionCache.func_cache = tmp_cache
            return
        key_to_clear = [
            k for k in FunctionCache.func_cache.keys()
            if obj.func_name in k[0]]
        for k in key_to_clear:
            # print(f"del obj: {obj.func_name}")
            del FunctionCache.func_cache[k]
        del obj
    def __call__(self, *args):
        key = (self._func_name, args)
        if key not in self.func_cache:
            self.func_cache[key] = self.function(*args)
        if isinstance(self.func_cache[key], (GeneratorType, Tee)):
            # the original can't be used any more,
            # so we need to change the cache as well
            self.func_cache[key], r = tee(self.func_cache[key])
            return r
        return self.func_cache[key]

 

2.     Cache au niveau de l’instance de class

L'instance d’une classe dépend des arguments d’entrée pour __init__. On prend alors ces arguments comme les clés du dictionnaire « instances » défini dans la classe à décorer. On libera l’objet par ces arguments. Il est possible d’avoir la méthode statique dans la classe, ce qui est pris en compte dans l’implémentation. 

 caches.py
class InstanceCache(object):
    def __init__(self, cls):
        self.cls = cls
        self.__dict__.update(cls.__dict__)
        # it allows staticmethods to work
        for attr, val in cls.__dict__.items():
            if isinstance(val, staticmethod):
                self.__dict__[attr] = val.__func__
    @staticmethod
    def get_key(args):
        return '//'.join(map(str, args))
    def __call__(self, *args):
        key = self.get_key(args)
        if key not in self.cls.instances:
            self.cls.instances[key] = self.cls(*args)
        return self.cls.instances[key]
    def garbage_collect(self, args=None):
        if args is None:
            self.cls.instances.clear()
            return
        key = self.get_key(args)
        del self.cls.instances[key]

3.    Cache python au niveau de la propriété de class

L’objectif ici est d’obtenir le même résultat par les appels répétitifs d’une méthode statique ou classique d’une classe. La mise en œuvre pourrait être considérée comme une application de la mise en cache au niveau de la méthode libre.

Par rapport aux deux caches définis dans les classes auparavant, on essaie d’implémenter ce cache par une méthode décoratrice. Les fonctions Python sont des descripteurs et on profite du design de lazy_properties (c.f. [3]). C'est ainsi que les objets de méthode sont créés. Lorsque vous faites obj.method, le protocole du descripteur est activé et la méthode __get__ de la méthode est appelée. Cela renvoie une méthode liée. Le cache de la propriété de classe cherche à mettre en mémoire les descripteurs, alors que la mise en cache de la méthode libre pourrait servir à mémoriser la méthode de la classe. On encapsule les méthodes __get__ et __set__ dans la classe ClassPropetyrDescriptor et on l'hérite dans la classe CachedClassPropetyrDescriptoren introduisant le cache de la méthode libre. Dans le décorateur on passe un argument to_cache pour déterminer si l'application du cache de propriété est activée.


 caches.py
class ClassPropertyDescriptor(object):
    def __init__(self, f_get, f_set=None):
        self.f_get = f_get
        self.f_set = f_set
    def __get__(self, obj, klass=None):
        if klass is None:
            klass = type(obj)
        return self.f_get.__get__(obj, klass)()
    def __set__(self, obj, value):
        if not self.f_set:
            raise AttributeError("can't set attribute")
        type_ = type(obj)
        return self.f_set.__get__(obj, type_)(value)
class CachedClassPropertyDescriptor(ClassPropertyDescriptor):
    @FunctionCache
    def __get__(self, obj, klass=None):
        return super().__get__

On teste les trois caches définis danscaches.py par les tests unitaires.

 test_caches.py
from caches import (
    InstanceCache,
    cached_class_property,
    FunctionCache,
)
import pytest
import time
import timeit
class TestCaches(object):
    class TestSquare:
        __test__ = False
        def __init__(self, v, t):
            self.v = v
            self.t = t
        @FunctionCache
        def get_value(self):
            time.sleep(self.t)
            return self.v
        @FunctionCache
        def get_square(self):
            return self.get_value(self) * self.get_value(self)
    @pytest.fixture(autouse=True)
    def _pass_fixtures(self):
        FunctionCache.func_cache.clear()
    @pytest.mark.parametrize("v, t, expected_v, expected_t",
                             [
                                 (1, 0.1, 1, 0.1),
                                 (2, 0.1, 2, 0.1),
                             ])
    def test_function_cache_free_func(self, v, t, expected_v, expected_t):
        @FunctionCache
        def f(x):
            time.sleep(t)
            return x
        assert expected_v == f(v)
        time_elapse = timeit.timeit()
        assert expected_v == f(v)
        assert timeit.timeit() - time_elapse < expected_t
    @pytest.mark.parametrize("v, t, expected_v, expected_t",
                             [
                                 (10, 0.1, range(10), 0.1),
                                 (20, 0.1, range(20), 0.1),
                             ])
    def test_function_cache_free_func_with_generator(self,
                                                     v,
                                                     t,
                                                     expected_v,
                                                     expected_t):
        @FunctionCache
        def f(x):
            for index in range(x):
                time.sleep(t)
                yield index
        g_init = f(v)
        g_next = f(v)
        for i in expected_v:
            assert expected_v[i] == next(g_init)
            time_elapse = timeit.timeit()
            assert expected_v[i] == next(g_next)
            assert timeit.timeit() - time_elapse < expected_t
    @pytest.mark.parametrize("v, t, expected_v, expected_t",
                             [
                                 (1, 0.1, 1, 0.1),
                                 (2, 0.1, 4, 0.1),
                             ])
    def test_function_cache_class_func(self, v, t, expected_v, expected_t):
        ts = self.TestSquare(v, t)
        assert expected_v == ts.get_square(ts)
        time_elapse = timeit.timeit()
        assert expected_v == ts.get_square(ts)
        assert timeit.timeit() - time_elapse < expected_t
    @pytest.mark.parametrize("v, t, expected_v, expected_t",
                             [
                                 (1, 0.1, 1, 0.1),
                                 (2, 0.1, 2, 0.1),
                             ])
    def test_cache(self, v, t, expected_v, expected_t):
        @InstanceCache
        class T:
            instances = {}
            def __init__

Application

Dans cette section, on développe une application en profitant des caches mis en oeuvre.

Cette application cherche les informations des clients des sources externes, dont la latence est émulée par le sleep(3) dans le code. On peut décorer les méthodes coûteuses de façon raffinée et flexible, ainsi que libérer ses données de retour si besoin.

Lors de l'exécution du code, on trouve que le temps d'attente par les appels répétitifs estbien diminué avant le ramasse-miette. 

 clients
from caches import (
    FunctionCache,
    cached_class_property,
    InstanceCache,
)
from datetime import datetime
import time
@FunctionCache
def headline():
    """
    Returns the application headline.
    Parameters
    ----------
    None
    Returns
    -------
    str
    """
    time.sleep(3)
    return 'This is our cache application!'
class Client(object):
    """
    A class to represent a client.
    ...
    Attributes
    ----------
    id : str
        id of the client
    fullname : int
        full name of the client
    Methods
    -------
    copyright():
        returns the client's related class copyright.
    info(additional=""):
        returns the client's full name and id.
    """
    def __init__(self, id, fullname):
        """
        Constructs all the necessary attributes for the client object.
        Parameters
        ----------
            id : str
                id of the client
            fullname : str
                full name of the client
        """
        self.id = id
        self.fullname = fullname
    @cached_class_property()
    def copyright(self):
        """
        Returns the copyright.
        Parameters
        ----------
        None
        Returns
        -------
        str
        """
        time.sleep(3)
        return 'INVIVOO'
    def info(self, additional=""):
        """
        Returns the client's full name and id.
        If the argument 'additional' is passed,
        then it is appended after the main info.
        Parameters
        ----------
        additional : str, optional
            More info to be displayed (default is None)
        Returns
        -------
        str
        """
        return f'My name is {self.fullname}. My ID is {self.id}. {additional}'
    def __str__(self):
        return self.info(self)
class FrenchClient(Client):
    """
        A class to represent a French client.
        ...
        Attributes
        ----------
        id : str
            id of the client
        fullname : int
            full name of the client
        city : str
            city of the client
        Methods
        -------
        info(additional=""):
            Prints the client's full name and id.
        """
    def __init__(self, id, fullname, city):
        """
        Constructs all the necessary attributes for the French client object.
        Parameters
        ----------
            id : str
                id of the client
            fullname : str
                full name of the client
            city : str
                city of the client
        """
        time.sleep(3)
        super().__init__(id, fullname)
        self.city = city
    @cached_class_property()
    def country(cls):
        """
        Returns the client's country.
        Parameters
        ----------
        None
        Returns
        -------
        str
        """
        time.sleep(3)
        return 'France'
    @FunctionCache
    def info(self):
        """
        Returns the client's full name, id, city, and country.
        Parameters
        ----------
        None
        Returns
        -------
        str
        """
        time.sleep(3)
        return f'{super().info(f"I come from {self.city} in {self.country}")}'
@InstanceCache
class GermanClient(Client):
    instances = {}
    def __init__(self, id, fullname, city):
        """
        Constructs all the necessary attributes for the German client object.
        Parameters
        ----------
            id : str
                id of the client
            fullname : str
                full name of the client
            city : str
                city of the client
        """
        time.sleep(3)
        super().__init__(id, fullname)
        self.city = city
    @cached_class_property()
    def country(cls):
        """
        Returns the client's country.
        Parameters
        ----------
        None
        Returns
        -------
        str
        """
        time.sleep(3)
        return 'Germany'
    @FunctionCache
    def info(self):
        """
        Returns the client's full name, id, city, and country.
        Parameters
        ----------
        None
        Returns
        -------
        str
        """
        time.sleep(3)
        return f'{super().info(f"I come from {self.city} in {self.country}")}'
if __name__ == '__main__

Les résultats de l'application sont ainsi avec les caches.


Conclusion

Dans cet article on résume les stratégies et les applications des mises en cache en Python ; et les nouvelles mises en œuvre avec une gestion de ramasse-miette flexible. 

L’idée du cache est de mettre les valeurs dans un dictionnaire en Python. On les retrouvera localement par leurs clés plus tard au lieu de rechercher dans les bases coûteuses. On a les stratégies basiques : premier entré / premier sorti (FIFO), dernier entré / premier sorti (LIFO), Moins récemment utilisé (LRU), Dernière utilisation (MRU), La moins fréquemment utilisée (LFU) et Remplacement aléatoire (RR) qui sont bien implémentées et intégrées dans beaucoup d’applications. 

Malgré leurs simplicités et efficacités, on préfère parfois dans certains contextes gérer les ramasse-miettes du cache de façon raffinée et flexible. Le mécanisme de suppression des clés dans le dictionnaire est conçu et présenté pour s’adapter aux méthodes libres, les objets et les propriétés de la classe. Leurs performances des caches sont bien testées par les tests unitaires, alors que l'on développe enfin une application qui s'appuie sur les caches pour atténuer la latence des recherches de données coûteuses. 

Références

1.     https://pypi.org/project/cached-property/

2.     https://pypi.org/project/cacheout/

3.     https://realpython.com/python-descriptors/

4.    https://werkzeug.palletsprojects.com/en/1.0.x/utils/