Optimisation de code
En programmation informatique, l'optimisation de code est la pratique consistant à améliorer l'efficacité du code informatique d'un programme ou d'une bibliothÚque logicielle. Ces améliorations permettent généralement au programme résultant de s'exécuter plus rapidement, de prendre moins de place en mémoire, de limiter sa consommation de ressources (par exemple les fichiers), ou de consommer moins d'énergie électrique.
Principes d'optimisation
La rÚgle numéro un de l'optimisation est qu'elle ne doit intervenir qu'une fois que le programme fonctionne et répond aux spécifications fonctionnelles. L'expérience montre qu'appliquer des optimisations de bas niveau du code avant que ces deux conditions ne soient réalisées revient le plus souvent à une perte de temps et s'avÚre néfaste à la clarté du code et au bon fonctionnement du programme :
« L'optimisation prématurée est la source de tous les maux. »
â Donald Knuth, citant Dijkstra
Cependant cette citation, tronquée, est trÚs souvent mal interprétée. La version complÚte étant :
« On devrait oublier les petites optimisations locales, disons, 97 % du temps : l'optimisation prématurée est la source de tous les maux[1]. »
â Donald Knuth
La citation originale indique que gĂ©nĂ©ralement durant l'Ă©criture d'un code, on peut laisser de cĂŽtĂ© les optimisations locales, de bas niveau (rĂ©Ă©criture en assembleur, dĂ©roulage de boucle, etc.). Il est possible d'interprĂ©ter cette citation en dĂ©duisant de celle-ci que les optimisations de haut niveau concernant le choix des algorithmes ou l'architecture d'un projet doivent venir avant celle de bas niveau. Ainsi, ce n'est que vers la fin de l'Ă©criture du programme, une fois que l'analyse montre qu'un dĂ©tail de bas niveau est critique qu'il peut Ă©ventuellement ĂȘtre nĂ©cessaire de le modifier :
« What Hoare and Knuth are really saying is that software engineers should worry about other issues (such as good algorithm design and good implementations of those algorithms) before they worry about micro-optimizations such as how many CPU cycles a particular statement consumes. »
â Randall Hyde, ACM Ubiquity Vol. 10, Issue 3
Au contraire plus le projet grandit et plus ces optimisations de haut niveau seront difficiles, coûteuses (en termes de temps, difficulté et budget) voire impossibles à effectuer.
La plupart des compilateurs récents pratiquent de façon automatique un certain nombre d'optimisations qu'il serait fastidieux d'effectuer manuellement et qui rendraient le code source moins lisible.
L'optimisation manuelle locale peut s'avĂ©rer nĂ©cessaire dans des cas trĂšs spĂ©cifiques, mais les mesures montrent que sur des machines RISC qui possĂšdent un nombre Ă©levĂ© de registres et oĂč l'efficacitĂ© demande le regroupement des instructions identiques pour bĂ©nĂ©ficier de l'effet pipeline, l'optimiseur d'un compilateur C fournit souvent un code plus efficace que celui qui serait Ă©crit en assembleur par un programmeur expĂ©rimentĂ© (ce qui n'Ă©tait jamais le cas sur les machines CISC). Et de surcroit ce code est bien plus facile Ă maintenir, car les instructions en C restent dans un ordre liĂ© Ă la seule intelligibilitĂ© du code et non aux spĂ©cificitĂ©s de la machine : dans les optimiseurs actuels, en effet, les ordres machines associĂ©s Ă une instruction ne se trouvent plus nĂ©cessairement en position contiguĂ«, pour des raisons d'efficacitĂ© d'exĂ©cution. Cela rend le code assembleur gĂ©nĂ©rĂ© particuliĂšrement indĂ©chiffrable.
Pratique de l'optimisation
Pour suivre lâefficacitĂ© dâune optimisation, le dĂ©veloppeur sâappuie sur des tests de performance, câest-Ă -dire sur des mesures objectives du temps de traitement et de la taille de la mĂ©moire allouĂ©e.
La rĂ©duction de la taille des donnĂ©es rĂ©sidentes en mĂ©moire est complexe puisque la libĂ©ration dâune zone de mĂ©moire permet rarement de rendre la mĂ©moire disponible pour le systĂšme dâexploitation[2].
Localisation du code Ă optimiser
Pour Ă©valuer le temps et la mĂ©moire nĂ©cessaire pour chaque partie du programme, les dĂ©veloppeurs rĂ©alisent le profilage du code. Il n'est pas rare quâune grande partie du temps soit consacrĂ© Ă l'exĂ©cution dâun petit morceau du code, ce morceau de code est appelĂ© « goulot dâĂ©tranglement ».
Le logiciel de profilage est chargĂ© de compter le nombre dâexĂ©cutions de chaque fonction et de cycles du microprocesseur correspondants au cours de l'exĂ©cution.
DiffĂ©rentes approches dâoptimisation
Plusieurs approches existent pour optimiser un code :
- au niveau algorithmique, en choisissant un algorithme de complexité mathématique inférieure et des structures de données adaptées ;
- au niveau du langage de développement, en ordonnant au mieux les instructions et en utilisant les bibliothÚques disponibles ;
- en utilisant localement un langage de bas niveau, qui peut ĂȘtre le langage C ou, pour les besoins les plus critiques, le langage assembleur.
Optimisation algorithmique
Lâoptimisation algorithmique consiste Ă appliquer au code des transformations mathĂ©matiques successives qui prĂ©servent la spĂ©cification du programme tout en rĂ©duisant la consommation des ressources.
Optimisations grĂące aux outils du langage
Lâutilisation de fonctions diffĂ©rentes voire de bibliothĂšques complĂštes diffĂ©rentes peut permettre une optimisation du programme.
Optimisation en changeant de langage utilisé
Dans la pratique, les applications comportant beaucoup d'entrĂ©es-sorties lentes peuvent ĂȘtre optimisĂ©es en Ă©tant rĂ©Ă©crites dans un langage comme Haskell ou Python.
Une application nĂ©cessitant beaucoup de calculs et dâaffectations en mĂ©moire peut ĂȘtre optimisĂ©e en Ă©tant rĂ©Ă©crite dans un langage tel que le C ou le C++.
Optimisation automatique
Les compilateurs sont souvent capables de faire des optimisations locales, auxquelles aucun développeur ne penserait en premiÚre approche.
Pour le langage C, cela peut considérer :
- les variables locales et les registres ;
- les fonctions non implémentées en assembleur en tant que fonction ;
- les
switch
, qui sont optimum.
Toutefois, on peut grandement aider le compilateur en déclarant les variables avec les mots-clefs const
et/ou restrict
quand c'est possible ; autrement, le compilateur ne peut savoir si une zone mémoire est accessible par d'autres références, et désactivera des optimisations (phénomÚne dit d'alias de mémoire).
Utilisation de variables locales pour éviter les alias de mémoire
Le code C++ suivant sera en général peu optimisé par le compilateur car il est souvent incapable de savoir si le code de la boucle modifie ou non le compteur d'itérations : un pointeur ou une référence pourrait le modifier.
void MyClass::DoSomething() const
{
for( int i=0; i<m_nbrElements; ++i )
{
void *ptr = GetSomePtr();
....
}
}
Dans cette version, on indique clairement qu'on utilise un nombre d'itérations fixé à l'avance et qui ne sera jamais modifié, autorisant le compilateur à effectuer des optimisations plus agressives :
void MyClass::DoSomething()
{
const int nbrElements = m_nbrElements;
for( int i=0; i<nbrElements; ++i )
{
....
}
}
Une spécificité du binaire : le décalage
Une des toutes premiÚres optimisations a été celle de la division et de la multiplication par une puissance de 2.
En effet, l'informatique actuelle repose sur le binaire, puisqu'elle utilise comme élément de base le transistor (et historiquement, auparavant le relais) qui n'autorise que deux valeurs différentes.
On a donc logiquement implémenté en langage machine les opérations de décalage à gauche et décalage à droite.
En effet, en binaire, le décalage d'un nombre d'un cran vers la gauche le multiplie par 2.
- Ainsi, 2 () décalé de 1 bit donne 4 ().
- 5 () décalé de 2 bits donne 20 () : .
Ceci marche aussi pour la division, en décalant les bits vers la droite.
- 100 () décalé de 3 bits vers la droite donne donc 12 () car nous travaillons sur des nombres entiers.
L'arithmĂ©tique entiĂšre d'un processeur est en fait l'arithmĂ©tique dans l'anneau des par exemple. Et donc, tous les nombres premiers avec ont un inverse, et il est possible d'effectuer une division par l'un de ces nombres en une seule instruction. Par exemple, dans l'anneau des entiers sur 32 bits, diviser par 3 revient Ă multiplier par 2863311531. Diviser par 14 revient Ă multiplier par 3067833783 puis diviser par 2. C'est donc possible avec deux instructions. Les compilateurs savent faire ces optimisations mais pour cela le diviseur doit ĂȘtre connu Ă la compilation.
La division dans le cas général est une instruction coûteuse en temps machine, et n'est d'ailleurs toujours pas disponible sur la grande majorité des processeurs de type RISC.
Le mot clef inline du C
Le mot clef inline attaché à une fonction indique au compilateur qu'il devrait essayer d'étendre cette fonction. Considérons par exemple le code C suivant :
inline int f(int a, int b) {
return a * b;
}
int g (int a) {
switch (a) {
case 10:
return f(a, a);
case 11:
case 12:
return f(a - 2, a);
case 1200:
return f(a - 2, a);
default:
return f(a, a);
}
}
Une compilation avec gcc -O4 -S donne, en assembleur i386 :
.file "opt.c"
.text
.p2align 4,,15
.globl g
.type g, @function
g:
pushl %ebp
movl %esp, %ebp
movl 8(%ebp), %edx
cmpl $12, %edx
jg .L14
leal -2(%edx), %eax
cmpl $11, %edx
jge .L15
movl $100, %eax
cmpl $10, %edx
.L17:
je .L2
movl %edx, %eax
.L15:
imull %edx, %eax
.L2:
popl %ebp
ret
.p2align 4,,7
.L14:
movl $1437600, %eax
cmpl $1200, %edx
jmp .L17
.size g, .-g
.section .note.GNU-stack,"",@progbits
.ident "GCC: (GNU) 3.3.2 (Mandrake Linux 10.0 3.3.2-6mdk)"
Ce qui pourrait se traduire, pour une compréhension plus aisée, par le code C suivant :
int g(int a) {
int eax, b;
if (a > 12) /* cas a == 1200 */
goto L14;
eax = a - 2;
if (a >= 11) /* cas a == 11 ou a == 12 */
goto L15;
eax=100; /* = 10 * 10 */
b=10;
L17:
if (a == b) /* cas a == 10 */
goto L2;
/* cas "default" */
eax=a;
L15:
eax=eax*a;
L2:
return eax;
L14:
eax = 1437600; /* = 1200*(1200-2) */
b = 1200;
goto L17;
}
On peut remarquer par exemple que la fonction 'f' n'a pas été générée, mais que son code a directement été incorporé dans la fonction 'g' (le mot clef 'inline' permet de forcer ce type d'optimisation en C)
Notes et références
- « We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. »
- (en) « Memory Optimization », sur redis.io (consulté le )
Voir aussi
- Optimisation de requĂȘte
- Ăvaluation paresseuse
- Recherche de sous-expressions communes
- Interprétation abstraite
- LLVM
- MĂ©moire cache
- MĂ©moĂŻsation
- Principe de localité
- Simulation de phénomÚnes
- Théorie des files d'attente
Liens externes
- The Fallacy of Premature Optimization by Randall Hyde expliquant la citation de Donald Knuth et sa mauvaise interprétation
- Premature Optimization by Charles Cook sur cette mĂȘme citation de Donald Knuth.