Les options du compilateur

Les compilateurs disposent de beaucoup d'options. Explorer ces options permet de produire un exécutable (ou une librairie) plus performant et/ou plus léger sans toucher au code. Attention cependant à quelques options, qui peuvent rendre le code moins portable ou même le faire dysfonctionner.

Des optimisations partent du principe quel le standard est respecté et enlèvent des vérifications ou corrections potentielles. Optimiser un programme peut donc le faire mal fonctionner : dans ce cas, l'erreur ne vient généralement pas de l'optimisation mais du programme.

Les flags donnés sont pour GCC, mais des options similaires sont généralement proposées par les autres compilateurs. Les flags cités sont souvent des raccourcis pour activer plusieurs flags à la fois : de meilleurs résultats peuvent être parfois obtenus en désactivant ou activant des flags individuels à la main.

Les options concernant les warnings ne sont pas couverts ici. Les flags souvent utilisés sont -Werror (pour traiter les warnings comme des erreurs), -Wall et -Wextra (pour activer la plupart des warnings).

Les optimisations communes

Des flags généraux comme -O1 ou -O3 regroupent de nombreux flags individuels. Cela permet de spécifier facilement le niveau d'optimisation voulu. Par défaut, c'est -O0 qui est spécifié : augmenter le chiffre (jusqu'à 3) va donc optimiser progressivement le programme.

Les optimisations des premiers niveaux sont celles qui sont le plus efficaces par rapport au temps de compilation supplémentaire qu'elles demandent : cela permet de faire un compromis entre le temps de compilation et le temps d'exécution.

-Og correspond à -O1 moins les optimisations qui pourraient nuire au débugging. -Os va optimiser le programme pour qu'il prenne le moins de place possible.

-Ofast active les optimisations les plus agressives, notamment -ffast-math (lui-même regroupant plusieurs flags) qui peut faire dysfonctionner un programme correctement écrit.

L'option -flto va ajouter des informations dans les object file produit par le compilateur, qui vont être réutilisées par le linker. C'est donc le linker qui va produire des optimisations, par exemple pour inline des fonctions dans les translation unit qui ne possèdent pas sa définition. Cette optimisation permet notamment d'optimiser des binding entre plusieurs langages (comme C++ et Fortran).

Exemple d'utilisation :

// main.cpp

#include <chrono>
#include <iostream>

void func(); // Pas visible (donc non inlinable).

int main() {
    using namespace std::chrono;
    auto begin = high_resolution_clock::now();
    
    for (int i = 0; i < 1'000'000'000; ++i) func();
    
    auto end = high_resolution_clock::now();
    auto ms = duration_cast<microseconds>(end - begin);
    std::cout << "loop time = " << ms.count() << "ms\n";
}
 
// func.cpp

void func() {}

En compilant avec g++ main.cpp func.cpp -O3, ce programme affiche sur mon ordinateur ~1500ms. Si on rajoute le flag -flto, l'exécution devient instantanée : le linker a pu inline la fonction, et voir que la boucle est inutile (et donc la supprimer).

Optimisations ciblant une architecture

Par défaut, le compilateur va générer un exécutable générique pouvant s'exécuter sur de nombreuses architectures (pas toutes : un programme 64bits ne pourra pas tourner sur une architecture 32bits). En disant au compilateur que l'on cible seulement certaines architectures, il pourra optimiser en utilisant des instructions propres à celles-ci ou arranger les instructions pour optimiser leur exécution sur cette architecture.

Parmi les instructions utiles disponibles sur certaines architectures, on retrouve par exemple les instructions et registres avx (pour la vectorisation), ou cmpxchg16b (pour les opérations atomiques).

-march=target va rendre le code moins portable en utilisant toutes les instructions et registres possibles de l'architecture donnée. D'autres compilateurs permettent de spécifier plusieurs architectures.-mtune=target va optimiser le code généré pour l'architecture donnée, mais sans changer sa portabilité.

mtune et march peuvent être combinés pour par exemple spécifier les architectures minimales nécessaires avec march, et optimiser l'exécutable pour une autre architecture avec mtune. Si target = native, l'architecture ciblée sera celle de l'ordinateur en train de compiler le code.

Profile-Guided Optimization (PGO)

Lorsqu'on compile le code, on ne dispose pas de toutes les informations qui pourraient permettre de mieux l'utiliser : en effet, le compilateur ne connaît pas forcément les branchements les plus probables (dans les if, switch, while, ...), les parties du code les plus appelées ou le nombre moyen d'itérations dans les boucles.

En cours d'exécution, on dispose de ces informations : la Profile Guided Optimization va donc consister en un compilation en deux temps : d'abord, le programme est compilé avec -fprofile-generate. Puis, on l'utilise en faisant les actions qu'on souhaiterait voir optimisées. Enfin, on le compile une seconde fois avec -fprofile-use. Le compilation va alors utiliser les informations accumulées durant l'exécution du code pour optimiser le programme.

Pour aller plus loin

Comme dit précédemment, les flags cités en regroupent plusieurs autres. Par exemple, pour demander des instructions précises dépendant d'architectures, de nombreux flags commençant par -m existent.

Des compilateurs à la volée (JIT, ou Just In Time) existent pour C++. Compiler à la volée permet d'optimiser le code pour le cas d'utilisation courant, un peu comme ce qu'essaye de faire la PGO.

-fno-rtti et -fno-exceptions désactivent respectivement les Run-Time Type Informations et les exceptions. Le gain obtenu affecte principalement la taille de l'exécutable.

Last updated