Parmi les domaines qui bougent beaucoup en ce moment, on ne peut pas passer à côté de l'intelligence articifielle. Les avancées théoriques et pratiques sont tellement rapides dans le domaine que, presque à chaque fois que je me repenche dessus, je suis abasourdi par les nouveautés disponibles. J'en veux pour exemple les avancées récentes dans la manipulation de langues écrites. Il y a encore 10 ans, aucune machine ne pouvait se targuer de comprendre ou de générer du texte correctement. Aujourd'hui, plusieurs réseaux de neurones sont capables de comprendre des textes, de les résumer, voire d'en générer de plutôt convaincants à partir de "rien" !
Et le plus fort dans tout ça, c'est qu'il est possible de télécharger des réseaux pré-entrainé a priori très convaincants. J'ai longtemps hésité à me jeter sur un réseau GPT2 (qui semble être ce qui se fait de mieux en génération) ou sur un réseau BERT (qui semble être ce qui se fait de mieux pour du traitement de langage écrit polyvalent : compréhension, résumé, génération, etc.) puis j'ai trouvé un réseau BERT qui avait été entrainé en français. Comme j'aime bien jouer en français, notamment pour le traitement du langage, j'ai donc jeté mon dévolu sur cette option.
Installer le réseau de neurones
Le réseau en question s'appelle camemBERT et est disponible librement. Cependant, comme souvent dans ce domaine de l'informatique à la pointe de la recherche, on va devoir installer beaucoup de dépendances dans des versions très récentes, pour le faire fonctionner. Je recommande donc vivement d'utiliser un environnement virtuel Python si vous souhaitez conserver un système stable :
user@oziris:~$ python3 -m venv camembert.venv
user@oziris:~$ source camembert.venv/bin/activate
Maintenant qu'on est dans un environnement virtuel un minimum cloisonné, regardons ce par quoi le tutoriel va commencer :
import torch
camembert = torch.hub.load('pytorch/fairseq', 'camembert')
camembert.eval() # disable dropout (or leave in train mode to finetune)
Logiquement, ça commence par un import de torch
. À moins que vous n'ayez déjà installé PyTorch dans votre environnement global, il va donc falloir l'installer dans notre environnement virtuel :
(camembert.venv) user@oziris:~$ # D'abord, on vérifie que, effectivement, on n'a pas installé PyTorch précédemment :
(camembert.venv) user@oziris:~$ python3 -c 'import torch'
Traceback (most recent call last):
File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'torch'
(camembert.venv) user@oziris:~$ # Maintenant qu'on est fixé, on commence à peupler notre environnement virtuel
(camembert.venv) user@oziris:~$ pip install torch # Et oui : le paquet "pytorch" s'appelle "torch"...
Collecting torch
(...)
Installing collected packages: typing-extensions, numpy, torch
Successfully installed numpy-1.20.0 torch-1.7.1 typing-extensions-3.7.4.3
On voit que, en plus de PyTorch, on s'est pris numpy
et typing-extensions
. On a bien fait de se mettre dans un environnement séparé du système. Donc, l'import de torch
ne remonte plus d'erreur maintenant, c'est bon signe :
(camembert.venv) user@oziris:~$ python3 -c 'import torch' && echo "Victoire"
Victoire
On peut passer à la seconde ligne du tutorial qui semble être le (télé)chargement du réseau de neurone CamemBERT :
>>> import torch
>>> camembert = torch.hub.load('pytorch/fairseq', 'camembert')
(...)
RuntimeError: Missing dependencies: hydra-core, omegaconf, regex, requests
Visiblement, il nous manque quelques dépendances. Qu'à cela ne tienne, on est dans un environnement jetable exprès donc on peut se permettre d'ajouter pleins de librairies plus ou moins instables :
(camembert.venv) user@oziris:~$ pip install hydra-core omegaconf regex requests
(...)
ModuleNotFoundError: No module named 'sentencepiece'
(...)
ImportError: Please install sentencepiece with: pip install sentencepiece
Encore un p'tit module qui manque. Pas grave, on l'ajoute :
(camembert.venv) user@oziris:~$ pip install sentencepiece
(...)
./build_bundled.sh: 15: ./build_bundled.sh: cmake: not found
make: *** Pas de cible spécifiée et aucun makefile n'a été trouvé. Arrêt.
make: *** Aucune règle pour fabriquer la cible « install ». Arrêt.
env: « pkg-config »: Aucun fichier ou dossier de ce type
Failed to find sentencepiece pkg-config
(...)
Okay, mon mauvais, je réalise cet article sur une Debian stable et j'ai visiblement trop l'habitude d'être sur Gentoo où tous les outils de compilation du monde sont forcément installés. Il me manque cmake
et pkg-config
, donc je répare cet oubli (en installant cmake
et pkg-config
au niveau de mon système, donc on déborde de l'environnement virtual python là) puis je re-lance l'installation de sentencepiece
dans l'environnement virtuel :
root@oziris:~# apt install cmake pkg-config
(...)
(camembert.venv) user@oziris:~$ pip install sentencepiece
(...)
Successfully installed sentencepiece-0.1.95
Ça s'est plutôt correctement passé, on peut donc revenir au tutoriel et tenter, à nouveau de (télé)charger le réseau camemBERT :
>>> import torch
>>> camembert = torch.hub.load('pytorch/fairseq', 'camembert')
(...)
[...]camembert.venv/lib/python3.7/site-packages/torch/cuda/__init__.py:52: UserWarning: CUDA initialization: Found no NVIDIA driver on your system. Please check that you have an NVIDIA GPU and installed a driver from http://www.nvidia.com/Download/index.aspx (Triggered internally at /pytorch/c10/cuda/CUDAFunctions.cpp:100.)
return torch._C._cuda_getDeviceCount() > 0
Unable to build Cython components. Please make sure Cython is installed if the torch.hub model you are loading depends on it.
Installer CUDA
Cette fois il y a un minuscule piège : le message d'erreur qui apparait en dernier n'est pas le bon. En effet, j'ai déjà cython installé dans mon système, mais pas encore Cuda. Là aussi, on va déborder de l'environnement virtuel python et on va installer des paquets systèmes (en suivant les instructions officielles :
root@oziris:~# apt install nvidia-cuda-dev nvidia-cuda-toolkit
(...)
Après cette opération, 2 534 Mo d'espace disque supplémentaires seront utilisés.
Souhaitez-vous continuer ? [O/n]
(...)
update-initramfs: Generating /boot/initrd.img-***censored***
gzip: stdout: No space left on device
E: mkinitramfs failure cpio 141 gzip 1
update-initramfs: failed for /boot/initrd.img-***censored*** with 1.
dpkg: erreur de traitement du paquet initramfs-tools (--configure) :
installed initramfs-tools package post-installation script subprocess returned error exit status 1
Des erreurs ont été rencontrées pendant l'exécution :
initramfs-tools
E: Sub-process /usr/bin/dpkg returned an error code (1)
On pourrait croire que ce sont les 2,5Go de paquets qui posent problème mais pas du tout. C'est ma partition /boot
qui est pleine :
root@oziris:~# df -h|grep boot
/dev/***censored*** 236M 202M 22M 91% /boot
Heureusement, ce problème et ses solutions semblent bien documentées sur debian-like. Dans mon cas un simple apt autoremove
suffira.
root@oziris:~# apt autoremove && df -h|grep boot
/dev/***censored*** 236M 110M 114M 50% /boot
On peut donc relancer l'installation de Cuda, constater qu'on ne prend plus l'erreur, et retourner au tutorial :
>>> import torch
>>> camembert = torch.hub.load('pytorch/fairseq', 'camembert')
[...]camembert.venv/lib/python3.7/site-packages/torch/cuda/__init__.py:52: UserWarning: CUDA initialization: The NVIDIA driver on your system is too old (found version 10010). Please update your GPU driver by downloading and installing a new version from the URL: http://www.nvidia.com/Download/index.aspx Alternatively, go to: https://pytorch.org to install a PyTorch version that has been compiled with your version of the CUDA driver. (Triggered internally at /pytorch/c10/cuda/CUDAFunctions.cpp:100.)
return torch._C._cuda_getDeviceCount() > 0
Unable to build Cython components. Please make sure Cython is installed if the torch.hub model you are loading depends on it.
Forcément. J'avais déjà eu ce problème à chaque fois que je faisais du CUDA (et plus particulièrement sur Debian) : les drivers évoluent vite et abandonnent rapidement la rétro-compatibilité. Avant de se lancer dans une course à la mise à jour (puisqu'on est hors environnement virtuel et que je veux conserver un système stable) on peut vérifier les versions de CUDA et de PyTorch qu'on a installé puis chercher si, à tout hasard, une version de pytorch relativement récente serait compatible. En faisant ça, je réalise que, pour une version de PyTorch donné, il existe parfois plusieurs paquets différents pour être compatible avec des versions différentes de CUDA. Cool ! Je tente donc d'installer la dernière version de PyTorch, mais compatible avec le CUDA 9.2 que j'ai installé sur ma debian stable :
root@oziris:~# apt show nvidia-cuda-dev 2>/dev/null|grep Version # On vérifie la version de CUDA qu'on a
Version: 9.2.148-7
(camembert.vent) user@oziris:~$ pip freeze|grep torch # On vérifie la version de PyTorch qu'on avait
torch==1.7.1
(camembert.vent) user@oziris:~$ pip uninstall torch
(...)
(camembert.vent) user@oziris:~$ pip install torch==1.7.1+cu92 -f https://download.pytorch.org/whl/torch_stable.html
(...)
Successfully installed torch-1.7.1+cu92
Tester CamemBERT
On re-tente le tutorial en espérant que, cette fois, le (télé)chargement de camemBERT va bien se passer et qu'on va pouvoir taper le 3ème ligne du tutorial :
>>> import torch
>>> camembert = torch.hub.load('pytorch/fairseq', 'camembert')
Using cache found in /home/user/.cache/torch/hub/pytorch_fairseq_master
Unable to build Cython components. Please make sure Cython is installed if the torch.hub model you are loading depends on it.
>>> masked_line = 'Le camembert est <mask> :)'
>>> camembert.fill_mask(masked_line, topk=3)
[('Le camembert est délicieux :)', 0.49091005325317383, ' délicieux'), ('Le camembert est excellent :)', 0.10556904226541519, ' excellent'), ('Le camembert est succulent :)', 0.0345332995057106, ' succulent')]
YEAH ! On a un camembert qui fonctionne. En résumé : on lui donne un texte avec un trou représenté par <mask>
, et il nous répond avec des propositions pour remplir le trou. Comme on peut le voir ci-dessus, ces propositions sont plutôt très pertinentes. Maintenant qu'on a réussi à reproduire l'exemple du tuto, on peut jouer avec de pleins de façons différentes. Par exemple en vérifiant ce qu'il pense de différents smileys :
>>> import torch
>>> camembert = torch.hub.load('pytorch/fairseq', 'camembert')
>>> camembert.fill_mask('Le camembert est <mask> :-)')[0][0]
'Le camembert est délicieux :-)'
>>> camembert.fill_mask('Le camembert est <mask> :-/')[0][0]
'Le camembert est introuvable :-/'
>>> camembert.fill_mask('Le camembert est <mask> :-(')[0][0]
'Le camembert est introuvable :-('
C'est plutôt pas mal du tout cette histoire ! CamemBERT a bien réalisé que ce qui était après le "trou" avait changé, et il en a déduit un bouche-trou plus pertinent. Il faut savoir qu'une des forces des réseaux BERT, c'est de prendre en compte du contexte avant et apres un mot (ou un trou) considéré. On peut donc tenter de modifier également ce qu'il y a en début de phrase. Par exemple, vérifions comment il réagit lorsqu'on modifie légèrement le sujet :
>>> camembert.fill_mask('Le gros camembert est <mask> :-)')[0][0]
'Le gros camembert est arrivé :-)'
>>> camembert.fill_mask('Le petit camembert est <mask> :-)')[0][0]
'Le petit camembert est délicieux :-)'
>>> camembert.fill_mask('Le gros camembert est <mask> :-(')[0][0]
'Le gros camembert est arrivé :-('
>>> camembert.fill_mask('Le petit camembert est <mask> :-(')[0][0]
'Le petit camembert est délicieux :-('
>>> camembert.fill_mask('Le petit bateau est <mask> :-(')[0][0]
'Le petit bateau est mort :-('
>>> camembert.fill_mask('Le petit bateau est <mask> :-)')[0][0]
'Le petit bateau est arrivé :-)'
Tout n'est pas comme on aurait pu l'imaginer, mais rien de toute ceci ne semble totalement délirant (après tout, il a le droit d'être légitimement heureux et triste que le "gros camembert" soit arrivé).
Et si on s'éloigne franchement de l'exemple du tuto, est-ce-que ça tient encore la route tout ça ?
>>> camembert.fill_mask('Bonjour mon cher <mask> !')[0][0]
'Bonjour mon cher ami !'
>>> camembert.fill_mask('Comment allez <mask> ?')[0][0]
'Comment allez vous ?'
>>> camembert.fill_mask("J'espère que vous <mask> bien.")[0][0]
"J'espère que vous allez bien."
>>> camembert.fill_mask("Que pensez-vous de cette belle <mask> ?")[0][0]
'Que pensez-vous de cette belle exposition ?'
Franchement bluffant...Mais ces phrases peuvent sembler assez "évidentes" et je soupçonne qu'un algo très con mais disposant d'un nombre de livres assez conséquent serait parvenu au même résultat. Voyons-voir ce qu'il se passe si on tend des pièges :
>>> camembert.fill_mask("Après une grosse journée de travail j'aime me <mask>.")[0][0]
"Après une grosse journée de travail j'aime me reposer."
>>> camembert.fill_mask("Avant une grosse journée de travail j'aime me <mask>.")[0][0]
"Avant une grosse journée de travail j'aime me détendre."
>>> camembert.fill_mask("J'aime la choucroute avec une bonne <mask>.")[0][0] # Moins pertinent...BIERE !
J'aime la choucroute avec une bonne sauce."
>>> camembert.fill_mask("Hisser les voiles <mask>.")[0][0] # Pardon ?
'Hisser les voiles sur.'
>>> camembert.fill_mask("Hisser les voiles <mask>!")[0][0] # Pas mal.
'Hisser les voiles maintenant!'
>>> camembert.fill_mask("Hisser les voiles, <mask>!")[0][0] # Décidément, je n'arriverai pas à avoir "moussaillons" :-p
'Hisser les voiles, bonjour!'
Conclusion
Même s'il n'est pas parfait, camemBERT est vraiment bluffant. Par contre, en décidant de partir sur un réseau BERT plutôt que sur un réseau GPT2 je n'avais pas anticipé un petit souci : le coeur de camemBERT est capable de beaucoup de choses (compréhension de texte notamment) mais, si on ne veut/peut pas mettre les mains dans le cambouis, on doit se contenter de ce qui est livré dans le tuto (à savoir le remplissage de texte à trou). Finalement, peut-être que jouer avec un GPT2 m'aurait donné accès à un "jouet" plus utile... Affaire à suivre ?
Pour ce premier billet depuis la résurrection de mon blog et sa migration vers Pelican, je vais vous présenter une bizarrerie de Python2. Ce sera l'occasion de parler de persévérance, de temps perdu, et d'analyse de performance de code Python.
Le contexte
Il y a quelques années j'avais rédigé vite fait une petite librairie Python2 mono-fichier me permettant d'afficher proprement des tableaux dans un terminal. Bien que pleine de défauts, cette petite librairie est toujours utilisée par une poignée de projets au travail et j'avais donc toujours dans un coin de ma tête l'idée de la ré-écrire plus proprement. Le moteur principal de cette envie c'était de corriger LE défaut majeur de la librairie tel que je m'en souvenais : quand elle traite des tableaux dépassant le millier de lignes, elle devient vraiment lente (jusqu'à plusieurs dizaines de secondes juste pour l'affichage du tableau, en fonction de votre CPU).
Ce (long) weekend, je m'étais lancé dans l'écriture d'une librairie générant des graphiques en nuages de points dans un terminal. Dans l'élan, je me suis enfin attelé à la ré-écriture de cet afficheur de tableau ! Quatre années s'étant écoulées depuis la précédente version, la nouvelle sera écrite en Python3 avec un algorithme totalement différent pour calculer la largeur optimale des colonnes. À la fin de la journée, en ayant codé "une heure par-ci, dix minutes par-là", ma nouvelle version de la librairie était prête à être testée.
Je lance quelques tests et j'observe un premier effet inattendu (mais très agréable) : le choix de largeur des colonnes est beaucoup plus pertinent sur ma nouvelle version que sur l'ancienne ! Fort de cette agréable surprise, j'attaque confiant la vérification du gain de performance tant recherché. Pour comparer les performances de l'ancienne et de la nouvelle librairie, je jette une vingtaine de lignes de code dans un fichier que j'appelle "stresstest.py"
import random
data = []
for _ in range(2000):
line = [random.randint(1,i+1)*'{0:.04f}'.format(random.random()) for i in range(25)]
data.append(line)
import time
before = time.time()
import NEWLIB
a = NEWLIB.Array(data)
print(a)
after1 = time.time()
import OLDLIB
b = OLDLIB.ascii_array()
for row in data:
b.add_row(row)
print(b)
after2 = time.time()
print("Nouvelle librairie : {0}s".format(after1-before))
print("Ancienne librairie : {0}s".format(after2-after1))
Je lance ce fichier et...
$ python3 stresstest.py
...
Traceback (most recent call last):
File "(...)OLDLIB.py", line 55, in __init__
content = unicode(content, errors='replace')
NameError: name 'unicode' is not defined
Prévisible. Une librairie écrite il y a 4 ans pour du Python2 avait quand même de fortes chances de ne pas fonctionner avec du Python3. Pas grave, pour le comparatif, je n'ai qu'à lancer stresstest.py
avec un interpréteur Python2. Évidemment, cette fois c'est la nouvelle librairie qui refuse de fonctionner. Que faire ? Sachant que :
- J'avais en tête de remplacer la vieille librairie par la nouvelle (or la vieille librairie est actuellement utilisée par quelques projets qui tournent encore en Python2) ;
- Le code de la nouvelle librairie était encore frais dans ma tête (car tout juste écrit), contrairement au code de l'ancienne (pas relu depuis sa création initiale) ;
- En 4 ans je pense que j'ai progressé et que le code de la nouvelle librairie est plus facilement maintenable/modifiable que le code de l'ancienne librairie.
J'ai donc décidé de modifier ma nouvelle librairie pour la rendre compatible Python2.
Le détail où se cache le diable
Le premier problème (quand je lance ma nouvelle librairie en Python2) c'est que mon code de test demande l'affichage d'un tableau dans lequel certaines cellules contiennent des caractères nationaux. Voici l'un d'eux avec un accent sur le "e" de Fév(rier) :
│ T1 │ T2 │Futur│
│Jan│Fév│Mar│Avr│Mai│Juin│ │
│0.0│0.2│0.8│1.3│0.7│0.7 │ ? │
│0.7│0.9│1.3│1.4│0.9│1.2 │ ? │
N'ayant rien précisé sur ces chaines de caractère, Python2 considère qu'il s'agit de son type par défaut, à savoir string
. Le problème c'est que, en l'absence d'indication contraire, Python2 traite les données des string
comme si elles étaient encodées en ascii or nos caractères nationaux n'existent pas en ascii. Ainsi, Python2 "plante" dès qu'il tente de traiter cet accent.
La première correction a donc été plutôt rapide : j'ai supprimé les accents de mon jeu de données de test. Ça me permet bien de contourner le plantage, mais la sortie affichée est immonde puisque tous les caractères unicodes que j'utilise (tout particulièrement les barres verticales délimitant les colonnes du tableau) sont affichées en tant que séquence d'échapement :
$ python3 -c 'print("\u2502")'
│
$ python2 -c 'print("\u2502")'
\u2502
Autant vous dire que mes tableaux ne ressemblent plus à rien. Le problème est similaire au précédent : en l'absence de précision, Python2 considère que ma chaine de caractère est une chaine ascii et il n'interprète donc pas la séquence d'échapement unicode. Pour corriger ça on peut, par exemple, re-passer sur l'ensemble du code et préfixer les chaines de caractères qui contiennent de l'unicode par la lettre "u" :
$ python2 -c 'print("\u2502")'
\u2502
$ python2 -c 'print(u"\u2502")'
│
Étant pressé de tester la performance de ma nouvelle librairie, j'opte pour une solution plus rapide qui consiste à utiliser un import "magique" précisant à Python2 qu'il doit considérer toutes mes chaines de caractères comme étant de l'unicode. Cette précision amène ainsi Python2 à se comporter comme Python3 vis à vis des chaines de caractères, ce qui va me simplifier la vie puisque la librairie est écrite pour Python3 :
from __future__ import unicode_literals
Avec ça, je n'aurai plus de problème d'interprétation de caractères unicode puisque l'interpréteur Python2 se comportera exactement comme l'interpréteur Python3 :
$ python2 -c 'from __future__ import unicode_literals;print("\u2502")'
│
$ python3 -c 'from __future__ import unicode_literals;print("\u2502")'
│
Je re-lance le test mais, cette fois, c'est une vérification de conformité de ma nouvelle librairie qui grogne. En effet, ma nouvelle librairie exige que toutes les cellules du tableau à afficher ne contiennent que des chaines unicodes. La librairie ne se charge pas de transformer d'éventuels entiers, flottants, ou datetime.datetime
en chaines imprimables. Cette conversion en chaine de caractères imprimables, c'est le boulot du code qui appelle la librairie. Sauf que, pour vérifier que le code appelant respecte bien cette convention de type, j'ai utilisé un if isinstance(cell, str)
. La classe str
correspond bien à une chaine unicode en Python3, mais pas en Python2. En Python2, les chaines unicodes sont de type unicode
, pas de type str
...et mon import "magique" vient de transformer toutes mes chaines str
en unicode
... Bon, il suffit de modifier la condition de mon test de conformité pour que ça passe :
from sys import version_info
(...)
if isinstance(cell, str)\
or ((version_info<(3,0) and cell.__class__.__name__=='unicode')): # Python2
Nouvelle tentative : la librairie fonctionne bien...jusqu'au print
final qui me retourne le tant redouté "UnicodeEncodeError: 'ascii' codec can't encode character u'\u2502' in position 4: ordinal not in range(128)
"... Bon, je sais comment corriger ce "petit" problème : il faut appeler encode(...)
et decode(...)
aux bons moments sur les chaines que je souhaite imprimer, mais ça risque d'être un peu fastidieux et, surtout, d'insérer beaucoup de code Python2 dans ma librairie Python3. Heureusement, j'ai une solution alternative : j'avais tout codé de façon à pouvoir désactiver facilement l'utilisation de caractères unicodes exotiques pour enjoliver l'ascii-art. Je n'ai donc qu'à changer le comportement par défaut (afficher de l'unicode, sauf mention contraire) en son inverse pour le Python2 (à savoir : ne pas utiliser d'unicode, sauf mention contraire) :
class BEFORE:
def __init__(self, only_ascii=False, ...)
self.only_ascii = only_ascii
class AFTER:
def __init__(self, only_ascii=None, ...)
self.only_ascii = only_ascii
if only_ascii is None:
self.only_ascii = False if version_info>=(3,0) else True
La modification est très simple et, en fait, j'aurai du y penser avant de faire un import "magique" qui transforme la quasi totalité de mes textes en chaines unicode (on en reparlera...). Au final, j'ai touché à moins de 10 lignes et, maintenant, la librairie accepte de m'afficher des tableaux lorsqu'elle est exécutée avec un interpréteur Python2. C'est moins joli (puisque je me restreint aux symboles ascii), mais c'est quand même très lisible :
| T1 | T2 |Futur|
|Jan|Fev|Mar|Avr|Mai|Juin| |
|0.0|0.2|0.8|1.3|0.7|0.7 | ? |
|0.7|0.9|1.3|1.4|0.9|1.2 | ? |
Le moment de la confrontation est donc enfin venu, je lance mon comparateur, super confiant :
$ python2 stresstest.py
(...)
Nouvelle librairie : 12.8832149506s
Ancienne librairie : 2.36754417419s
Douche froide. Une fois la première vague de déception avalée, je hack dans ma nouvelle librairie une optimisation que j'avais été contraint de faire sur l'ancienne (sinon elle était vraiiiiiment lente) : au lieu de prendre en compte toutes les lignes pour calculer la largeur idéale des colonnes, je n'en considère qu'un échantillon. (Mal?)Heureusement, ça ne sert à rien du tout et je retire ce vieux hack.
Le diagnostic
Devant un problème de vitesse, j'ai pris l'habitude de dégainer cProfile
. Aussitôt dit, aussitôt fait : je commente les 6 lignes qui importent puis appellent l'ancienne librairie puis je relance le stresstest en traçant son exécution.
$ python2 -m cProfile -s cumtime stresstest.py
(...)
Nouvelle librairie : 14.5225701332s
(...)
1110680 function calls in 15.248 seconds
Ordered by: cumulative time
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.258 0.258 15.248 15.248 stresstest.py:1(<module>)
1 12.006 12.006 13.847 13.847 NEWLIB.py:902(__str__)
1 0.232 0.232 0.812 0.812 NEWLIB.py:853(compute_col_width)
50000 0.479 0.000 0.774 0.000 NEWLIB.py:733(formatted)
(...)
Cette fois, cProfile
me laisse un peu tomber. En effet, ce profileur a pleins de qualités (dont celles d'être dans la librairie standard et très simple à utiliser) mais il ne trace que les appels de fonctions et, visiblement, dans mon cas ça ne sera pas assez précis. En effet, la trace m'indique qu'il passe bien 13.847s
(des 14.5225701332s
que stresstest.py
mesure lui-même) dans la fonction __str__
de ma nouvelle librairie mais nous ne saurons pas qu'est-ce-qui, dans cette fonction de 28 petites lignes, prend le plus gros du temps (puisque la fonction qui a pris le plus de temps juste après est négligeable avec ses 0.812s
alors que je pensais que ça serait justement elle la plus lente puisque c'est cette portion de code qui était critique dans la vieille version).
Heureusement, Google ne me laisse pas tomber et je découvre rapidement l'existence de line_profiler
. Je l'installe donc dans un environnement virtuel avec pip install line_profiler
, j'ajoute un @profile
au dessus de ma fonction __str__
, je baisse drastiquement le nombre de lignes du tableau (pour ne pas attendre encore 15s pour avoir mon résultat) et c'est parti :
$ kernprof -l -v stresstest.py
(...)
Total time: 2.68528 s
File: NEWLIB.py
Function: __str__ at line 902
Line # Hits Time Per Hit % Time Line Contents
==============================================================
902 @profile
903 def __str__(self):
904 1 308429.0 308429.0 11.5 self.compute_col_width()
(...)
913 17500 865419.0 49.5 32.2 result += '|' if self.only_ascii else '\u2502'
(...)
923 17500 882593.0 50.4 32.9 result += to_add
(...)
La surprise n'est pas immense, mais ça fait plaisir d'avoir des "preuves". D'abord, on note que le calcul de largeur des colonnes prend ici 11.5% du temps alors qu'il ne représentait que 5,86% sous cProfile... C'est peut-être la différence de traceur qui explique cet écart, ou la réduction drastique du nombre de lignes. En tout cas, il reste marginal face aux 65% passés dans deux lignes anodines : des concaténations de chaines.
Anodines ? Pas tant que ça en fait. La vitesse de concaténation des chaines de caractères en Python est un sujet assez souvent évoqué sur Internet. D'ailleurs, pour rapidement construire une chaine de caractère en Python2, on voit très souvent des appels à la fonction join
, censée être beaucoup plus rapide :
bigstring = ''
small_string = 'plop'
# Ça, c'est censé être lent
for _ in range(500):
bigstring += small_string
# Ça, c'est censé être rapide
bigstring = ''.join([small_string for _ in range(500)])
Je le savais déjà, j'avais déjà utilisé ce genre de formulation (y compris dans ma vieille librairie), mais je n'avais jamais vérifié si il y avait un vrai gain de performance. Il est donc temps de vérifier ! Après cProfile
, on va donc utiliser timeit
, un autre module standard qui est justement conçu pour faire des comparaison de performances.
$ python2 -m timeit -s 'bigstring=""' 'for i in range(1000):' ' bigstring+="plop"'
10000 loops, best of 3: 69.7 usec per loop
$ python2 -m timeit -s 'bigstring=""' 'bigstring+="".join(["plop" for _ in range(1000)])'
10000 loops, best of 3: 49 usec per loop
D'accord c'est plus rapide, mais je n'ai même pas un facteur 2. On est loin du facteur 6 que l'ancienne librairie met à ma nouvelle.
Découverte du paradoxe
Je commence donc à être circonspect sur mon analyse. D'autant plus que, lorsque je faisais mes tests fonctionnels pendant le développement de la nouvelle librairie, elle ne m'avait pas l'air si lente que ça...
Par acquis de conscience, je prends le stresstest.py
dont j'ai commenté la partie relative à l'ancienne librairie, je supprime le décorateur @profile
, et je lance ce stresstest deux fois d'affilés en changeant juste l'interpréteur :
$ python2 stresstest.py
(...)
Nouvelle librairie : 66.7044699192s
(...)
$ python3 stresstest.py
(...)
Nouvelle librairie : 0.8455526828765869s
(...)
Rho la vache ! Ça serait juste une question d'interpréteur ?! La version 3 de Python aurait-elle amené un gain aussi énorme sur une fonction aussi basique qu'une concaténation de chaine ?! Vérifions, avec timeit, si le Python3 met vraiment une claque au Python2 (ça tombe bien, on a testé Python2 il y a un pragraphe de ça :-D).
$ python3 -m timeit -s 'bigstring=""' 'for i in range(1000):' ' bigstring+="plop"'
2000 loops, best of 5: 104 usec per loop
$ python3 -m timeit -s 'bigstring=""' 'bigstring+="".join(["plop" for _ in range(1000)])'
5000 loops, best of 5: 49.1 usec per loop
Là, c'est le moment où j'en ai perdu mon latin. Python3 est en fait légèrement plus lent que Python2 sur ces opérations de concaténation de chaine qui sont censées prendre la majorité du temps et, pourtant, Python2 est 79 fois plus lents que Python3 sur mon stresstest.py
(sur 2000 lignes) !
Conclusion
À ce moment de l'analyse j'ai passé une bonne demi-heure à vérifier tout un tas d'hypothèse à coup de timeit
. Systématiquement, Python2 était dans le même ordre de grandeur de vitesse que Python3. Puis j'ai eu une illumination...
from __future__ import unicode_literals
Évidemment, j'avais fait tous mes timeit
avec des string
standard or avec l'import de unicode_literals
j'avais, quasi-silencieusement, transformé presque toutes mes string
standard de l'interpréteur Python2 par des objets d'un type totalement différents (des objets unicode
). Voyons voir si Python2 manipule aussi bien les unicode
que les str
:
$ python2 -m timeit -s 'bigstring=""' 'for i in range(1000):' ' bigstring+="plop"'
10000 loops, best of 3: 69.9 usec per loop
$ python2 -m timeit -s 'bigstring=u""' 'for i in range(1000):' ' bigstring+=u"plop"'
100 loops, best of 3: 117 msec per loop
Et bien voilà un joli facteur 1674 ! L'explication de l'extrème lenteur de ma nouvelle librairie quand on utilise un interpréteur Python2 se décompose donc ainsi :
- J'ai transformé quasiment tous les objets
str
en objets unicode
pour le Python2 avec mon import "magique" - Python2 est beaucoup plus lent à manipuler des objets
unicode
que des objets str
Pour une comparaison plus honnête entre mes deux librairies, je lance alors un stresstest
de l'ancienne librairie avec un interpréteur Python2 et un autre de la nouvelle librairie avec un interpréteur Python3 :
$ python2 stresstest2.py
(...)
Ancienne librairie : 6.01993203163s
$ python3 stresstest3.py
Nouvelle librairie : 0.8533799648284912s
Pour afficher un tableau de 2000 lignes la nouvelle librairie va donc 7 fois plus vite que l'ancienne. De plus, l'affichage est meilleur. Malheureusement, je ne peux pas remplacer mon ancienne librairie par la nouvelle dans les projets les plus importants au boulot car ils tournent encore en Python2 et qu'ils doivent traiter des caractères non-ascii. Dommage...
Au moins j'ai la nouvelle librairie pour les projets Python3, j'ai découvert line_profile
, et si j'arrive à me motiver je pourrai toujours plonger dans le code de la vieille librairie pour lui adapter le nouvel algorithme de calcul de largeur des colonnes.
L'an dernier, j'avais relaté quelques unes de mes pérégrinations dans la réalisations de mes propres PCB et, depuis, je m'étais lancé dans la conception de ma propre planche de prototypage à base d'atmega(8), d'un cristal à environ 16MHz, sans doute d'un cristal de montre pour pouvoir utiliser le tout en RTC, et en gardant les ports I2C/SPI accessibles pour jouer avec des nRF24 facilement. Mais cette semaine, j'ai réalisé que ce n'était pas très malin...
Lorsque j'avais conçu un PCB pour contrôler des moteurs depuis un arduino et que je l'avais commandé (pour une bouchée de pain chez elecfreaks, je m'étais dit que c'était le début d'une longue liste de commande de PCB personnalisés. Sauf que le second dans lequel je me suis lancé (un PCB générique de prototypage) n'est toujours pas terminé. En raison de la vocation très générique de ce PCB, je n'arrive pas à me décider sur un design et je tourne en rond sans rien commander. Pourtant, j'ai trouvé un second fournisseur vraiment pas cher et je meurt d'envie de le tester. Finalement, j'ai donc décidé d'abandonner (pour l'instant) l'idée de concevoir mon propre PCB de prototypage et j'ai même décidé d'en faire un micro article afin de communiquer plus largement l'une des raisons de ce choix qui me semble méconnue : les clones d'arduino, ça ne coute rien.
L'été dernier, j'ai eu l'honneur de présenter une seconde conférence au SSTIC. Si vous regardez les slides (ou mieux : la vidéo) de cette conférence vous verrez que, lors d'un calcul de couts, je compte un arduino pour "environ 7€". Après cette conférences, plusieurs personnes sont venus me voir pour me dire que si ça coute vraiment si peu ils veulent bien m'en acheter une centaine pour les revendre. J'aurai du accepter :p ! En effet, les prix français/européens sont bien ancrés dans nos esprits et, pour la majorité des gens que je croise, un arduino coute environ 20~25€. Moi-même, jusqu'à il y a peu, j'avais inconsciemment l'idée qu'un arduino "ça ne coute pas cher" mais qu'un microcontrolleurs ça ne coute vraiment rien. En réalité, et c'est en partie ce qui a motivé ma décision d'abandonner mon projet de PCB de prototypage, un clone d'arduino ça ne coute quasiment rien. Pour être précis, ça coute 3,02€ chez aliexpress ou 4,70€ sur ebay,..tout ça livraison incluse.
Autant vous dire qu'à 3€ le clone d'arduino, même en déployant des trésors d'ingénierie, mon PCB conçu maison n'atteindra jamais un tel rapport qualité prix ! Bref : je me suis commandé une demi-douzaine de clones d'arduino (chez 3 vendeurs différents, pour limiter les risques de paquets s'évaporant en chemin) pour moins de 20€, et je peux à présent passer à la conception de PCB dédié à une tache spécifique :-D ! Les deux seuls inconvénients de cette solution découlent du format bien particulier de l'arduino : il fait un peu plus de 5cm de large (donc pour nos shields on rate, de quelques millimètres, le format le moins cher des graveurs de PCB : 5cmX5cm) et les deux rangées de connecteurs empilables ne sont pas alignées ce qui est un peu chiant pour l'alignement dans gschem. En tout cas, j'espère avoir motivé certains d'entre vous à ressortir leurs arduino du tiroir/placard où il prend la poussière, et je conclurai ce billet ainsi : Happy Hacking !
Fin 2012, je vous avait parlé de deux modules standards de la librairie python que j'aimais beaucoup : logging et argparse. Presque deux ans plus tard, il est temps de rendre hommage à deux modules n'appartenant pas à la librairie standard python mais que j'aime tout particulièrement : peewee et bottle.
Sqlite, MySQL, PostgreSQL ? Les trois !
Nombre des scripts python que j'écris ont besoin, lorsqu'ils atteignent une certaine importance, de stocker des informations de façon stable. Dans les cas les plus rustres je me contente d'un gros coup de pickle dans un fichier qui sera relu plus tard mais, parfois, le stockage et l'accès à mes données doit être plus performant et l'utilisation d'un outil dédié (à savoir : une base de donnée) semble trop évidente pour être ignorée. Avant que je ne découvre peewee, je réglais systématiquement ces besoins par la librairie sqlite3, qui a le bon gout d'être standard et, donc, de ne pas rajouter de dépendance à mes scripts (les laissant ainsi le plus portable possibles d'une machine à une autre). Malheureusement, cette approche avait deux défauts majeurs pour moi :
-
Si sqlite s'avérait, au bout de plusieurs semaines/mois d'utilisation, trop léger pour traiter mon volume de donnée je n'avais plus qu'à tout reprendre de zéro pour passer à une solution plus robuste
-
Je suis mauvais en SQL, donc taper les requêtes SQL moi-même était à la fois désagréable et peu efficace.
C'est pourquoi, lorsque j'ai découvert peewee, j'ai tout de suite adhéré au concept : c'est un orm (donc pas de SQL à écrire) capable d'utiliser un backend sqlite, mysql, ou postgresql sans avoir à changer le code :) ! En plus, je connais quelqu'un qui m'a dit récemment avoir testé peewee sur un backend sqlite avec une dizaine de thread concurrent sans le moindre problème. Étant assez fan du multithread et ayant déjà eu des problèmes avec sqlite ça serait pour moi la cerise sur le gateau si cette robustesse en multithread se confirmait :) ! En tout cas, j'ai déjà utilisé peewee sur une demi douzaine de projets (dont un qui commence à devenir assez imposant) et c'est toujours un plaisir de travailler avec cet ORM : je le conseille donc vivement.
Seul tout petit bémol, pour terminer le paragraphe : j'ai eu UN problème avec peewee. Je voulais utiliser un modèle ou une table A référence (foreign key) une table B; sachant que la table B doit, elle aussi, avoir une foreign key vers la table A. Oui, je sais, ça fait des références circulaires et ce n'est généralement pas le signe d'une bonne conception mais dans mon cas d'usage c'est ce que je voulais faire et il n'y avait pas vraiment d'autres alternative plus propre. Comme peewee crée les table en base au fur et à mesure qu'il lit la déclaration des objets, ce cas d'usage pose problème puisqu'au moment de créer la première table peewee n'a pas (encore) connaissance de la seconde et il échoue plante lors de la création de la foreign key vers une table qui n'existe pas encore. Si une seule des deux tables référençait l'autre, il me suffirait d'inverser l'ordre de déclaration des tables pour que peewee découvre la table cible en premier, mais comme j'ai une référence dans les deux sens cette astuce ne me sauvera pas. Tout n'est pas perdu pour autant : peewee prévoit ce cas de figure et propose d'utiliser un objet "proxy" qui sera la cible de la foreign key lors de la création des tables puis qui sera remplacé par les tables réellement ciblées une fois qu'elles auront toutes été créées. En pratique, ça marche probablement...SAUF quand le backend est sqlite.
En effet, pour réaliser ce tour de passe-passe, peewee crée les tables sans les foreign key lorsqu'il découvre la déclaration des objets, puis il utilise l'opération SQL "ALTER TABLE" pour ajouter les "CONSTRAINT" de foreign key au moment où l'objet proxy est remplacé par les bonnes tables une fois que ces dernières ont toutes été créées. Sauf que sqlite ne supporte pas cette fonctionnalité du SQL. Dans ce cas, il n'y a que deux solutions :
- abandonner sqlite
- créer (et gérer) soit même la relation de foreign key, en utilisant de simples champ "INTEGER" référençant un ID dans la table cible (c'est ce que j'ai choisi).
Nginx ? Apache ? IIS ? Aucun des trois !
Une autre fonction classique de mes script python lorsqu'il prennent de l'ampleur, c'est d'avoir une interface web (plus ou moins simpliste). Pour ce faire, j'ai longtemps surchargé BaseHTTPServer. J'ai bien tenté d'utiliser cherrypy ou django quand je prévoyais que mes scripts allaient demander un peu plus de complexité, mais j'ai vite abandonné. Bref : j'utilisais toujours BaseHTTPServer, et j'étais contraint de ne faire que de petits sites un peu minables...
Puis, un jour, j'ai découvert wsgi sur l'excellent site de bortzmeyer. En résumé : c'est une spécification, dans un esprit assez similaire à CGI, qui définit une interface python très simple pour fabriquer des sites web. Pour offrir une interface web à votre script python il vous suffit donc de créer une classe d'objet "application" compatible avec la spécification WSGI, et à donner cette classe en argument à un serveur web compatible (par exemple, le "simple_server" de la librairie standard wsgiref). L'énorme avantage, c'est que si vous avez besoin de performance plus tard il vous suffit de changer le serveur web sans changer une seule ligne de votre code python (un peu comme lorsque vous changer de backend peewee). Par exemple, vous pouvez remplacer le serveur inclus dans la librairie standard par werkzeug ou cherrypy et vous obtenez immédiatement le support du multithread (qui est quand même un must pour tout ce qui est web).
Le seul inconvénient de WSGI (qui m'est tombé dessus assez vite), c'est que ça reste quand même assez rustre. On est à un niveau d'abstraction supérieure à BaseHTTPServer, mais il manque quand même des raccourcis pour réaliser certaines tâches classiques (comme mettre des fichiers statiques à dispositions, types CSS ou JS). On se retrouve donc rapidement à devoir passer du temps de développement sur des fonctions "inutiles" vis-à-vis de l'objet du script mais nécessaires au fonctionnement de l'interface web (et servir des fichiers static peut prendre beaucoup de temps de développement, en particulier quand on est paranoïaque et qu'on veut faire attention aux LFI et autres farces). C'est pourquoi, je conseille bottle ! Bottle est une librairie python non-standard typiquement dans l'esprit de ce que j'aime : elle tient en un seul fichier python (et vous pouvez donc l'embarquer partout sans sacrifier la portabilité de votre script :)). C'est un wrapper mince autour de la spécification WSGI qui vous offre des facilité pour tout un tas de besoins classiques, par exemple :
De plus, comme bottle est un wrapper mince, on ne perd pas la puissance de WSGI puisqu'on peut passer d'un simple serveur embarqué écrit en python à des choses plus musclées comme gunicorn ou bjoern sans avoir à ré-écrire son code ! Cerise (inutile mais agréable) sur le gateau : bottle accepte d'utiliser un serveur "auto" signifiant qu'il va, tout seul, sélectionner le serveur compatible WSGI le plus performant actuellement installé sur votre machine (sympa quand vous avez cherrypy sur vos machines de prod mais pas sur les machines de dev par exemple).