paint-brush
Optimisations du compilateur : améliorer les performances du code avec un minimum de modifications !by@durganshu
1,253
1,253

Optimisations du compilateur : améliorer les performances du code avec un minimum de modifications !

Durganshu Mishra13m2023/11/30
Read on Terminal Reader

Les développeurs cherchant à optimiser les performances de leur code C++ devraient découvrir les optimisations du compilateur : un ensemble d'indicateurs C++ très efficaces qui améliorent les performances du code sans trop d'effort. La seule condition est que vous sachiez ce que vous faites. Explorez les indicateurs adaptés aux compilateurs Intel C++ tels que -fno-alias, -xHost, -xCORE-AVX512, IPO, etc., avec une confrontation pratique sur un code C++ d'itération Jacobi.
featured image - Optimisations du compilateur : améliorer les performances du code avec un minimum de modifications !
Durganshu Mishra HackerNoon profile picture
0-item
1-item


Obtenir des performances optimales à partir de votre code C++ peut s'avérer intimidant, exigeant un profilage méticuleux, des ajustements complexes de l'accès à la mémoire et une optimisation du cache. Y a-t-il une astuce pour simplifier un peu cela ?? Heureusement, il existe un raccourci pour obtenir des gains de performances remarquables avec un minimum d'effort, à condition que vous disposiez des bonnes informations et que vous sachiez ce que vous faites. Entrez dans les optimisations du compilateur qui peuvent améliorer considérablement les performances de votre code.


Les compilateurs modernes constituent des alliés indispensables dans ce voyage vers des performances optimales, notamment en matière de parallélisation automatique. Ces outils sophistiqués possèdent les prouesses nécessaires pour examiner des modèles de code complexes, en particulier dans les boucles, et exécuter des optimisations de manière transparente.


Cet article vise à mettre en lumière la puissance des optimisations des compilateurs, en se concentrant sur les compilateurs Intel C++ , réputés pour leur popularité et leur utilisation généralisée.


Dans cette histoire, nous dévoilons les couches de magie du compilateur qui peuvent transformer votre code en un chef-d'œuvre hautes performances, nécessitant moins d'intervention manuelle que vous ne le pensez.


Points forts : Que sont les optimisations du compilateur ? | -On | Architecture ciblée | Optimisation interprocédurale | -fno-alias | Rapports d'optimisation du compilateur

Que sont les optimisations du compilateur ?

Les optimisations du compilateur englobent diverses techniques et transformations qu'un compilateur applique au code source lors de la compilation. Mais pourquoi? Pour améliorer les performances, l'efficacité et, dans certains cas, la taille du code machine résultant. Ces optimisations sont essentielles pour influencer divers aspects de l'exécution du code, notamment la vitesse, l'utilisation de la mémoire et la consommation d'énergie.


Tout compilateur exécute une série d'étapes pour convertir le code source de haut niveau en code machine de bas niveau. Celles-ci impliquent l'analyse lexicale, l'analyse syntaxique, l'analyse sémantique, la génération de code intermédiaire (ou IR), l'optimisation et la génération de code.


Pendant la phase d'optimisation, le compilateur recherche méticuleusement des moyens de transformer un programme, en visant une sortie sémantiquement équivalente qui utilise moins de ressources ou s'exécute plus rapidement. Les techniques utilisées dans ce processus englobent, sans toutefois s'y limiter , le repliement constant, l'optimisation des boucles, l'intégration de fonctions et l'élimination du code mort .


Je ne vais pas discuter de toutes les options disponibles, mais de la manière dont nous pouvons demander au compilateur d'effectuer une optimisation spécifique susceptible d'améliorer les performances du code. Alors la solution ???? Indicateurs du compilateur.

