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] :
- 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.
- 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.
- 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.
- 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.
- 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.
- 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/