Les indices au compilateur
Les compilateurs de C++ sont généralement très forts pour optimiser le code. Cependant, ils peuvent manquer des optimisations si ils ne disposent pas assez d'informations. On va donc s'intéresser aux différentes façons de donner ces informations à notre compilateur.
Les indications utilisées en exemple proviennent de GCC, elles leurs équivalents existent pour Clang et (parfois) pour MSVC. Etant de simples indications, elles peuvent souvent être ignorées pour les compilateurs qui ne les implémentent pas. Lorsqu'on écrit du code pour plusieurs compilateurs, on regroupe généralement ces indications avec une macro commune :
Les attributs standards
[[noreturn]]
indique qu'une fonction n'est jamais quitté normalement : soit parce qu'elle arrête le programme, ou lance un exception à chaque fois par exemple.[[carries_dependency]]
permet d'optimiser des manipulations de variables atomiques en retirant quelques contraintes sur le compilateur, lorsquestd::memory_order_consume
est utilisé.[[deprecated("message")]]
émet un warning lorsque la fonction marquée est utilisée. Le message est optionnel.[[maybe_unusued]]
empêche au compilateur d'émettre un warning lorsque la fonction n'est pas utilisée.[[fallthrough]]
indique que lecase
d'unswitch
laisse intentionnellement continuer l'exécution dans le prochaincase
.[[nodiscrad]]
émet un warning lorsque la valeur de retour d'une fonction est ignorée.
[[noreturn]]
permet d'optimiser le code en évitant de gérer le retour de la fonction marquée. Hormis [[carries_dependency]]
, les autres attributs n'optimisent pas le code généré.
Les prédictions de branches
Lors de l'exécution de code, le processeur dispose de moyens de prédire quelle branche le programme exécutera, c'est-à-dire les instructions suivant une condition (un if
/else
, un while
, un switch
ou un for
). Cela permet au programme de ne pas attendre le résultat du test, ce qui permet au pipeline du processus de continuer à se remplir. En cas d'erreur, il doit alors rebrousser chemin.
Ces prédictions peuvent être très assez précises (notamment pour les boucles for
), mais elles ne peuvent pas être connues à la compilation (car elles dépendent de l'exécution du programme) et ne changent donc pas le code généré.
On peut donc indiquer à GCC (qui est pris en exemple) quelle est la valeur la plus probable d'un test avec __builtin_expect(variable, value)
, généralement placé à l'intérieur du test. Voici par exemple les macros likely(val)
et unlikely(val)
, qui sont définies de cette façon pour le noyau linux :
En connaissant la branche la plus probable à la compilation, le compilateur peut alors par exemple déplacer les instructions de la branche la plus probable devant celles des autres. Ainsi, lorsqu'on lit les instructions à exécuter, les plus courantes se suivent, ce qui va accélérer leur exécution.
L'aliasing
Le standard indique que des références ou des pointeurs de types différents ne peuvent pas s'alias, c'est-à-dire désigner les mêmes données. Toutes les valeurs accédées depuis un pointeur ne doivent donc pas être accessibles depuis un pointeur de type différent.
Les types définis avec using
et typedef
ne sont pas différents du type d'origine : il s'agit juste d'un renommage. Des pointeurs ou références vers T
et const T
peuvent s'alias.
Les compilateurs qui implémentent ce principe (le strict aliasing) peuvent appliquer des optimisations supplémentaires, par exemple en évitant de lire les mêmes valeurs plusieurs fois en mémoire, ou pour appliquer plus efficacement la vectorisation. MSVC ne propose pas de strict aliasing.
Le strict aliasing demande une attention particulière au type-punning (qui réinterpréte des types) pour ne pas avoir de pointeurs de types différents qui s'aliasent, sous peine d'avoir un programme qui n'est pas garanti de fonctionner.
Les compilateurs qui utilisent le strict aliasing peuvent le désactiver, par exemple GCC avec -fno-strict-aliasing
.
Le mot-clé restrict
en C, disponible avec __restrict
en C++ pour de nombreux compilateurs indique qu'un pointeur ou une référence n'alias pas les autres pointeurs ou références de même type. Par exemple, memcpy
prend des pointeurs qui ne doivent pas s'alias, contrairement à memmove
. En C++, __restrict
peut également s'appliquer à this
, pour indiquer que les données pointées par this
n'alias pas les arguments de la fonction.
Pour l'exemple ci-dessus, le gain est important puisqu'on retire une instruction et on retire une dépendance : le pipeline du processeur peut assigner data[0]
et data[1]
en même temps dans le second cas, alors que les instructions du premier cas se font l'une après l'autre.
Attributs et autres indications
Les compilateurs permettent d'ajouter des attributs à des symboles, avec par exemple __attribute__((xxx))
pour GCC et Clang ou __declspec(xxx)
pour MSVC. GCC et Clang supportent l'écriture des attributs avec la syntaxe [[gnu::xxx]]
.
Les attributs noinline
et always_inline
indiquent au compilateur de ne pas inline une fonction (toujours possible) ou de toujours essayer d'inline une fonction (parfois impossible, si on souhaite désactiver les inlines de fonction pour débug ou pour certaines fonctions récursives).
Les attributs hot
et cold
vont ranger ensemble les fonctions marquées. Cela permet d'optimiser les accès mémoire lors de la lecture des instructions utilisées souvent (marquées hot).
Il existe d'autres indications communes, qui ne sont pas implémentées par le standard C++ :
La macro unreachable()
créé au début de ce chapitre indique que le processeur n'atteindra jamais cet endroit. Cela peut optimiser le code de manière semblable à [[noreturn]]
et clarifier le programme pour ses lecteurs, en évitant de renvoyer une valeur inutile pour éviter un warning ou une erreur :
La fonction __assume(x)
sur MSVC indique au compilateur que l'expression x
sera toujours évaluée à vraie (tant que ses dépendances ne sont pas modifiées). Cela permet de passer des informations au compilateur pour obtenir plus d'optimisations. Récemment, Clang a implémenté __builtin_assume(x)
. Pour GCC, l'information peut être donné indirectement avec if (!x) unreachable();
.
Ces indications ne vont pas apporter de gros gains de performances (à part parfois __restrict
), mais peuvent aider à optimiser une partie critique. De plus, elles peuvent souvent aider lors de la lecture du code (comme pour unreachable()
).
Pour aller plus loin
[[no_unique_address]]
en C++20 permet d'avoir des objets qui ne prennent aucune place en mémoire : actuellement, même un objet vide doit au moins prendre un octet (notamment pour qu'il dispose de sa propre adresse).
Last updated