logo le blog invivoo blanc

Les slots, une optimisation méconnue

24 juin 2019 | Python | 0 comments

En tant qu’ancien développeur C travaillant dans le calcul hautes performances, je me suis très tôt posé la question de la compacité des objets que l’on créait ordinairement en Python. Il m’est apparu rapidement que celle-ci n’était pas optimale dans la plupart des cas, ce qui résulte de choix de conceptions originels voulus et assumés.
Néanmoins, j’ai découvert à cette occasion qu’il existait une façon de résoudre en partie ce problème depuis l’apparition des nouvelles classes en Python 3 : recourir à des déclarations d’attributs appelés slots.

1. Quelques rappels et observations

Lorsque nous écrivons une classe en Python, nous ne procédons pas à une déclaration statique de ses attributs. Au mieux, dans la méthode __init__ de l’objet nous initialisons un certain nombre d’attributs d’instance. Mais cela
ne nous empêche d’en rajouter à la volée par la suite. Illustrons ce propos :

>>> class MaClasse:
. . .      def __init__(self, a, b):
. . .          self.a = a
. . .          self.b = b
. . .
>>> mon_objet = MaClasse(1, 2)
>>> mon_objet.c = 3

On voit que l’interpréteur accepte que je rajoute l’attribut c. Comment cela fonctionne ? Sans nous étendre sur les subtilités des accès aux attributs, c’est l’existence d’un attribut spécial appelé __dict__ qui rend l’opération possible. En effet, la dernière ligne de code est équivalente à :

>>> mon_objet.__dict__[‘c’] = 3

Tous les attributs d’instance rajoutés via une instruction du type self.x, que ce soit dans la méthode __init__ ou ailleurs, finissent dans ce dictionnaire.

C’est à partir de cette simple observation que j’ai commencé très tôt à me poser la question de la performance et de la compacité en mémoire des objets ainsi construits. Puisqu’ils sont extensibles, leur implémentation ne peut être optimale au regard de ces critères. CPython est l’implémentation principale de Python et qui nous intéresse ici. Elle est obligée d’allouer de la mémoire à l’aveugle, une première fois. Puis d’autres, selon une heuristique que nous n’avons pas besoin de connaître à priori. Et pour ce qui est des performances, devoir aller chercher un attribut dans un dictionnaire Python n’est sans doute pas non plus la manière le plus optimal.
C’est en réalité principalement pour cette question de performance que Guido van Rossum a créé les slots.

2. Présentation

Les slots sont une manière de déclarer les attributs d’instance qui composent nos objets. Depuis le début, j’ai pris soin de parler d’attributs d’instance pour bien faire la distinction avec les autres attributs accessibles depuis un objet, comme les méthodes qu’on peut lui appliquer par exemple, qui sont des attributs de classe.

Dans l’exemple précédent, j’ai défini trois attributs au cours de la vie de mon objet. Voyons comment redéfinir celui-ci avec des slots.

>>> class MaClasseSlottee:
. . .  
. . .      __slots__ = (‘a’, ‘b’, ‘c’)
. . . 
. . .      def __init__(self, a, b):
. . .          self.a = a
. . .          self.b = b
. . .
>>> mon_objet_slotte = MaClasseSlottee(1, 2)
>>> mon_objet_slotte.c = 3

C’est tout ! Il suffit d’ajouter un attribut de classe _slots_ renvoyant à un itérable avec les noms des attributs d’instance que l’on souhaite manipuler. Aucun autre ne sera accepté :

>>> mon_objet_slotte.d = 4
Traceback (most recent call last):
    File “<input>”, line 1, in <module>
AttributeError: ‘MaClasseSlottee’ object has no attribute ‘d’

Effectivement, l’attribut d ne figure pas dans la liste de ceux que nous avons déclarés. Quelles sont les autres conséquences de l’ajout de __slots__ ? D’abord, la disparition pure et simple de __dict__ :

>>> mon_objet_slotte.__dict__[‘c’]
Traceback (most recent call last):
    File “<input>”, line 1, in <module>
AttributeError: ‘MaClasseSlottee’ object has no attribute ‘__dict__’

Ensuite, les gains en termes d’occupation mémoire et de performance. Le premier aspect est difficile à mesurer en Python car la fonction getsizeof du module sys, prévue à cet effet, se base sur une méthode magique __sizeof__ dont le comportement par défaut ne va pas procéder à une inspection profonde de l’objet. Voyons ce que cela donne :

>>> getsizeof(mon_objet)
56
>>> getsizeof(mon_objet_slotte)
64

À première vue, l’ajout des slots résulte en un objet plus lourd en mémoire. À première vue seulement. En réalité, c’est juste qu’on a omis un petit détail. Le premier objet que nous avons créé garde ses attributs dans son __dict__, alors que ce n’est pas le cas pour le second, puisque son __dict__ a disparu. Or getsizeof appliqué sur l’objet entier ne mesure pas l’occupation mémoire du contenu de __dict__. Vérifions par nous-même :

>>> getsizeof(mon_objet.__dict__)
112

Effectivement vu sa taille, __dict__ n’était pas mesuré par getsizeof. Cela est en revanche aussi vrai pour __slots__ dans le second objet, qu’il faut donc compter pour être équitable :

>>> getsizeof(mon_objet_slotte.__slots__)
72

La comparaison qui a vraiment du sens est donc plutôt 56 + 112 contre 64 + 72 (soit 168 contre 136) ! Comme promis, on observe bien un gain.