Les développeurs peuvent spécifier un ensemble d'indicateurs du compilateur pendant le processus de compilation, une pratique familière à ceux qui utilisent des options telles que « -g » ou « -pg » avec GCC pour les informations de débogage et de profilage. Au fur et à mesure, nous discuterons des indicateurs de compilateur similaires que nous pouvons utiliser lors de la compilation de notre application avec le compilateur Intel C++. Ceux-ci peuvent vous aider à améliorer l’efficacité et les performances de votre code.


Go Kick Off GIF Par CAF



Alors, avec quoi travaillons-nous ?

Je ne vais pas me plonger dans une théorie sèche ni vous inonder de documentation fastidieuse répertoriant chaque indicateur du compilateur. Essayons plutôt de comprendre pourquoi et comment ces drapeaux fonctionnent.


Comment fait-on pour y parvenir ???


Nous prendrons une fonction C++ non optimisée responsable du calcul d'une itération de Jacobi , et étape par étape, nous découvrirons l'impact de chaque indicateur du compilateur. Au cours de cette exploration, nous mesurerons l'accélération en comparant systématiquement chaque itération avec la version de base, en commençant par aucun indicateur d'optimisation (-O0).


Les accélérations (ou temps d'exécution) ont été mesurées sur une machine à processeur Intel® Xeon® Platinum 8174 . Ici, la méthode Jacobi résout une équation aux dérivées partielles 2D (équation de Poisson) pour modéliser la répartition de la chaleur sur une grille rectangulaire.


La méthode Jacobi


u(x,y,t) est la température au point (x,y) au temps t.


On résout l'état stable lorsque la distribution ne change plus :

Résoudre l'état stable


Un ensemble de conditions aux limites de Dirichlet ont été appliquées à la frontière.


Nous disposons essentiellement d'un codage C++ effectuant les itérations de Jacobi sur des grilles de tailles variables (que nous appelons résolutions). Fondamentalement, une taille de grille de 500 signifie résoudre une matrice de taille 500x500, et ainsi de suite.


La fonction pour effectuer une itération Jacobi est la suivante :


 /* * One Jacobi iteration step */ void jacobi(double *u, double *unew, unsigned sizex, unsigned sizey) { int i, j; for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { unew[i * sizex + j] = 0.25 * (u[i * sizex + (j - 1)] + // left u[i * sizex + (j + 1)] + // right u[(i - 1) * sizex + j] + // top u[(i + 1) * sizex + j]); // bottom } } for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { u[i * sizex + j] = unew[i * sizex + j]; } } }


Nous continuons à effectuer l'itération Jacobi jusqu'à ce que le résidu atteigne une valeur seuil (à l'intérieur d'une boucle). Le calcul des résidus et l'évaluation des seuils sont effectués en dehors de cette fonction et ne sont pas concernés ici. Alors parlons maintenant de l’éléphant dans la pièce !

Comment fonctionne le code de base ?

Sans optimisations (-O0), nous obtenons les résultats suivants :


Durée d'exécution en secondes et MFLOP/s pour le cas de base (« -O0 »)


Ici, nous mesurons les performances en termes de MFLOP/s. Ce sera la base de notre comparaison.


MFLOP/s signifie « Millions d'opérations à virgule flottante par seconde ». Il s'agit d'une unité de mesure utilisée pour quantifier les performances d'un ordinateur ou d'un processeur en termes d'opérations en virgule flottante. Les opérations à virgule flottante impliquent des calculs mathématiques avec des nombres décimaux ou réels représentés dans un format à virgule flottante.


MFLOP/s est souvent utilisé comme référence ou mesure de performance, en particulier dans les applications scientifiques et techniques où les calculs mathématiques complexes sont répandus. Plus la valeur MFLOP/s est élevée, plus le système ou le processeur effectue rapidement les opérations en virgule flottante.


Note 1 : Pour fournir un résultat stable, j'exécute l'exécutable 5 fois pour chaque résolution et prends la valeur moyenne des valeurs MFLOP/s.

Remarque 2 : Il est important de noter que l'optimisation par défaut sur le compilateur Intel C++ est -O2. Il est donc important de spécifier -O0 lors de la compilation du code source.


Allons de l'avant et voyons comment ces temps d'exécution varieront à mesure que nous essayons différents indicateurs du compilateur !

Les plus courants : -O1,-O2,-O3 et -Ofast

Ce sont quelques-uns des indicateurs du compilateur les plus couramment utilisés lorsque l’on commence par les optimisations du compilateur. Dans un cas idéal, les performances de Ofast > O3 > O2 > O1 > O0 . Cependant, cela n'arrive pas nécessairement. Les points critiques de ces options sont les suivants :


-O1 :

  • Objectif : optimiser la vitesse tout en évitant l’augmentation de la taille du code.
  • Caractéristiques principales : convient aux applications avec de grandes tailles de code, de nombreuses branches et où le temps d'exécution n'est pas dominé par le code dans les boucles.

-O2 :

  • Améliorations par rapport à -O1 :
    • Permet la vectorisation.
    • Permet l’intégration des intrinsèques et l’optimisation interprocédurale intra-fichier.

-O3 :

  • Améliorations par rapport à -O2 :
    • Permet des transformations de boucle plus agressives (Fusion, Block-Unroll-and-Jam).
    • Les optimisations ne peuvent surpasser systématiquement -O2 que si des transformations d'accès en boucle et en mémoire se produisent. Cela peut même ralentir le code.
  • Recommandé pour:
    • Applications avec des calculs à virgule flottante nécessitant beaucoup de boucles et de grands ensembles de données.

-Derapide :

  • Définit les indicateurs suivants :
    • "-O3"
    • « - no-prec-div » : Il permet des optimisations qui donnent des résultats rapides et légèrement moins précis que la division IEEE complète. Par exemple, A/B est calculé comme A * (1/B) pour améliorer la vitesse de calcul.
    • « -fp-model fast=2" : permet des optimisations en virgule flottante plus agressives.


Le guide officiel explique en détail les optimisations qu'offrent ces options.


En utilisant ces options sur notre code Jacobi, nous obtenons ces temps d'exécution d'exécution :

Comparaison des indicateurs -On

Il est bien évident que toutes ces optimisations sont bien plus rapides que notre code de base (avec « -O0 »). Le temps d’exécution est 2 à 3 fois inférieur au scénario de base. Qu'en est-il des MFLOP/s ??


Comparaison des indicateurs -On


Eh bien, c'est quelque chose !!!


Il existe une grande différence entre les MFLOP du cas de base et ceux de l'optimisation.


Dans l’ensemble, bien que légèrement, « -O3 » est le plus performant.


Les indicateurs supplémentaires utilisés par « - Ofast » (« -no-prec-div -fp-model fast=2 ») n'apportent aucune accélération supplémentaire.

Architecture ciblée (-xHost,-xCORE-AVX512)

L'architecture de la machine s'impose comme un facteur essentiel influençant les optimisations du compilateur. Cela peut améliorer considérablement les performances lorsque le compilateur connaît les jeux d'instructions disponibles et les optimisations prises en charge par le matériel (comme la vectorisation et SIMD).


Par exemple, ma machine Skylake dispose de 3 unités SIMD : 1 AVX 512 et 2 unités AVX-2.


Puis-je vraiment faire quelque chose avec ces connaissances ???


La réponse réside dans les indicateurs stratégiques du compilateur. Expérimenter des options telles que « -xHost » et, plus précisément, « -xCORE-AVX512 » peut nous permettre d'exploiter tout le potentiel des capacités de la machine et d'adapter les optimisations pour des performances optimales.


Voici une brève description de la signification de ces drapeaux :


-xHôte :

  • Objectif : Spécifie que le compilateur doit générer du code optimisé pour le jeu d'instructions le plus élevé de la machine hôte.
  • Principales fonctionnalités : profite des dernières fonctionnalités et capacités disponibles sur le matériel. Cela peut donner une accélération incroyable sur le système cible.
  • Considérations : Bien que cet indicateur soit optimisé pour l'architecture hôte, il peut en résulter des fichiers binaires qui ne sont pas portables sur différentes machines avec différentes architectures de jeu d'instructions.

-xCORE-AVX512 :

  • Objectif : demander explicitement au compilateur de générer du code qui utilise le jeu d'instructions Intel Advanced Vector Extensions 512 (AVX-512).

  • Caractéristiques principales : AVX-512 est un jeu d'instructions SIMD (Single Instruction, Multiple Data) avancé qui offre des registres vectoriels plus larges et des opérations supplémentaires par rapport aux versions précédentes comme AVX2. L'activation de cet indicateur permet au compilateur d'exploiter ces fonctionnalités avancées pour des performances optimisées.

  • Considérations : La portabilité est encore une fois le coupable ici. Les binaires générés avec les instructions AVX-512 peuvent ne pas fonctionner de manière optimale sur les processeurs qui ne prennent pas en charge ce jeu d'instructions. Ils ne fonctionneront peut-être pas du tout !


Les instructions de jeu AVX-512 utilisent des registres Zmm, qui sont un ensemble de registres de 512 bits de large. Ces registres servent de base au traitement vectoriel.


Par défaut, « -xCORE-AVX512 » suppose que le programme ne bénéficiera probablement pas de l'utilisation des registres zmm. Le compilateur évite d'utiliser les registres zmm sauf si un gain de performances est garanti.


Si l'on envisage d'utiliser les registres zmm sans restrictions, « -qopt-zmm-usage » peut être réglé sur élevé. C'est ce que nous ferons également.


N'oubliez pas de consulter le guide officiel pour des instructions détaillées.


Voyons comment ces indicateurs fonctionnent pour notre code :

Effets de -xHost et -xCORE-AVX512

Waouh !


On franchit désormais la barre des 1200 MFLOP/s pour la plus petite résolution. Les valeurs MFLOP/s pour d’autres résolutions ont également augmenté.


Ce qui est remarquable, c'est que nous avons obtenu ces résultats sans aucune intervention manuelle substantielle, simplement en incorporant une poignée d'indicateurs du compilateur pendant le processus de compilation de l'application.


Cependant, il est essentiel de souligner que l’exécutable compilé ne sera compatible qu’avec une machine utilisant le même jeu d’instructions.


Le compromis entre optimisation et portabilité est évident, car le code optimisé pour un jeu d'instructions particulier peut sacrifier la portabilité entre différentes configurations matérielles. Alors assurez-vous de savoir ce que vous faites !!


Remarque : Ne vous inquiétez pas si votre matériel ne prend pas en charge AVX-512. Le compilateur Intel C++ prend en charge les optimisations pour AVX, AVX-2 et même SSE. La documentation contient tout ce que vous devez savoir !

Optimisation interprocédurale (IPO)

L'optimisation interprocédurale implique l'analyse et la transformation du code dans plusieurs fonctions ou procédures, en regardant au-delà de la portée des fonctions individuelles.


L’IPO est un processus en plusieurs étapes axé sur les interactions entre différentes fonctions ou procédures au sein d’un programme. L'introduction en bourse peut inclure de nombreux types d'optimisations différents, notamment la substitution directe, la conversion d'appel indirect et l'inlining.


Intel Compiler prend en charge deux types courants d'IPO : la compilation d'un seul fichier et la compilation multi-fichiers (optimisation du programme entier) [ 3 ]. Il existe deux indicateurs courants du compilateur qui exécutent chacun d'eux :


-IPO :

  • Objectif : permet l'optimisation interprocédurale, permettant au compilateur d'analyser et d'optimiser l'ensemble du programme, au-delà des fichiers sources individuels, lors de la compilation.

  • Caractéristiques principales : - Optimisation de l'ensemble du programme : « -ipo » effectue une analyse et une optimisation sur tous les fichiers sources, en tenant compte des interactions entre les fonctions et les procédures tout au long du programme.- Optimisation inter-fonctions et inter-modules : l'indicateur facilite les fonctions en ligne et la synchronisation d'optimisations et d'analyse des flux de données dans différentes parties du programme.

  • Considérations : Cela nécessite une étape de liaison distincte. Après avoir compilé avec « -ipo », une étape de liaison particulière est nécessaire pour générer l'exécutable final. Le compilateur effectue des optimisations supplémentaires basées sur l'ensemble de la vue du programme lors de la liaison.


-ip :

  • Objectif : permet l'analyse-propagation interprocédurale, permettant au compilateur d'effectuer certaines optimisations interprocédurales sans nécessiter une étape de liaison distincte.

  • Caractéristiques principales : - Analyse et propagation : « -ip » permet au compilateur d'effectuer des recherches et de propager des données entre différentes fonctions et modules lors de la compilation. Cependant, il n'effectue pas toutes les optimisations qui nécessitent une vue complète du programme. - Compilation plus rapide : contrairement à « -ipo », « -ip » ne nécessite pas d'étape de liaison distincte, ce qui entraîne des temps de compilation plus rapides. Cela peut être bénéfique pendant le développement lorsqu’un retour d’information rapide est essentiel.

  • Considérations : Seules quelques optimisations interprocédurales limitées se produisent, y compris l'inlining de fonctions.


-ipo fournit généralement des capacités d'optimisation interprocédurales plus étendues car cela implique une étape de liaison distincte, mais cela se fait au prix de temps de compilation plus longs. [ 4 ]

-ip est une alternative plus rapide qui effectue certaines optimisations interprocédurales sans nécessiter une étape de liaison distincte, ce qui la rend adaptée aux phases de développement et de test.[ 5 ]


Puisque nous ne parlons que des performances et des différentes optimisations, les temps de compilation ou la taille de l'exécutable ne nous préoccupent pas, nous nous concentrerons sur « -ipo ».

Effet de -ipo

-fno-alias

Toutes les optimisations ci-dessus dépendent de votre connaissance de votre matériel et de vos expérimentations. Mais ce n'est pas tout. Si nous essayons d'identifier comment le compilateur verrait notre code, nous pouvons identifier d'autres optimisations potentielles.


Regardons à nouveau notre code :


 /* * One Jacobi iteration step */ void jacobi(double *u, double *unew, unsigned sizex, unsigned sizey) { int i, j; for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { unew[i * sizex + j] = 0.25 * (u[i * sizex + (j - 1)] + // left u[i * sizex + (j + 1)] + // right u[(i - 1) * sizex + j] + // top u[(i + 1) * sizex + j]); // bottom } } for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { u[i * sizex + j] = unew[i * sizex + j]; } } }


La fonction jacobi() prend quelques pointeurs pour servir de paramètres, puis fait quelque chose à l'intérieur des boucles for imbriquées. Lorsqu'un compilateur voit cette fonction dans le fichier source, il doit être très prudent.


Pourquoi??


L'expression pour calculer unew en utilisant u implique la moyenne de 4 valeurs u voisines. Et si vous et unew pointiez vers le même endroit ? Cela deviendrait le problème classique des pointeurs aliasés [ 7 ].


Les compilateurs modernes sont très intelligents et, pour garantir la sécurité, ils supposent que l'alias est possible. Et pour des scénarios comme celui-ci, ils évitent toute optimisation pouvant avoir un impact sur la sémantique et la sortie du code.


Dans notre cas, nous savons que u et unew sont des emplacements mémoire différents et sont destinés à stocker des valeurs différentes. Ainsi, nous pouvons facilement faire savoir au compilateur qu’il n’y aura pas d’alias ici.


Comment fait-on cela?


Il existe deux méthodes. Le premier est le mot - clé C « restrict » . Mais cela nécessite de changer le code. Nous ne voulons pas de cela pour l'instant.


Quelque chose de simple ? Essayons « -fno-alias ».


-fno-alias :

  • Objectif : demander au compilateur de ne pas supposer d'alias dans le programme.

  • Caractéristiques principales : en supposant qu'il n'y ait pas d'alias, le compilateur peut optimiser plus librement le code, améliorant potentiellement les performances.

  • Considérations : Le développeur doit être prudent lorsqu'il utilise cet indicateur, car en cas d'alias injustifié, le programme peut donner des résultats inattendus.


Plus de détails peuvent être trouvés dans la documentation officielle .


Comment cela fonctionne-t-il pour notre code ?

Effet de -fno-alias

Eh bien, maintenant nous avons quelque chose !!!


Nous avons obtenu ici une accélération remarquable, près de 3 fois par rapport aux optimisations précédentes. Quel est le secret de ce boost ?


En demandant au compilateur de ne pas assumer d'alias, nous lui avons donné la liberté de déclencher de puissantes optimisations de boucles.


Un examen plus approfondi du code assembleur (bien qu'il ne soit pas partagé ici) et du rapport d'optimisation de compilation généré (voir ci-dessous ) révèle l'application astucieuse du compilateur en matière d'échange et de déroulement de boucles . Ces transformations contribuent à des performances hautement optimisées, démontrant l'impact significatif des directives du compilateur sur l'efficacité du code.

Graphiques finaux

Voici comment toutes les optimisations fonctionnent les unes par rapport aux autres :


Comparaison de tous les indicateurs d'optimisation

Rapport d'optimisation du compilateur (-qopt-report)

Le compilateur Intel C++ fournit une fonctionnalité précieuse qui permet aux utilisateurs de générer un rapport d'optimisation résumant tous les ajustements effectués à des fins d'optimisation [ 8 ]. Ce rapport complet est enregistré au format de fichier YAML, présentant une liste détaillée des optimisations appliquées par le compilateur dans le code. Pour une description détaillée, consultez la documentation officielle sur « -qopt-report ».

Et ensuite ?

Nous avons discuté d'une poignée d'indicateurs du compilateur qui peuvent améliorer considérablement les performances de notre code sans que nous fassions grand-chose. Seul prérequis : ne rien faire aveuglément ; assurez-vous de savoir ce que vous faites !!


Il existe des centaines de ces indicateurs de compilateur, et cette histoire n'en parle que d'une poignée. Il vaut donc la peine de consulter le guide officiel du compilateur de votre compilateur préféré (en particulier la documentation relative à l'optimisation).


Outre ces indicateurs du compilateur, il existe tout un tas de techniques telles que la vectorisation, les intrinsèques SIMD, l'optimisation guidée par profil et le parallélisme automatique guidé , qui peuvent étonnamment améliorer les performances de votre code.


De même, les compilateurs Intel C++ (et tous les plus populaires) prennent également en charge les directives pragma, qui sont des fonctionnalités très intéressantes. Cela vaut la peine de vérifier certains pragmas comme ivdep, parallel, simd, vector, etc., sur la référence de pragma spécifique à Intel .


C'est tout, les amis sur Giphy

Lectures suggérées

[1] Optimisation et programmation (intel.com)

[2] Calcul haute performance avec « Elwetritsch » à l'Université de Kaiserslautern-Landau (rptu.de)

[3] Optimisation interprocédurale (intel.com)

[4] introduction en bourse, Qipo (intel.com)

[5] IP, Qip (intel.com)

[6] Compilateur Intel, optimisation et autres indicateurs à utiliser par SPEChpc

[7] Alias — Documentation IBM

[8] Rapports d'optimisation du compilateur Intel®


Photo en vedette par Igor Omilaev sur Unsplash .


Également publié ici .