Ce que fait PGO est assez simple : il exécute votre code avec des compteurs de performance (logiciels). Les appels aux fonctions seront comptés, et ce faisant, le programme produira un « résumé » de la façon dont votre code s’est exécuté.
Vous ne devriez pas pousser ces builds en production.
Votre compilateur (j’utilise clang) utilisera ces données pour inliner des fonctions, guider la disposition des branchements, et séparer vos chemins chauds/froids.
Le Link Time Optimization de LLVM est une optimisation intégrée à LLVM, et donc au compilateur Clang.
Le LLVM Link Time Optimizer offre une transparence complète pendant l’optimisation intermodulaire, au sein de la chaîne d’outils du compilateur. Son objectif principal est de permettre au développeur de tirer parti des optimisations intermodulaires sans avoir à apporter de changements significatifs à ses makefiles ou à son système de build. Ceci est réalisé grâce à une intégration étroite avec le linker. Dans ce modèle, le linker traite les fichiers bitcode LLVM comme des fichiers objets natifs et permet de les combiner entre eux. Le linker utilise libLTO, un objet partagé, pour gérer les fichiers bitcode LLVM. Cette intégration étroite entre le linker et l’optimiseur LLVM permet de réaliser des optimisations impossibles dans d’autres modèles. L’entrée du linker permet à l’optimiseur d’éviter de s’appuyer sur une analyse d’échappement conservative.
Chaque symbole (disons une fonction) est défini dans un module : par exemple, math.c, gui.c, ou main.c.
Il va analyser chaque module et effectuer un certain nombre d’optimisations, par exemple éliminer les symboles inutilisés.
C’est pourquoi c’est une bête puissante quand c’est utilisé avec PGO : si le compilateur sait qu’une branche n’est jamais prise, il peut éliminer les symboles et blocs correspondants, dégageant le chemin chaud.
En regardant mon code, il y a quelque chose à remarquer.
C’est une simulation. Chaque calcul effectué le sera dans une boucle. On ne peut pas optimiser ses branchements s’il n’y a pas de branchements pour commencer.
En cherchant à optimiser leurs CPU, Intel, AMD, et d’autres fabricants ont compris que réduire la taille du silicium, ajouter des instructions, et les optimiser ne suffisait pas.
Ils devaient réduire le nombre de chargements. Les instructions du CPU (vos briques de base en assembleur) sont chargées par le CPU et stockées dans le cache. Comme les caches ne sont pas si grands, les CPU vont essayer de ne sélectionner que ce dont ils ont besoin. C’est là qu’intervient le prédicteur de branchement.
Grâce à une analyse spéculative, le CPU va déterminer quelles branches sont les plus utilisées (vos chemins chauds) et les mettre en cache, en les gardant prêtes pour la prochaine itération. Ça fonctionne incroyablement bien dans les boucles.
Au final, PGO pré-optimise quelque chose que le CPU fait déjà (build PGO ou non !) plutôt bien. C’est pourquoi on n’extrait pas autant de performance qu’on l’aurait souhaité.
Il y a aussi le cas de l’inlining (les corps de fonctions sont intégrés dans leurs appelants, supprimant le besoin d’un appel coûteux).
Enfin, PGO va aussi aider le compilateur avec le hot/cold splitting. Je vous suggère de jeter un œil à cette présentation de Ruijie Fang, de Princeton.
TL;DR : certains blocs d’instructions ne sont jamais utilisés, d’autres le sont fréquemment. En séparant les blocs les plus utilisés des moins utilisés, votre cache d’instructions sera principalement rempli d’instructions (blocs) provenant des chemins chauds.
Simplement : du code avec beaucoup de branchements.
Pensez à une machine virtuelle. Le CPU ne peut pas vraiment analyser les programmes à cette échelle. Mais en l’exécutant vous-même, vous pouvez prédire les instructions les plus utilisées, et quand cette instruction atterrit dans un if, la branche « pop » (probablement, j’invente ça) sera celle que le flux de contrôle emprunte le plus souvent.
Il existe d’autres fonctionnalités du compilateur qui pourraient être utilisées pour améliorer les performances. Une véritable méthodologie de benchmark/profiling pourrait aussi être bénéfique. De plus, je n’ai pas exploré cette partie, mais vous pouvez informer le compilateur des branches les plus susceptibles d’être utilisées avec __builtin_expect.
Mais je veux plonger dans le code et l’optimiser moi-même : SIMD, Multithreading, SoA, AoS, et plus encore.
Un guide pas à pas testé pour activer le Profile Guided Optimization et le Link Time Optimization dans un projet Meson compilé avec Clang, y compris l'étape de fusion llvm-profdata que la plupart des guides oublient.
Avant de toucher la moindre ligne de ma simulation de fluide, j'ai laissé le compilateur faire le travail. Changer simplement les flags m'a fait passer de 1x à environ 40x.
Multiboot2 vous dépose en mode protégé 32 bits ; Limine vous fournit un environnement 64 bits. Un unique trampoline d'entrée qui gère les deux sans forker le noyau.