13 janvier 2014
eldy

Optimiser les performances de votre Code Perl

INTRODUCTION

Si aujourd’hui le nombre et la diversité de ses modules a fait de Perl un langage tout usage (IHM, services, batch, Web), l’une de ces vocations première reste avant tout les traitements d’exploitation et le parsing d’information pour du traitement de masse.
Cet article a pour but de vous apprendre à optimiser vos programmes Perl lorsqu’ils sont dédiés à cette tâche.

Après plusieurs années de développements sur AWStats Log Analyzer, logiciel encore utilisé par plusieurs dizaines de millions d’utilisateurs dans le monde, je vous fait part de mon retour d’experience sur les nombreux trucs et astuces découverts, testés et mises en oeuvre afin de faire d’un logiciel Perl, un programme plus performants que ces équivalents en d’autres langages.

Inutile d’espérer réduire la charge machine prise par votre programme CGI de recherche en base de donnée MySQL via cet article, les performances dans un tel cas étant beaucoup plus liées à la requête SQL ou au moteur de base de donnée qui l’exécute, qu’au code du programme qui la soumet.
Pour de telles optimisations, je vous laisse vous reporter à d’autres articles sur l’optimisation du langage SQL ou des bases de donnée.
Si par contre, vous développez des programmes Perl réalisant de gros traitements de Parsing de données ou de fichiers (outils anti-spam, analyseurs de log, analyseurs de code, routine de calculs, etc...), cet article vous aidera à les rendre plus performant.

LES DIFFERENTES VOIES D’OPTIMISATION

L’article est organisé en 4 parties :
* Gagner du temps sur les Regular Expressions,
* Bonne utilisation des fonctions et modules de Perl,
* Utilisation judicieuse des Array et des Hash,
* Notions liées à la "compilation"
Les astuces et méthodes présentées dans ces rubriques sont applicables à tout Perl, 5.0 ou supérieur (sauf si le contraire est spécifié).

  • I) Cas de Regexp

I - 1) Utilisation des Regexp précompilés (opérateur "qr")

Une des force de Perl tient dans les Regular Expression, cette syntaxe étrange permettant de faire des tests ou des remplacement par rapport à des critères évolués.
La facilité à utiliser les Regexp en Perl nous amène à les utiliser fréquemment.
Imaginons que notre code doit tester régulièrement (dans une boucle par exemple) si une chaine $machaine commence par le texte "demo" et se termine par un chiffre, cela sans se soucier de la casse. On aura le code suivant :

> foreach my $i (1..1000)
>  ...
>  if ($machaine =~ /^demo.*\d$/i) { ... }
>  ...
> }

On ne reviendra pas ici sur la syntaxe des Regexp (Je vous invite à consulter la documentation en ligne https://perldoc.perl.org/perlre.html) mais imaginons le travail que doit réaliser notre interpréteur. Ici, Perl analyse la syntaxe de la Regexp pour la traduire puis effectue la recherche de ce critère dans $machaine.
Dans le cas où on effectue une deuxième fois ce même test, Perl va à nouveau effectuer cette analyse syntaxique et même autant de fois que de passage dans la boucle si le code est répété en boucle.
Afin d’éviter cette tache répétitive d’analyse de la Regexp, Perl fourni un opérateur qui permet de précompiler cette règle de recherche Regexp.
Pour réaliser plus efficacement la même chose que précédemment, on pourra utiliser le code suivant :

> $prex = qr/^demo.*\d$/i;
> foreach my $i (1..1000) {
>  ...
>  if ($machaine =~ /$prex/) { ... }
>  ...
> }

Ce code a le même effet que le précédent. Par contre, l’analyse syntaxique de la RegExp est faite une fois pour toute et sauvé dans $prex avant de rentrer dans la boucle (ligne 1).
Ainsi, au sein de la boucle, lorsque le test est réalisé (ligne 4), Perl se base sur une règle de recherche qui est déjà en partie décodée et traduite pour l’interpréteur.
Cela économise donc (au prix dérisoire d’1 variable suplémentaire), un travail d’analyse, et cela autant de fois que de passage dans la boucle.
Vous pouvez précompiler autant de Regexp que besoin ($prex1, $prex2, ...).

Inconvénient :
L’utilisation de l’opérateur qr n’est utile que si vous devez effectuer plusieurs fois des tests sur la meme règle (précompilé dans $prex). Même si vous effectuez des tests sur des opérandes différentes ($machaine, $machaine2, ...), dès lors que vous sollicitez plusieurs fois la même Regexp, le gain sera effectif, dans le cas d’une utilisation unique d’une Regexp, inutile de surcharger votre code.
De plus, l’opérateur "qr" ne fonctionne qu’à partir du Perl 5.005.

I -2) Avez-vous besoin des parenthèses ?
Imaginons que l’on ait besoin de rechercher plusieurs fois si une chaîne se termine par le verbe manger avec toutes ses déclinaisons conjuguées au futur (mangerais,mangeras,mangera,mangerons,mangerez,mangeront).
Classiquement, pour effectuer un "ou" en regex on utilisera le symbol | et on encadrera les différentes cas possibles de parenthèses pour déterminer le début et la fin du ou, selon l’exemple suivant :

