🔝 Retour au Sommaire
Chapitre 41 : Optimisation CPU et Mémoire · Section 4
Niveau : Expert · Prérequis : Sections 41.1–41.3 (cache, branchements, SIMD), Chapitre 26 (CMake), Chapitre 31 (profiling)
Les sections précédentes ont présenté des optimisations que le développeur effectue manuellement : réorganiser les données (SoA), éliminer les branchements (branchless), aider la vectorisation (pragmas). Toutes ces techniques exigent d'identifier les hotspots, de comprendre le problème, et de modifier le code source.
Le Profile-Guided Optimization (PGO) prend l'approche inverse : laisser le compilateur observer l'exécution réelle du programme, puis recompiler en exploitant ces observations. Le compilateur ne devine plus quelles branches sont probables ni quelles fonctions sont chaudes — il le sait, grâce à des données de profiling collectées lors d'une exécution représentative.
Le résultat est un binaire dont le layout du code, les décisions d'inlining, les prédictions de branchement et les paramètres de vectorisation sont calibrés sur le comportement réel du programme. Les gains typiques sont de 10 à 25 % sur l'ensemble du programme, sans modifier une seule ligne de code source. Sur certains workloads (compilateurs, navigateurs, bases de données), les gains atteignent 30–40 %.
Le PGO se déroule en trois étapes distinctes :
Phase 1 : INSTRUMENTATION Phase 2 : PROFILING Phase 3 : OPTIMISATION
┌───────────────────────┐ ┌───────────────────────┐ ┌────────────────────────┐
│ │ │ │ │ │
│ Code source (.cpp) │ │ Binaire instrumenté │ │ Code source (.cpp) │
│ + │ │ + │ │ + │
│ Compilation avec │──────►│ Exécution sur un │──────►│ Recompilation avec │
│ -fprofile-generate │ │ workload réaliste │ │ -fprofile-use │
│ │ │ │ │ + │
│ → Binaire instrumenté│ │ → Fichiers .gcda / │ │ Données de profiling │
│ (plus lent ~15-30%)│ │ .profdata │ │ │
└───────────────────────┘ └───────────────────────┘ │ → Binaire OPTIMISÉ │
│ (10-25% plus rapide)│
└────────────────────────┘
Phase 1 — Instrumentation. Le compilateur insère des compteurs dans le binaire : à chaque branchement, à chaque appel de fonction, à chaque entrée de boucle. Le binaire résultant est fonctionnellement identique mais ~15–30 % plus lent à cause de l'overhead des compteurs.
Phase 2 — Profiling. Le binaire instrumenté est exécuté sur un workload représentatif du cas d'usage réel. Les compteurs enregistrent les fréquences de passage de chaque branchement, le nombre d'itérations de chaque boucle, et le nombre d'appels de chaque fonction. Ces données sont écrites dans des fichiers de profiling.
Phase 3 — Optimisation guidée. Le compilateur recompile le code source en lisant les fichiers de profiling. Il utilise ces données pour prendre des décisions d'optimisation informées au lieu de se fier à des heuristiques statiques.
Le compilateur utilise les données de profiling pour ajuster plusieurs aspects du code généré :
Les fonctions et blocs de base fréquemment exécutés (hot) sont regroupés dans des sections contiguës du binaire. Les chemins rarement pris (cold) — gestion d'erreurs, cas limites — sont déplacés dans des sections séparées. Cela améliore la localité du cache d'instructions (L1i) en concentrant le code chaud dans un espace mémoire réduit.
SANS PGO : AVEC PGO :
┌──────────────────┐ ┌──────────────────┐
│ func_a (hot) │ │ .text.hot │
│ func_b (cold) │ │ func_a │
│ func_c (hot) │ │ func_c │
│ func_d (cold) │ │ func_e │
│ func_e (hot) │ │ .text.unlikely │
│ error_handler │ │ func_b │
└──────────────────┘ │ func_d │
│ error_handler │
Code hot et cold └──────────────────┘
entrelacés Code hot contigu →
→ cache L1i pollué meilleure localité L1i
Sans PGO, le compilateur utilise des heuristiques basées sur la taille de la fonction pour décider de l'inlining. Avec PGO, il inline agressivement les petites fonctions appelées des millions de fois dans le hot path, et refuse d'inliner les fonctions rarement appelées même si elles sont petites — ce qui évite de gonfler le binaire inutilement.
Le compilateur annote chaque branchement avec sa probabilité réelle. Au lieu de supposer qu'un if est 50/50, il sait que le then est pris 99,7 % du temps. Cela influence le layout du code machine : la branche probable est placée en fall-through (pas de saut), et la branche rare est déplacée loin du hot path.
C'est l'équivalent automatique de [[likely]] / [[unlikely]] (section 41.2), mais appliqué partout dans le programme avec des probabilités exactes, pas des annotations manuelles.
Le PGO fournit au compilateur le nombre moyen d'itérations de chaque boucle. Une boucle qui itère en moyenne 3 fois ne bénéficie pas d'un déroulement ×8 — le compilateur ajuste le facteur de déroulement et le choix de vectorisation en conséquence. Inversement, une boucle qui itère 10 millions de fois justifie un déroulement et une vectorisation agressifs.
Pour les appels via pointeurs de fonction ou les méthodes virtuelles, le PGO identifie les cibles les plus fréquentes et peut les spécialiser (devirtualization) : le compilateur insère un test rapide « si la cible est Foo::process() (90 % des cas), appeler directement ; sinon, appel indirect ».
// Code source : appel virtuel
base_ptr->process(data);
// Code généré avec PGO (pseudo) :
if (base_ptr->vtable == &Foo::vtable) { // vérifié en 90% des cas
Foo::process(data); // appel direct, inlinable
} else {
base_ptr->process(data); // fallback indirect
}Cette transformation élimine le coût de l'appel indirect (indirection mémoire + misprediction de cible) dans le cas dominant.
# Compiler avec instrumentation
g++ -O2 -march=native -fprofile-generate=./pgo-data \
-o my_program_instrumented \
src/*.cpp
# L'option -fprofile-generate :
# - Insère des compteurs dans le code
# - Spécifie le répertoire de sortie des données de profiling
# - Le répertoire est créé automatiquement s'il n'existe pas# Créer le répertoire (si non existant)
mkdir -p pgo-data
# Exécuter avec un workload REPRÉSENTATIF
./my_program_instrumented --typical-workload input_data.bin
# Les fichiers .gcda sont générés dans pgo-data/
ls pgo-data/
# src/main.gcda src/parser.gcda src/engine.gcda ...
⚠️ Le workload de profiling est critique. Il doit être représentatif de l'utilisation réelle. Si le programme est un serveur HTTP et que le profiling est effectué avec un seul type de requête, les branchements liés aux autres types de requêtes seront mal optimisés. L'idéal est d'utiliser un jeu de test qui couvre les cas d'usage principaux dans les proportions réalistes.
# Recompiler en utilisant les données de profiling
g++ -O2 -march=native -fprofile-use=./pgo-data \
-o my_program_optimized \
src/*.cpp
# Options supplémentaires recommandées avec PGO :
# -fprofile-correction : tolère les données légèrement incohérentes
# -Wmissing-profile : avertit si un fichier source n'a pas de profil# Supprimer les données de profiling (avant un nouveau cycle)
rm -rf pgo-data/
# Cycle complet en une commande
g++ -O2 -march=native -fprofile-generate=./pgo-data -o instr src/*.cpp \
&& ./instr --workload data.bin \
&& g++ -O2 -march=native -fprofile-use=./pgo-data -o optimized src/*.cppClang utilise un workflow similaire mais avec des noms de flags différents et un outil supplémentaire pour fusionner les profils.
clang++ -O2 -march=native -fprofile-instr-generate=default.profraw \
-o my_program_instrumented \
src/*.cpp# Exécuter le programme instrumenté
./my_program_instrumented --typical-workload input_data.bin
# → génère default.profraw
# Convertir le profil brut en format indexé
llvm-profdata merge -output=default.profdata default.profraw
# Si plusieurs exécutions (recommandé pour couvrir plus de cas) :
./my_program_instrumented --workload-1 data1.bin # → profraw1
./my_program_instrumented --workload-2 data2.bin # → profraw2
./my_program_instrumented --workload-3 data3.bin # → profraw3
# Fusionner tous les profils
llvm-profdata merge -output=merged.profdata \
default_*.profrawLa fusion de profils issus de workloads variés produit un profil plus représentatif que celui d'une seule exécution. C'est une bonne pratique systématique.
clang++ -O2 -march=native -fprofile-instr-use=merged.profdata \
-o my_program_optimized \
src/*.cpp# Vérifier quels fichiers sont couverts par le profil
clang++ -O2 -fprofile-instr-use=merged.profdata \
-Wprofile-instr-unprofiled \
-c src/nouveau_fichier.cpp
# Warning si nouveau_fichier.cpp n'a pas de données de profiling| Aspect | GCC | Clang |
|---|---|---|
| Flag instrumentation | -fprofile-generate |
-fprofile-instr-generate |
| Flag utilisation | -fprofile-use |
-fprofile-instr-use |
| Format des données | .gcda (un par fichier source) |
.profraw → .profdata (fusion) |
| Outil de fusion | Non nécessaire (merge implicite) | llvm-profdata merge (obligatoire) |
| Profils multi-run | Écrase ou accumule selon les options | Fusion explicite avec llvm-profdata |
| Gains typiques | 10–20 % | 10–25 % (LLVM tend à exploiter le PGO plus agressivement) |
Pour un projet CMake, le PGO peut être intégré via des presets ou des options de configuration :
# CMakeLists.txt — support PGO via une option
option(ENABLE_PGO_GENERATE "Compile with PGO instrumentation" OFF)
option(ENABLE_PGO_USE "Compile with PGO optimization" OFF)
set(PGO_DATA_DIR "${CMAKE_BINARY_DIR}/pgo-data")
if(ENABLE_PGO_GENERATE)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU")
add_compile_options(-fprofile-generate=${PGO_DATA_DIR})
add_link_options(-fprofile-generate=${PGO_DATA_DIR})
elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang")
add_compile_options(-fprofile-instr-generate=${PGO_DATA_DIR}/default.profraw)
add_link_options(-fprofile-instr-generate=${PGO_DATA_DIR}/default.profraw)
endif()
elseif(ENABLE_PGO_USE)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU")
add_compile_options(-fprofile-use=${PGO_DATA_DIR} -fprofile-correction)
add_link_options(-fprofile-use=${PGO_DATA_DIR})
elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang")
add_compile_options(-fprofile-instr-use=${PGO_DATA_DIR}/merged.profdata)
add_link_options(-fprofile-instr-use=${PGO_DATA_DIR}/merged.profdata)
endif()
endif()Script de build PGO complet :
#!/bin/bash
set -e
BUILD_DIR="build-pgo"
PGO_DATA="$BUILD_DIR/pgo-data"
echo "=== Phase 1 : Compilation instrumentée ==="
cmake -B "$BUILD_DIR" -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DENABLE_PGO_GENERATE=ON
cmake --build "$BUILD_DIR" --parallel
echo "=== Phase 2 : Profiling ==="
mkdir -p "$PGO_DATA"
"$BUILD_DIR/my_program" --benchmark-workload data/representative_input.bin
# Pour Clang uniquement : fusionner les profils
if command -v llvm-profdata &> /dev/null; then
llvm-profdata merge -output="$PGO_DATA/merged.profdata" \
"$PGO_DATA"/*.profraw 2>/dev/null || true
fi
echo "=== Phase 3 : Recompilation optimisée ==="
cmake -B "$BUILD_DIR" -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DENABLE_PGO_GENERATE=OFF \
-DENABLE_PGO_USE=ON
cmake --build "$BUILD_DIR" --parallel
echo "=== Binaire PGO-optimisé prêt ==="
ls -la "$BUILD_DIR/my_program" Le PGO s'intègre naturellement dans un pipeline CI/CD. Le profiling peut être exécuté automatiquement sur chaque release :
# .github/workflows/pgo-build.yml (GitHub Actions — simplifié)
name: PGO Release Build
on:
push:
tags: ['v*']
jobs:
pgo-build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: sudo apt-get install -y ninja-build
# Phase 1 : Build instrumenté
- name: Build instrumented
run: |
cmake -B build-pgo -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DENABLE_PGO_GENERATE=ON
cmake --build build-pgo --parallel
# Phase 2 : Profiling
- name: Run profiling workload
run: |
./build-pgo/my_program --benchmark tests/pgo_workload.bin
# Phase 3 : Build optimisé
- name: Build PGO-optimized
run: |
cmake -B build-pgo -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DENABLE_PGO_GENERATE=OFF \
-DENABLE_PGO_USE=ON
cmake --build build-pgo --parallel
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: my_program-pgo
path: build-pgo/my_programLe workload de profiling doit être versionné et maintenu avec le code source (dans tests/ ou benchmarks/), comme tout autre artefact de test.
Le PGO classique (instrumentation) a un inconvénient : le binaire instrumenté est 15–30 % plus lent, ce qui le rend impraticable pour le profiling en production.
L'AutoFDO (Automatic Feedback-Directed Optimization) est une variante qui utilise le sampling au lieu de l'instrumentation : on profile un binaire normal (non instrumenté) en production avec perf record, puis on convertit les données perf en profil exploitable par le compilateur.
# 1. Compiler normalement (avec infos de debug pour le mapping)
g++ -O2 -march=native -g -o my_program src/*.cpp
# 2. Profiler en production avec perf (overhead < 2%)
perf record -b -e cycles:u -o perf.data -- ./my_program --production-workload
# 3. Convertir le profil perf en profil GCC
# Outil : create_gcov (Google AutoFDO tools)
create_gcov --binary=my_program \
--profile=perf.data \
--gcov=profile.afdo
# 4. Recompiler avec le profil AutoFDO
g++ -O2 -march=native -fauto-profile=profile.afdo \
-o my_program_optimized src/*.cppClang propose une variante appelée Context-Sensitive PGO qui peut utiliser des profils perf :
# 1. Compiler avec debug info
clang++ -O2 -march=native -gline-tables-only -o my_program src/*.cpp
# 2. Profiler avec perf
perf record -b -e cycles:u -o perf.data -- ./my_program
# 3. Convertir avec llvm-profgen
llvm-profgen --binary=my_program \
--perfdata=perf.data \
--output=profile.spgo
# 4. Recompiler
clang++ -O2 -march=native -fprofile-sample-use=profile.spgo \
-o my_program_optimized src/*.cpp| Aspect | PGO classique (instrumentation) | AutoFDO (sampling) |
|---|---|---|
| Overhead à l'exécution | 15–30 % | < 2 % |
| Utilisable en production | Non (trop lent) | Oui |
| Précision du profil | Exacte (compteurs) | Statistique (sampling) |
| Gain typique | 15–25 % | 8–15 % |
| Complexité de setup | Faible | Moyenne (outils supplémentaires) |
| Représentativité | Dépend du workload de test | Workload de production réel |
L'AutoFDO capture un profil moins précis que l'instrumentation, d'où un gain légèrement inférieur. Mais il a l'avantage majeur de profiler le vrai workload de production, pas un substitut de test. Pour les services à longue durée de vie (serveurs, bases de données), l'AutoFDO est souvent la meilleure option.
Le PGO est utilisé en production par de nombreux projets majeurs :
| Projet | Gain PGO rapporté | Source |
|---|---|---|
| Clang/LLVM (compilateur) | ~20 % sur la compilation | Documentation LLVM officielle |
| GCC (compilateur) | ~7–10 % sur la compilation | Benchmarks communautaires |
| Chromium (navigateur) | ~10–15 % sur les benchmarks web | Blog Google Chrome |
| Firefox | ~5–12 % sur Speedometer | Bugzilla Mozilla |
| CPython 3.12+ | ~5 % sur pyperformance | PEP 744, documentation CPython |
| PostgreSQL | ~5–10 % sur pgbench | Wiki PostgreSQL |
| RocksDB | ~10 % sur les benchmarks I/O | Blog Facebook Engineering |
Le gain est systématiquement plus élevé sur les programmes ayant un code footprint important avec beaucoup de branchements imprévisibles statiquement (compilateurs, interpréteurs, bases de données). Il est plus modeste sur les programmes dominés par une boucle serrée déjà bien optimisée.
Le PGO atteint son plein potentiel lorsqu'il est combiné avec le Link-Time Optimization (LTO, section 41.5). Le LTO permet l'optimisation inter-fichiers, et le PGO fournit les données de profiling pour guider ces optimisations à travers tout le programme.
# La combinaison maximale : PGO + LTO + march=native
# Phase 1
g++ -O2 -march=native -flto -fprofile-generate=./pgo-data \
-o instrumented src/*.cpp
# Phase 2
./instrumented --workload data.bin
# Phase 3
g++ -O2 -march=native -flto -fprofile-use=./pgo-data \
-o optimized src/*.cppLe gain combiné PGO + LTO est typiquement supérieur à la somme des gains individuels : le LTO permet des inlining inter-fichiers que le PGO peut ensuite calibrer, et le PGO guide le LTO vers les optimisations les plus rentables.
| Configuration | Gain typique vs -O2 seul |
|---|---|
| PGO seul | 10–20 % |
| LTO seul | 5–10 % |
| PGO + LTO | 15–30 % |
Le piège le plus courant : un workload de profiling non représentatif. Si le profiling est fait sur un micro-benchmark qui ne couvre que 20 % des chemins de code, les 80 % restants seront moins bien optimisés qu'avec les heuristiques par défaut — le compilateur suppose qu'ils sont froids et les déplace hors du hot path.
Bonne pratique : utiliser un workload qui couvre les cas d'usage principaux dans des proportions réalistes. Pour un serveur web : un mélange de GET, POST, requêtes statiques et dynamiques, avec la distribution réelle. Pour un compilateur : compiler un ensemble varié de fichiers source.
Les données de profiling deviennent obsolètes (stale) quand le code source évolue significativement. Un profil datant de 6 mois sur un projet actif est probablement inutile — les fonctions ont été renommées, déplacées, ou supprimées. Le compilateur gère gracieusement les profils partiellement obsolètes (il ignore les fonctions inconnues), mais le gain diminue.
Bonne pratique : régénérer les profils à chaque release majeure, ou intégrer le PGO dans le pipeline CI/CD (voir section précédente).
Les profils PGO rendent le build non déterministe si le workload de profiling n'est pas strictement reproductible. Deux machines différentes peuvent produire des profils légèrement différents (timing, ordonnancement des threads), menant à des binaires différents.
Bonne pratique : versionner le workload de profiling et les fichiers .profdata / .gcda dans le système de gestion de version (ou un artifact store), comme tout autre artefact de build.
Le PGO triple le temps de build (compilation × 2 + exécution du profiling). Pour les gros projets, cela peut ajouter 10–30 minutes au pipeline. L'utilisation de ccache (section 2.3) et de Ninja (section 28.3) atténue le problème, mais le coût reste réel.
Bonne pratique : réserver le PGO pour les builds de release, pas pour le développement quotidien.
| Aspect | Détail |
|---|---|
| Principe | Compiler → profiler → recompiler avec les données d'exécution réelle |
| Gain typique | 10–25 % sans modification du code source |
| GCC | -fprofile-generate / -fprofile-use |
| Clang | -fprofile-instr-generate / -fprofile-instr-use + llvm-profdata merge |
| Alternative légère | AutoFDO : profiling perf en production, overhead < 2 % |
| Combinaison recommandée | PGO + LTO + -march=native pour les builds de release |
| Point critique | Le workload de profiling doit être représentatif de la production |
| Fréquence | Régénérer à chaque release majeure |
| Projets qui en bénéficient le plus | Compilateurs, interpréteurs, bases de données, navigateurs |