logo le blog invivoo blanc

La mise en cache et ses utilisations en Python

17 juin 2021 | Design & Code, Python | 0 comments

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__(obj, klass)





def cached_class_property(to_cache=True):

    def class_property(func):

        if not isinstance(func, (classmethod, staticmethod)):

            func = classmethod(func)

        return (CachedClassPropertyDescriptor(func)

                if to_cache else

                ClassPropertyDescriptor(func))



    return class_property

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__(self, v, *args):

                time.sleep(t)

                self.v = v

                self.args = args



            def f(self):

                return self.v



        assert T(v).f() == expected_v

        time_elapse = timeit.timeit()

        assert expected_v == T(v).f()

        assert timeit.timeit() - time_elapse < expected_t

        assert len(T.instances) == 1

        T(v, 1), T(v, 1)

        T(v + 1), T(v + 1)

        assert len(T.instances) == 3

        assert {str(v), f'{v}//1', str(v + 1)} == set(T.instances.keys())



    def test_cached_class_property(self):

        class B:

            @cached_class_property(False)

            def a(cls):

                return 1



            @cached_class_property()

            def b(cls):

                time.sleep(0.1)

                return 11



        class A(B):

            ...



        class C(B):

            ...



        assert 1 == A.a

        assert 11 == A.b

        time_elapse = timeit.timeit()

        assert 11 == A.b

        assert timeit.timeit() - time_elapse < 0.1



        A.a = 100

        assert 100 == A.a

        A.b = 100

        assert 100 == A.b



        assert 1 == C.a

        assert 11 == C.b

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__':

    start = datetime.today()

    print(headline())

    print(f"1st time to get headline in {datetime.today()-start}")

    start = datetime.today()

    print(Client.copyright)

    print(f"1st time to get copyright of Client in {datetime.today()-start}")

    start = datetime.today()

    print(Client.copyright)

    print(f"2nd time to get copyright of Client in {datetime.today()-start}")

    start = datetime.today()

    print(headline())

    print(f"2nd time to get headline in {datetime.today()-start}")

    leo = FrenchClient('001', 'LEO DIOR', 'PARIS')

    lea = FrenchClient('002', 'LEA DIOR', 'NICE')

    print(f"instance two clients in {datetime.today()-start}")

    start = datetime.today()

    print(leo.info(leo))

    print(f"1st time to get info of LEO in {datetime.today()-start}")

    start = datetime.today()

    print(leo.info(leo))

    print(f"2nd time to get info of LEO in {datetime.today()-start}")

    start = datetime.today()

    print(lea.info(lea))

    print(f"1st time to get info of LEA in {datetime.today()-start}")

    start = datetime.today()

    print(lea.info(lea))

    print(f"2nd time to get info of LEA in {datetime.today()-start}")

    print(headline())

    start = datetime.today()

    harry = GermanClient('003', 'HARRY DIOR', 'BERLIN')

    print(f"1st time to instance Harry in {datetime.today() - start}")

    start = datetime.today()

    harry_bis = GermanClient('003', 'HARRY DIOR', 'BERLIN')

    print(f"2nd time to instance Harry in {datetime.today() - start}")

    start = datetime.today()

    print(harry.info(harry))

    print(f"1st time to get info of Harry {datetime.today()-start}")

    start = datetime.today()

    print(harry.info(harry))

    print(f"2nd time to get info of Harry {datetime.today()-start}")

    print(headline())

    start = datetime.today()

    FunctionCache.garbage_collect({headline, FrenchClient.info})

    print(f"to clear headline and FrenchClient.info from FunctionCache"

          f"in {datetime.today()-start}")

    start = datetime.today()

    print(leo.info(leo))

    print(f"to get LEO info after GC in {datetime.today()-start}")

    start = datetime.today()

    print(lea.info(lea))

    print(f"to get LEA info after GC in {datetime.today()-start}")

    start = datetime.today()

    print(headline())

    print(f"to get headline after GC in {datetime.today()-start}")

    FunctionCache.garbage_collect()

    InstanceCache(GermanClient).garbage_collect()

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

This is our cache application!

1st time to get headline in 0:00:03.003996

INVIVOO

1st time to get copyright of Client in 0:00:03.002161

INVIVOO

2nd time to get copyright of Client in 0:00:00.000047

This is our cache application!

2nd time to get headline in 0:00:00.000036

instance two clients in 0:00:06.008500

My name is LEO DIOR. My ID is 001. I come from PARIS in France

1st time to get info of LEO in 0:00:06.006954

My name is LEO DIOR. My ID is 001. I come from PARIS in France

2nd time to get info of LEO in 0:00:00.000044

My name is LEA DIOR. My ID is 002. I come from NICE in France

1st time to get info of LEA in 0:00:06.005895

My name is LEA DIOR. My ID is 002. I come from NICE in France

2nd time to get info of LEA in 0:00:00.000028

This is our cache application!

1st time to instance Harry in 0:00:03.004809

2nd time to instance Harry in 0:00:00.000027

My name is HARRY DIOR. My ID is 003. I come from BERLIN in Germany

1st time to get info of Harry 0:00:06.002546

My name is HARRY DIOR. My ID is 003. I come from BERLIN in Germany

2nd time to get info of Harry 0:00:00.000038

This is our cache application!

to clear headline and FrenchClient.info from FunctionCachein 0:00:00.000046

My name is LEO DIOR. My ID is 001. I come from PARIS in France

to get LEO info after GC in 0:00:06.008592

My name is LEA DIOR. My ID is 002. I come from NICE in France

to get LEA info after GC in 0:00:06.005533

This is our cache application!

to get headline after GC in 0:00:03.003777

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/