> $prex=qr/manger(ais|as|a|ons|ez|ont)$/;
> foreach my $key (keys %listex) {
>   if ($listex{$key} =~ /$prex/) { print "La declinaison '$1' a été détectée !"; }
> }

Hors, les parenthèses dans une chaine regex ont une autre fonction. Elles demandent
à Perl de stocker la chaine qui répond au modèle dans la variable système $1,
et si il y a plusieurs lots de parenthèses, dans $2, $3, etc...
Ainsi dans notre print, si on trouve "mangerons" dans notre tableau listex, on affichera la chaine :
"La declinaison 'ons' a été détectée !"

Il existe cependant de nombreux cas pour lesquels on a pas besoin de stocker la
chaine trouvée dans le matching situé entre parenthése (on exploite pas $1).
Dans ce cas, il est inutile que Perl fasse des affectations implicites de valeurs dans ces variables système $1, $2... Pour cela, on change la forme de notre regexp de manière à mettre après la parenthèse ouvrante un point d’interrogation suivi des 2 points. Notre ligne 1 devient :

> $prex=qr/manger(?:ais|as|a|ons|ez|ont)$/;

Dans ce cas, Perl utilise bien les parenthèses comme critère de regroupement pour le "ou" mais ne les utilisent pas comme critère d’affectation des chaines trouvées dans $1, $2, ... Dans notre cas, si on utilise cette nouvelle ligne 1, la chaine affichée à l’écran sera :
"La declianaison '' a été détectée !"
Ici $1 n’a pas été défini. Cela implique donc qu’il ne faut utilisez le " ? :" au début de parenthèses regex que si on n’exploite pas derrière les variable $n. Ainsi on économise des affectations mémoire, le gain en vitesse étant alors d’autant plus grand que le nombre de matchings trouvés est important.

I - 3) Mutualisation des regex
Lorsque de nombreux regex doivent etre testés sur la même chaine et que le but est
juste de savoir si ils match ou non, il est possible d’optimiser la série de test grâce au module "Regexp ::Optimizer".
Prenons l’exemple d’un antispam dont le but et d’analyser l’email émetteur afin de refuser le mail si le domain correspond à une liste noire de domaines.
Pour une liste simple de 3 éléments, nous ferons le test suivant :
> if ($domain =~ /foobar|fooxar|foozar/)
On constate ici que les 3 éléments à tester ont un tronc commun. Le test peut donc
s’optimiser par le suivant qui sera plus rapide car évite de tester 3 fois la chaine "foo" :
> if ($domain =~ /foo[bx]ar|zar/)
Ici l’optimisation est assez simple à deviner. Mais imaginez une liste de 100 regexp !
C’est la qu’intervient le module Regexp ::Optimizer. Son utilisation est simple :

> use Regexp::Optimizer;
> my $o  = Regexp::Optimizer->new;
> my $re = $o->optimize(qr/foobar|fooxar|foozap/);