Néanmoins il est essentiel de relativiser car plus on rajoute d’attributs plus la différence entre les deux approches devient anecdotique par rapport à la taille totale de l’objet. C’est la raison pour laquelle on peut souvent lire et entendre que les slots sont utiles quand on doit travailler avec beaucoup d’instances d’une même classe dont la taille est relativement petite. C’est vrai, mais pas seulement !

En réalité, comme je l’évoquais plus haut, la principale raison qui a poussé à créer ce système est la question de la performance. Le fait d’éliminer la recherche dans un dictionnaire et d’appeler à la place un descripteur rend l’accès aux attributs plus rapide. On peut mesurer cet effet de manière relativement simple en profilant un bout de code répétant des opérations de lecture/écriture :

>>> import timeit
>>> def test(avec_slots=False):
. . .      instance = MaClasse(1, 2) if not avec_slots else MaClasseSlottee(1, 2)
. . .      def repeat_basic_operations(instance=instance):
. . .          for _ in range(10):
. . .              instance.a, instance.b = 3, 4
. . .              instance.a, instance.b
. . .      return repeat_basic_operations
. . .
>>> for _ in range(10):
. . .      avec_slots = min(timeit.repeat(test(avec_slots=True)))
. . .      sans_slots = min(timeit.repeat(test()))
. . .      printf(f’Le test sans slots prend {(sans_slots/avec_slots – 1)*100:.4}% de temps en plus’)
. . .  
Le test sans slots prend 15.21% de temps en plus
Le test sans slots prend 14.62% de temps en plus
Le test sans slots prend 14.76% de temps en plus
Le test sans slots prend 16.23% de temps en plus
Le test sans slots prend 15.6% de temps en plus
Le test sans slots prend 14.8% de temps en plus
Le test sans slots prend 18.42% de temps en plus
Le test sans slots prend 15.78% de temps en plus
Le test sans slots prend 15.76% de temps en plus
Le test sans slots prend 15.27% de temps en plus

Ces temps ont été obtenus à partir d’une versoin 3.6.7 de Python sur un Macbook Pro.

3. Inconvénients

Tout n’est malheureusement pas parfait et je vais maintenant évoquer les problèmes que je considère comme majeurs avec cette technique.

Écartons d’emblée l’impossibilité de rajouter dynamiquement des attributs aux objets grâce à __dict__, car c’est justement ce qui est à l’origine des gains observés. Mais surtout, parce qu’il est tout à fait possible de rajouter __dict__ parmi les __slots__ ! On aboutit alors à une sorte d’optimisation partielle qui ne concerne que les attributs qui sont déclarés parmi les slots, tandis qu’on peut rajouter dynamiquement tous les autres attributs qu’on souhaite.

Le problème majeur concerne plutôt l’héritage et la réutilisabilité du code. Il y a trois règles à avoir à l’esprit lorsqu’on entreprend de rajouter des slots dans un arbre d’héritage :

  • Les slots d’une classe mère s’ajoutent à ceux de la classe file
  • Il ne peut exister qu’une seule classe mère avec une séquence de slots non vide
  • Il suffit qu’une classe dans l’arbre héritage omette de déclarer des slots, mêmes vides, pour que les instances qui en résulteront aient un _dict_

On peut donc factoriser un certain nombre de slots dans une classe mère, ce qui peut s’avérer très utile. En revanche, les deux derniers points posent problème. Lorsqu’on veut définir des slots dans une classe, cela implique de repasser dans toutes les classes mères pour leur ajouter soit des slots pertinents, quitte à y introduire un __dict__ s’il est absolument nécessaire de pouvoir rajouter dynamiquement des attributs (malgré les pertes de performance qu’on a vues précédemment).

Dans les cas d’héritage multiple, du fait du second point, cela signifie qu’on se retrouve confronté à une situation difficile. Soit on peut déclarer une séquence de slots vides car la classe n’apporte pas d’attribut (seulement des méthodes), soit nous n’avons aucun moyen d’empêcher la création d’un __dict__. Ce dernier inconvénient se retrouve également si les classes dont on hérite se trouvent dans une bibliothèque qui n’a pas prévu ce cas d’utilisation et que nous ne pouvons pas l’éditer. Pour ceux qui ont déjà un peu navigué dans le code de certains modules de la bibliothèque standard (je pense par exemple à typing et collections) qui définissent des nouveaux types d’objets, c’est la raison pour laquelle vous trouverez beaucoup de définitions de slots, la plupart du temps vides.

Conclusion

On a vu que les slots sont une technique d’optimisation potentiellement très simple à mettre en œuvre. D’expérience, je sais qu’il n’est pas rare du tout d’avoir des objets dont la structure ne varie jamais et qui dérivent soit directement de la classe object soit d’une ou deux classes mère(s) qu’on peut éditer librement. Dans ces cas-là, je ne vois aucune raison de ne pas profiter des slots. D’éventuelles classes filles pourront choisir de réutiliser cette mécanique ou non, libre à leur développeur. Le seul point potentiellement gênant serait que la classe qu’on est en train d’écrire se retrouve à être utilisée comme mixin au milieu d’autres qui déclarent des slots non vides, mais c’est loin d’être un cas fréquent.

Quoiqu’il en soit, gardons à l’esprit qu’une optimisation prématurée est la source de nombreux maux. La meilleure façon de procéder consiste très certainement à rajouter cette optimisation à posteriori et progressivement, en profilant son code, encore et toujours.