Un compilateur optimisant est un compilateur conçu pour générer du code optimisé en minimisant notamment le temps d'exécution , l'utilisation de la mémoire , la taille du stockage et la consommation d'énergie . L'optimisation est généralement mise en œuvre sous la forme d'une séquence de transformations optimisantes , également appelées optimisations du compilateur
- Machine cible
- La possibilité et l’opportunité d’appliquer certaines optimisations peuvent dépendre des caractéristiques de la machine cible. Certains compilateurs, tels que GCC et Clang, paramétrent des facteurs dépendants de la machine afin de permettre l’optimisation pour différentes machines.
- Architecture du processeur cible
- Nombre de registres : Les registres permettent d’optimiser les performances. Les variables locales peuvent être stockées dans des registres plutôt que dans la pile . Les résultats temporaires ou intermédiaires peuvent être accessibles depuis les registres plutôt que depuis la mémoire, plus lente.
- RISC vs. CISC : Les jeux d’instructions CISC présentent souvent des longueurs d’instructions variables , un plus grand nombre d’instructions utilisables et un temps d’exécution variable pour chaque instruction. Les jeux d’instructions RISC s’efforcent de limiter cette variabilité : leur longueur est généralement constante, à quelques exceptions près ; le nombre de combinaisons d’opérations sur les registres et la mémoire est généralement plus faible ; et le taux d’émission des instructions (nombre d’instructions exécutées par période, généralement un multiple entier du cycle d’horloge) est généralement constant lorsque la latence mémoire est négligeable. Plusieurs méthodes permettent d’effectuer une tâche donnée, le CISC offrant généralement plus d’alternatives que le RISC. Les compilateurs doivent connaître le coût relatif des différentes instructions et choisir la séquence d’instructions optimale (voir la sélection des instructions ).
- Pipelines : Un pipeline est un processeur décomposé en une chaîne de montage . Il permet d'utiliser différentes parties du processeur pour diverses instructions en divisant leur exécution en plusieurs étapes : décodage de l'instruction, décodage de l'adresse, accès à la mémoire, accès aux registres, calcul, écriture dans les registres, etc. Une instruction peut se trouver à l'étape d'écriture dans les registres, tandis qu'une autre peut être à l'étape d'accès aux registres. Des conflits de pipeline surviennent lorsqu'une instruction à une étape du pipeline dépend du résultat d'une autre instruction qui la précède dans le pipeline, mais qui n'est pas encore terminée. Ces conflits peuvent entraîner des blocages de pipeline : le processeur gaspille alors des cycles en attendant la résolution d'un conflit. Les compilateurs peuvent ordonner , ou réorganiser, les instructions afin de réduire la fréquence des blocages de pipeline.
- Nombre d'unités fonctionnelles : Certains processeurs possèdent plusieurs unités arithmétiques et logiques (UAL) et unités de calcul en virgule flottante (UCF) leur permettant d'exécuter plusieurs instructions simultanément. Des restrictions peuvent s'appliquer quant aux instructions pouvant être appariées (l'« appariement » désigne l'exécution simultanée de deux instructions ou plus) et aux unités fonctionnelles pouvant exécuter chaque instruction. Ils peuvent également présenter des problèmes similaires aux conflits de pipeline. L'ordonnancement des instructions peut être optimisé afin de maximiser la charge des unités fonctionnelles.
- Architecture machine
- Taille et type du cache du processeur (à correspondance directe, associatif 2/4/8/16 voies, entièrement associatif) : des techniques telles que l’expansion en ligne et le déroulement de boucle peuvent augmenter la taille du code généré et réduire sa localité. Le programme peut ralentir considérablement si une section de code fréquemment utilisée (comme les boucles internes de divers algorithmes) ne tient plus dans le cache suite à des optimisations augmentant sa taille. De plus, les caches non entièrement associatifs présentent un risque accru de collisions, même lorsqu’ils ne sont pas remplis.
- Taux de transfert cache/mémoire : ils indiquent au compilateur la pénalité en cas de défaut de cache. Cette fonctionnalité est principalement utilisée dans des applications spécialisées.
- Usage prévu
- Débogage : Lors du développement, les optimisations sont souvent désactivées pour accélérer la compilation ou faciliter le débogage du code exécutable. Les transformations d’optimisation, notamment celles qui réorganisent le code, peuvent rendre difficile la mise en relation du code exécutable avec le code source.
- Utilisation générale : Les logiciels pré-packagés sont souvent conçus pour fonctionner sur diverses machines qui peuvent partager le même jeu d’instructions, mais présenter des performances différentes. Le code peut ne pas être optimisé pour une machine particulière ou être conçu pour fonctionner de manière optimale sur la machine la plus courante, au détriment des autres.
- Utilisation à des fins spécifiques : si le logiciel est compilé pour des machines aux caractéristiques uniformes, le compilateur peut alors optimiser fortement le code généré pour ces machines.
- Parmi les cas notables, citons le code conçu pour les processeurs parallèles et vectoriels , pour lequel des compilateurs de parallélisation spéciaux sont utilisés.
- Le firmware d'un système embarqué peut être optimisé pour le processeur et la mémoire cibles. Le coût ou la fiabilité du système peuvent primer sur la vitesse d'exécution. Par exemple, les compilateurs pour logiciels embarqués proposent généralement des options permettant de réduire la taille du code au détriment de la vitesse. La prévisibilité du temps d'exécution du code étant souvent plus importante que sa rapidité maximale, la mise en cache du code peut être désactivée, de même que les optimisations du compilateur qui en dépendent.
Thèmes communs
L'optimisation englobe les thèmes suivants, parfois contradictoires.
- Optimiser le cas commun
- Le cas courant peut présenter des propriétés uniques permettant un chemin rapide au détriment d'un chemin plus lent . Si le chemin rapide est emprunté plus souvent, il en résulte de meilleures performances globales.
- Évitez les redondances
- Réutilisez les résultats déjà calculés et stockez-les pour une utilisation ultérieure, au lieu de les recalculer.
- Moins de code
- Supprimez les calculs inutiles et les valeurs intermédiaires. Moins de travail pour le processeur, le cache et la mémoire se traduit généralement par une exécution plus rapide. Par ailleurs, dans les systèmes embarqués , un code plus concis permet de réduire le coût du produit.
- Moins de sauts grâce à l'utilisation de code linéaire , également appelé code sans branchement
- Un code moins complexe. Les sauts ( branchements conditionnels ou inconditionnels ) interfèrent avec la prélecture des instructions, ce qui ralentit l'exécution. L'utilisation de l'inlining ou du déroulement de boucle permet de réduire les branchements, au prix d'une augmentation de la taille du fichier binaire proportionnelle à la longueur du code répété. Cela tend à fusionner plusieurs blocs de base en un seul.
- Localité
- Le code et les données qui sont accédés à peu près simultanément dans le temps doivent être placés à proximité les uns des autres en mémoire afin d'accroître la localité spatiale de la référence .
- Exploiter la hiérarchie de la mémoire
- Les accès à la mémoire sont de plus en plus coûteux à chaque niveau de la hiérarchie de la mémoire ; il convient donc de placer d’abord les éléments les plus fréquemment utilisés dans les registres, puis dans les caches, puis dans la mémoire principale, avant de les écrire sur le disque.
- Paralléliser
- Réorganiser les opérations pour permettre l'exécution parallèle de plusieurs calculs, que ce soit au niveau des instructions, de la mémoire ou des threads.
- Des informations plus précises sont préférables
- Plus les informations dont dispose le compilateur sont précises, mieux il pourra employer l'une ou l'ensemble de ces techniques d'optimisation.
- Les métriques d'exécution peuvent être utiles
- Les informations recueillies lors d'un test peuvent être utilisées pour l'optimisation guidée par profil . Les informations recueillies à l'exécution, idéalement avec une surcharge minimale , peuvent être utilisées par un compilateur JIT pour améliorer dynamiquement l'optimisation.
- Réduction de la force
- Remplacez les opérations complexes, difficiles ou coûteuses par des opérations plus simples. Par exemple, remplacez la division par une constante par une multiplication par son inverse, ou utilisez l'analyse par induction pour remplacer la multiplication par un indice de boucle par une addition.
Techniques spécifiques
Optimisations de boucle
- fission de boucle ou distribution de boucle
- La fission de boucle consiste à diviser une boucle en plusieurs boucles couvrant la même plage d'indices, chaque nouvelle boucle n'utilisant qu'une partie du corps de la boucle originale. Ceci permet d'améliorer la localité des références aux données accédées dans la boucle et au code contenu dans son corps.
- fusion de boucles ou combinaison de boucles ou bourrage de boucles ou blocage de boucles
- Une autre technique vise à réduire la surcharge des boucles. Lorsque deux boucles adjacentes s'exécuteraient le même nombre de fois, que ce nombre soit connu ou non à la compilation, leurs corps peuvent être combinés à condition qu'elles ne fassent pas référence aux données de l'autre.
- Inversion de boucle
- Cette technique transforme une boucle `while` standard en une boucle `do/while` (aussi appelée `repeat/until` ) intégrée dans une condition `if` , réduisant ainsi de deux le nombre d'itérations lors de l'exécution de la boucle. Bien que cela duplique la vérification de la condition (augmentant la taille du code), cette méthode est plus efficace car les itérations provoquent généralement un blocage du pipeline . De plus, si la condition initiale est connue à la compilation et qu'elle est sans effet de bord , la condition `if` peut être omise.
- Échangeur de boucle
- Ces optimisations inversent les boucles internes et externes. Lorsque les variables de boucle indexent un tableau, une telle transformation peut améliorer la localité des références, en fonction de la structure du tableau.
- Mouvement de code invariant de boucle
- Si une quantité est calculée à l'intérieur d'une boucle à chaque itération, et que sa valeur est identique pour chaque itération, il est possible d'améliorer considérablement l'efficacité en la déplaçant hors de la boucle et en calculant sa valeur une seule fois avant le début de la boucle. Ceci est particulièrement important pour les expressions de calcul d'adresse générées par les boucles sur les tableaux. Pour une implémentation correcte, cette technique doit être utilisée conjointement avec l'inversion de boucle , car tout le code ne peut pas être déplacé hors de la boucle en toute sécurité.
- Optimisation des boucles imbriquées
- Certains algorithmes omniprésents, comme la multiplication matricielle, présentent un comportement de cache très médiocre et un nombre excessif d'accès mémoire. L'optimisation des boucles imbriquées augmente le nombre d'accès au cache en opérant sur de petits blocs et en utilisant une permutation de boucles.
- inversion de boucle
- L'inversion de boucle inverse l'ordre d'affectation des valeurs à la variable d'index. Cette optimisation subtile permet d'éliminer les dépendances et, par conséquent, d'en autoriser d'autres. De plus, sur certaines architectures, l'inversion de boucle contribue à réduire la taille du code. En effet, lors de la décrémentation de l'index, la condition de sortie de la boucle est une comparaison avec zéro. Contrairement à une comparaison avec un nombre, qui nécessite ce paramètre, cette condition est souvent implémentée par une instruction spéciale sans paramètre. L'inversion de boucle permet ainsi de gagner de la mémoire en évitant le stockage du paramètre. Enfin, si le nombre à comparer dépasse la taille d'un mot de la plateforme, l'exécution d'une boucle standard nécessiterait l'exécution de plusieurs instructions pour évaluer la comparaison, ce qui n'est pas le cas avec l'inversion de boucle.
- Déroulement de la boucle
- Le déroulement de boucle duplique le corps de la boucle plusieurs fois afin de réduire le nombre de tests de la condition et le nombre de sauts. Ces tests et sauts peuvent nuire aux performances en perturbant le pipeline d'instructions. Il s'agit d'une optimisation visant à réduire le nombre de sauts. Le déroulement complet d'une boucle élimine toute surcharge, mais nécessite de connaître le nombre d'itérations à la compilation.
- Division de boucle
- Le fractionnement de boucle vise à simplifier une boucle ou à éliminer les dépendances en la divisant en plusieurs boucles ayant le même corps, mais itérant sur différentes portions contiguës de l'intervalle d'indices. Un cas particulier utile est le pelage de boucle , qui permet de simplifier une boucle présentant une première itération problématique en effectuant cette itération séparément avant d'entrer dans la boucle.
- Décommutation de boucle
- La fonction Unswitching déplace une conditionnelle de l'intérieur d'une boucle vers l'extérieur de celle-ci en dupliquant le corps de la boucle à l'intérieur de chacune des clauses if et else de la conditionnelle.
- pipeline logiciel
- La boucle est restructurée de manière à ce que le travail effectué lors d'une itération soit divisé en plusieurs parties et réalisé sur plusieurs itérations. Dans une boucle serrée, cette technique masque la latence entre le chargement et l'utilisation des valeurs.
- Parallélisation automatique
- Une boucle est convertie en code multithread ou vectorisé (ou même les deux) pour utiliser plusieurs processeurs simultanément dans une machine multiprocesseur à mémoire partagée (SMP), y compris les machines multicœurs.
Optimisations de magasin prévoyantes
si ( cond ) { foo (); } si ( cond ) { bar (); }// devient : si ( cond ) { foo (); bar (); }