On crée un objet Regexp ::Optimizer (ligne 1 et 2) et on lui soumet la chaine à optimiser (ligne 3), on obtient une variable ($re) qui contient le regexp optimisé.
Il reste alors juste à tester notre chaine sur cette variable :
> if ($domain =~ /$re/)
Bien entendu, le jeu en vaut surtout la chandelle quand il faut executer plusieurs fois le même test, le surcout de l’optimisation n’étant réalisé qu’une fois.

  • II) Utilisation des fonctions et modules de Perl

II - 1) Fonctions prédéfinies
A force de lire et relire du code Perl, il n’est pas rare de voir l’utilisation des Regexp pour certaines opérations qui n’en nécessitent pas.
Ainsi, une méthode populaire pour convertir en majuscule une chaine est la suivante :
> $mystring =~ tr/a-z/A-Z/;
Il existe pourtant une fonction Perl prédéfinie capable de faire le travail bien plus efficacement :
> $mystring = uc($mystring);
Jetez un oeil sur le manuel de référence des fonctions Perl standards (https://perldoc.perl.org/index-functions.html), ne serait-ce que pour connaitre leur existence. Leur utilisation, quant elles sont adaptées, ne peut que rendre votre code plus performant (et lisible), elles vous simplifieront la vie.
Un exemple des fonctions souvent oubliées :
uc et ucfirst, lc et lcfirst, pack, map, join, ...

II - 2) Opérateurs spécialisés (+=,-=,*=,/=,<<,...)
Dans le même ordre d’esprit, on utilisera dans la mesure du possible, les opérateurs
spécialisés que fournie Perl. Bien qu’ils ne soient par propre au Perl (On les trouve
dans d’autres langages, leur utilisation dans des traitements mémoire pure s’avère
efficace).
Ainsi, on utilisera
> $myvar+=$valeur
plutôt que
> $myvar=$myvar+$valeur
L’avantage du += est qu’il génère moins d’accès/écriture en mémoire, le résultat étant directement écrit dans $myvar plutot que de passer par une mémoire tampon pour stocker le résultat afin de l’affecter ensuite dans $myvar.
Cela est également vrai pour -=, *= et /=.

On pourra, dans le cas de multiplication ou division entière par un multiple de 2, chose qui arrive fréquemment en informatique, effectuer l’opération efficacement grâce aux opérateurs de décalage de bit << et >> (que l’on retrouve aussi en C).
En effet, multiplier un entier par 2 puissance n revient à décaler les bits de cet entier de n cran vers la gauche.

Ainsi on utilisera :
> $myvar=$valeur<<3;
plutot que
> $myvar=$valeur*8;
ou pour la division, on utilisera
> $myvar=$valeur>>4;
plutot que
> $myvar=int($valeur/16);

II - 3) Modules
De même, il existe souvent des modules qui peuvent exécuter/remplacer efficacement une routine que vous avez écrite. Les modules profitant des améliorations de la ommunauté Perl, ils sont en général déjà optimisé. Certains sont même écrit en C, offrant ainsi la facilité de développement du langage interprété Perl et la rapidité du langage compilé C. Prenez le temps de faire une recherche sur CPAN (https://www.cpan.org) qui recense les modules disponibles avant d’entamer le développement d’une routine. Vous gagnerez souvent en performance (sans compter le temps de développement).

  • III) Utilisation judicieuse des Array et des Hash

III - 1) Array contre Hash
Lorsque vous avez besoin d’un tableau clé-valeur, le Perl offre les tableaux hash, assez performant, et surtout d’emploi très pratique car la clé peut être n’importe quelle valeur (y compris une chaine de texte).
On affecte un couple clé-valeur et on récupère sa valeur facilement :

> my %myhash=();                         # Déclaration du tableau hash
> $myhash{$cle}=$valeur;                         # Affectation d'un couple clé-valeur dans un tableau hash
> $valeur=$myhash{$cle};                         # Lecture d'une valeur par la clé

Si la récupération à partir d’une valeur par ce code standard est rapide, y compris pour
de gros tableau (car les clés y sont indexés), l’utilisation de tableau array sera cependant encore plus performant. Cela ne peut cependant s’appliquer que si les "clés" sont des valeures entières, de par la nature même du type array. Si tel est votre cas, vous gagnerez en temps d’accès à remplacer les lignes de code vues précédemment par les suivantes :

> my %myarray=();                         # Déclaration du tableau hash
> $myarray[$cle]=$valeur;                         # Affectation d'un couple clé-valeur dans un tableau array si cle est entier
> $valeur=$myarray[$cle];                         # Affectation d'un couple clé-valeur dans un tableau array si cle est entier

Perl va dans ce cas accéder aux valeurs par des accès mémoire direct sans utilisation
d’index de clé qui nécessite un calcul hash de la clé.

Inconvénient :
Il y a, dans certains cas, un danger cependant à utiliser les tableaux array plutot que
hash quand la clé est un entier. En effet, imaginez que, dans votre programme, les valeurs possibles pour la clé soient comprises entre 100 000 et 100 999.
En utilisant un tableau hash, Perl va s’allouer de quoi stocker 1 000 couples clé-valeurs dans le tableau hash. En utilisant les array, Perl alloue de la mémoire pour stocker des valeurs jusqu’à l’indice 100 999, mais en partant de 0 ! C’est-à-dire que votre tableau ira de 0 à 100 999. Même si les 100 000 premiers éléments ne sont jamais utilisés ou définis, la surconsommation de mémoire, peut difficilement être acceptée, d’autant qu’elle fera peut-être swapper votre disque et le gain espéré se transformera alors en gros ralentissement.
Vous l’aurez donc compris, dans ce cas, mieux vaudra utiliser les hash. A moins de décider, d’appliquer un retrait de 100 000 sur la clé au moment de l’insertion et lecture. Cela implique que l’offset de décalage d’indice soit fixe et connu. Un ajout/soustraction à un indice numérique sera plus rapide que l’utilisation du hash qui nécessite aussi un traitement (mais plus lourd) sur l’indice.

III - 2) Préallocation des tableaux
Soit le code suivant

@m=();
foreach my $i (1..10000) {
   $m[$i]=f($i);
}

On peut le rendre plus rapide en pré-dimensionnant le tableau à sa taille finale. Cela évite les extensions dynamique du tableau au cours de son remplissage par la boucle.

@m=();
$#m=10000;
foreach my $i (1..10000) {
   $m[$i]=f($i);
}

III - 3) Eviter les répétitions de lecture des valeurs d’un tableau hash
Prenons la portion de code suivante :

> $var1=$myhash{$cle};
> $var2=$myhash{$cle};

Dans un tel cas de lecture répétée d’une valeur d’un tableau hash pour une même clé donnée, il faut mieux "alourdir" le code d’une instruction supplémentaire pour stocker la valeur dans une variable et utiliser ensuite cette variable de manière répétée plutot que de répéter la lecture du hash.
Ansi, le code précédent peut être avantageusement remplacé par :

> my $val=$myhash{$cle};
> $var1=$val;
> $var2=$val;

III - 4) Utilisation de "exists"
Il vaut mieux utiliser la directive "exists" au lieu du test sur la valeur d’un hash pour savoir si une clé existe ou pas dans un tableau hash.

  • IV) Notions liées à la compilation

IV - 1) Fonctions inline ?
En Perl, comme dans tout autre langage, lorsqu’on appelle une fonction, votre système
place les valeurs des paramètres dans une pile mémoire (ainsi que la position du pointeur de code afin de savoir à quel endroit du code revenir après execution de la fonction).
Le système peut alors sauter au code de la fonction. L’appel des paramètres dans
cette dernière se fera par dépilement de la pile. Cette opération peut avoir un coup
non négligeable si la fonction est rapide et appellée souvent.
Les langages compilés ont en général des options pour utiliser ce qu’on appelle des
fonctions "inline". Ces fonctions ne sont en fait des fonctions qu’au sein du code manipulé par le programmeur. Le code qu’elles contiennent est substitué, à l’endroit de l’appel de la fonction dans le source, au moment de la compilation et cela à chaque endroit d’appel de la fonction. Cela accroit donc la taille du programme compilé (+ de code machine répété) mais évite le transfert et récupération de paramètres lors de l’exécution.
Ce type de fonction est intéressant pour les fonctions rapides comprenant peu de code
(comme une fonction qui calcul le Max de deux paramètres). Le passage de paramètres dans un tel exemple prend autant de temps que l’exécution de la fonction elle même.
Malheureusement, le Perl, étant interprété, ne dispose pas d’un tel mécanisme. Il faudra soi-même dupliquer le code, en se passant de la fonction, au risque de grossir et réduire la lisibilité de son code.

IV - 2) Recompilation du Perl avec différentes options
Perl est programme interpréteur de fichiers .pl transformant les instructions
textuels de ces derniers en code machine. Il est lui même écrit en C. Le comportement de son exécution dépend donc des options de compilation propre au compilateur C mais aussi propre aux variantes offertes par le code Perl lui-même.
Ainsi, l’utilisation d’un analyseur de log comme AWStats (qui manipule de nombreux entiers longs) a vu un gain de 22% sur le même machine lorsque Perl a été compilé avec les options adaptées. Leur liste dépend toutefois de votre Os, perl, version et le gain si il est positif sur votre programme peut être négatif pour un autre.

IV - 3) Convertir son script Perl en exécutable ?
Il existe des produits commerciaux (perl2exe) ou libre, comme perlcc fourni en standard avec Perl, qui permettent de générer un executable compilé à partir d’un
source Perl. Cependant, contrairement aux idées préconçues, l’intérêt de cette opération réside dans le fait de créer un programme indépendant de tout autre (pour une diffusion sur une machine sans Perl) ou de pouvoir diffuser un programme sans en diffuser les sources (ce qui aujourd’hui est plutôt un gage de non sérieux). Cette opération n’améliore en rien les performances dans la mesure ou l’exécutable généré ne fait que reproduire les instructions du Perl qui l’exécuterait, interprétation comprise (une sorte d’exécutable muni d’un Perl embarqué).
Cette transformation n’apportera donc rien aux objectifs de cet article qui sont d’accélérer votre script, mais l’erreur de croire que l’utilisation de tels outils améliore la vitesse sous prétexte d’avoir un code binaire est si fréquente qu’il me semblait important à évoquer.

CONCLUSION

Les méthodes présentées ici ne sont qu’une liste, non exhaustive, des techniques dont le rendement en terme de performance ont un effet significatif dans le cadre de mes activités de développeurs Open-Source Perl.
Certaines sacrifient la lisibilité du code au nom de la performance, à vous de trouver
le juste milieu.
Pour vous aider, vous pourrez avoir recourt à des profiler, outils permettant d’analyser
les fonctions et portions de code les plus lentes ou les plus appelées.

AUTEUR

Laurent Destailleur,
Développeur Perl depuis plus de 20 ans, auteur de plusieurs logiciels Open-Sources en Perl et PHP, dont AWStats Log Analyzer, Dolibarr CRM et ERP ou encore Sell-Your-Saas
.

ANNEXES :

D’autres pistes peuvent être étudier. Toutefois, je n’ai jamais pris la peine de vérifier si elles apportaient un changement sur le plan de la performance. En voici toutefois la liste (si vous faites le test, envoyez moi l’information):

- Utilisation de l’opérateur $_
foreach my $key (@big_array) $big_hash$key=1 ;
remplacé par
foreach (@big_array) $big_hash$_=1 ;
Inconvénient :
Il faut être vigilent au code car la variable $_, globale au script, peut etre écrasée
au sein même de la boucle par une autre opération, ou une sous boucle qui positionnerait aussi cet opérateur.

- Utilisation de mot clé ou opérateur spécifiques
D’autres pistes encore peuvent être étudiées mais n’ont pas été évaluées car nécessite un contexte très particulier pour être légitime. Parmi ces pistes, citons l’utilisation de "study" ou de l’opérateur "o"

- Utilisation de Memoize
Si vous pouvez ajouter des modules Perl et que vous appelez souvent la même fonction, le module Memoize peut vous être utile : https://howtodoinperl.blogspot.fr/2014/01/perl-memoize-make-functions-faster.html