Il y a quelques mois, j'ai mentionné dans un article que
c'était un mythe, comme si const aide à activer les optimisations du compilateur en C et C ++ . J'ai décidé que cette déclaration devait être expliquée, surtout parce que je croyais moi-même en ce mythe auparavant. Je commencerai par des exemples théoriques et artificiels, puis je passerai à des expériences et à des tests basés sur une véritable base de code - SQLite.
Test simple
Commençons par, comme il me semble, l'exemple le plus simple et le plus évident d'accélérer le code C avec
const
. Disons que nous avons deux déclarations de fonction:
void func(int *x); void constFunc(const int *x);
Et supposons qu'il existe deux versions du code:
void byArg(int *x) { printf("%d\n", *x); func(x); printf("%d\n", *x); } void constByArg(const int *x) { printf("%d\n", *x); constFunc(x); printf("%d\n", *x); }
Pour exécuter
printf()
, le processeur doit récupérer
*x
de la mémoire via un pointeur. Évidemment, l'exécution de
constByArg()
peut être légèrement plus rapide, car le compilateur sait que
*x
est une constante, il n'est donc pas nécessaire de charger à nouveau sa valeur après que
constFunc()
ait fait. Non? Voyons le code assembleur généré par GCC avec les optimisations activées:
$ gcc -S -Wall -O3 test.c $ view test.s
Et voici le résultat complet de l'assembleur pour
byArg()
:
byArg: .LFB23: .cfi_startproc pushq %rbx .cfi_def_cfa_offset 16 .cfi_offset 3, -16 movl (%rdi), %edx movq %rdi, %rbx leaq .LC0(%rip), %rsi movl $1, %edi xorl %eax, %eax call __printf_chk@PLT movq %rbx, %rdi call func@PLT # The only instruction that's different in constFoo movl (%rbx), %edx leaq .LC0(%rip), %rsi xorl %eax, %eax movl $1, %edi popq %rbx .cfi_def_cfa_offset 8 jmp __printf_chk@PLT .cfi_endproc
La seule différence entre le code assembleur généré pour
byArg()
et
constByArg()
est que
constByArg()
a un
call constFunc@PLT
, comme dans le code source.
const
lui-même ne fait aucune différence.
D'accord, c'était GCC. Peut-être avons-nous besoin d'un compilateur plus intelligent. Dis Clang.
$ clang -S -Wall -O3 -emit-llvm test.c $ view test.ll
Voici le code intermédiaire. Il est plus compact que l'assembleur, et je vais supprimer les deux fonctions, afin que vous compreniez ce que j'entends par «aucune différence, sauf pour l'appel»:
; Function Attrs: nounwind uwtable define dso_local void @byArg(i32*) local_unnamed_addr #0 { %2 = load i32, i32* %0, align 4, !tbaa !2 %3 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %2) tail call void @func(i32* %0) #4 %4 = load i32, i32* %0, align 4, !tbaa !2 %5 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %4) ret void } ; Function Attrs: nounwind uwtable define dso_local void @constByArg(i32*) local_unnamed_addr #0 { %2 = load i32, i32* %0, align 4, !tbaa !2 %3 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %2) tail call void @constFunc(i32* %0) #4 %4 = load i32, i32* %0, align 4, !tbaa !2 %5 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %4) ret void }
Option qui (type) fonctionne
Et voici le code dans lequel la présence de
const
vraiment importante:
void localVar() { int x = 42; printf("%d\n", x); constFunc(&x); printf("%d\n", x); } void constLocalVar() { const int x = 42;
Le code assembleur pour
localVar()
, qui contient deux instructions optimisées en dehors de
constLocalVar()
:
localVar: .LFB25: .cfi_startproc subq $24, %rsp .cfi_def_cfa_offset 32 movl $42, %edx movl $1, %edi movq %fs:40, %rax movq %rax, 8(%rsp) xorl %eax, %eax leaq .LC0(%rip), %rsi movl $42, 4(%rsp) call __printf_chk@PLT leaq 4(%rsp), %rdi call constFunc@PLT movl 4(%rsp), %edx # not in constLocalVar() xorl %eax, %eax movl $1, %edi leaq .LC0(%rip), %rsi # not in constLocalVar() call __printf_chk@PLT movq 8(%rsp), %rax xorq %fs:40, %rax jne .L9 addq $24, %rsp .cfi_remember_state .cfi_def_cfa_offset 8 ret .L9: .cfi_restore_state call __stack_chk_fail@PLT .cfi_endproc
Le middleware LLVM est un peu plus propre.
load
avant le deuxième appel à
printf()
été optimisé en dehors de
constLocalVar()
:
; Function Attrs: nounwind uwtable define dso_local void @localVar() local_unnamed_addr #0 { %1 = alloca i32, align 4 %2 = bitcast i32* %1 to i8* call void @llvm.lifetime.start.p0i8(i64 4, i8* nonnull %2) #4 store i32 42, i32* %1, align 4, !tbaa !2 %3 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 42) call void @constFunc(i32* nonnull %1) #4 %4 = load i32, i32* %1, align 4, !tbaa !2 %5 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %4) call void @llvm.lifetime.end.p0i8(i64 4, i8* nonnull %2) #4 ret void }
Ainsi,
constLocalVar()
réussi à ignorer le redémarrage
*x
, mais vous remarquerez peut-être quelque chose d'étrange: dans les corps
localVar()
et
constLocalVar()
le même appel à
constFunc()
. Si le compilateur peut comprendre que
constFunc()
n'a pas modifié
*x
dans
constLocalVar()
, alors pourquoi ne peut-il pas comprendre que le même appel de fonction n'a pas modifié
*x
dans
localVar()
?
L'explication est pourquoi
const
en C n'est pas pratique à utiliser comme optimisation. En C,
const
a essentiellement deux significations possibles:
- cela peut signifier qu'une variable est un pseudonyme en lecture seule pour certaines données, qui peuvent être constantes ou non.
- ou cela peut signifier que la variable est vraiment une constante. Si vous détachez
const
d'un pointeur à une valeur constante, puis que vous y écrivez, vous obtiendrez un comportement indéfini. En revanche, il n'y aura pas de problème si const
est un pointeur vers une valeur qui n'est pas une constante.
Voici un exemple explicatif d'implémentation de
constFunc()
:
localVar()
donné à
constFunc()
un pointeur
const
vers une variable non
const
. Puisque la variable n'était pas
const
initialement,
constFunc()
peut s'avérer être un menteur et modifier la force avec force sans lancer UB. Par conséquent, le compilateur ne peut pas supposer qu'après avoir renvoyé
constFunc()
variable aura la même valeur. La variable dans
constLocalVar()
effet
const
, donc le compilateur ne peut pas supposer qu'elle ne sera pas modifiée, car cette fois ce
sera UB pour
constFunc()
, de sorte que le compilateur déliera
const
et écrit dans la variable.
Les fonctions
byArg()
et
constByArg()
du premier exemple sont sans espoir, car le compilateur ne peut pas
constByArg()
si
*x
est
const
.
Mais d'où vient l'incohérence? Si le compilateur peut supposer que
constFunc()
ne change pas son argument lorsqu'il est appelé à partir de
constLocalVar()
, alors il peut appliquer les mêmes optimisations aux
constFunc()
, n'est-ce pas? Non. Le compilateur ne peut pas supposer que
constLocalVar()
sera jamais appelé du tout. Et si ce n'est pas le cas (par exemple, car il s'agit simplement d'un résultat supplémentaire du générateur de code ou de l'opération de macro),
constFunc()
peut modifier les données en silence sans lancer UB.
Vous devrez peut-être lire les exemples et explications ci-dessus plusieurs fois. Ne vous inquiétez pas, cela semble absurde - ça l'est. Malheureusement, écrire dans des variables
const
est le pire type d'UB: le plus souvent, le compilateur ne sait même pas si ce sera UB. Par conséquent, lorsque le compilateur voit
const
, il doit partir du fait que quelqu'un peut le modifier quelque part, ce qui signifie que le compilateur ne peut pas utiliser
const
pour l'optimisation. En pratique, cela est vrai, car beaucoup de vrai code C contient un rejet de
const
dans le style "Je sais ce que je fais".
En bref, il existe de nombreuses situations où le compilateur n'est pas autorisé à utiliser
const
pour l'optimisation, notamment la récupération de données d'une autre étendue à l'aide d'un pointeur ou le placement de données sur un segment de mémoire. Ou pire encore, généralement dans des situations où le compilateur ne peut pas utiliser
const
, ce n'est pas nécessaire. Par exemple, tout compilateur qui se respecte peut comprendre sans
const
que dans ce code
x
est une constante:
int x = 42, y = 0; printf("%d %d\n", x, y); y += x; printf("%d %d\n", x, y);
Donc
const
presque inutile pour l'optimisation, car:
- À quelques exceptions près, le compilateur est obligé de l'ignorer, car du code peut délier légalement
const
.
- Dans la plupart des exceptions ci-dessus, le compilateur peut toujours comprendre que la variable est une constante.
C ++
Si vous écrivez en C ++,
const
peut affecter la génération de code via une surcharge de fonction. Vous pouvez avoir des surcharges
const
et non
const
de la même fonction, et non
const
peut être optimisé (par un programmeur, pas un compilateur), par exemple, pour copier moins.
void foo(int *p) {
D'une part, je ne pense pas que, dans la pratique, cela soit souvent appliqué dans le code C ++. D'un autre côté, pour que cela fasse vraiment une différence, un programmeur doit faire des hypothèses qui ne sont pas disponibles pour le compilateur, car elles ne sont pas garanties par le langage.
Expérimentez avec SQLite3
Assez de théorie et d'exemples farfelus. Quel effet
const
sur la base de code réelle? J'ai décidé d'expérimenter avec SQLite DB (version 3.30.0), car:
- Il utilise
const.
- Il s'agit d'une base de code non triviale (plus de 200 KLOC).
- En tant que base de données, il comprend un certain nombre de mécanismes, commençant par le traitement des valeurs de chaîne et se terminant par la conversion des nombres à ce jour.
- Il peut être testé avec une charge limitée du processeur.
De plus, l'auteur et les programmeurs impliqués dans le développement ont déjà passé des années à améliorer la productivité, nous pouvons donc supposer qu'ils n'ont rien raté d'évident.
La préparation
J'ai fait deux copies du
code source . Un compilé en mode normal et le second prétraité à l'aide d'un hack pour transformer
const
en une commande inactive:
#define const
(GNU)
sed
peut ajouter ceci au-dessus de chaque fichier avec la commande
sed -i '1i#define const' *.c *.h
.
SQLite complique un peu les choses, en utilisant des scripts pour générer du code lors de la construction. Heureusement, les compilateurs introduisent beaucoup d'interférences lors du mélange de code avec
const
et sans
const
, vous pouvez donc immédiatement remarquer et configurer les scripts pour ajouter mon code anti-
const
.
La comparaison directe des codes compilés n'a pas de sens, car un petit changement peut affecter l'ensemble du schéma de mémoire, ce qui entraînera un changement de pointeurs et d'appels de fonction dans tout le code. Par conséquent, j'ai pris un cast désassemblé (
objdump -d libSQLite3.so.0.8.6
) comme taille du binaire et nom mnémonique de chaque instruction. Par exemple, cette fonction:
000000000005d570 <SQLite3_blob_read>: 5d570: 4c 8d 05 59 a2 ff ff lea -0x5da7(%rip),%r8 # 577d0 <SQLite3BtreePayloadChecked> 5d577: e9 04 fe ff ff jmpq 5d380 <blobReadWrite> 5d57c: 0f 1f 40 00 nopl 0x0(%rax)
Se transforme en:
SQLite3_blob_read 7lea 5jmpq 4nopl
Lors de la compilation, je n'ai pas modifié les paramètres de l'assembly SQLite.
Analyse de code compilé
Pour libSQLite3.so, la version avec
const
occupait 4 740 704 octets, environ 0,1% de plus que la version sans
const
avec 4 736 712 octets. Dans les deux cas, 1374 fonctions ont été exportées (sans compter les fonctions d'assistance de bas niveau dans le PLT) et 13 présentaient des différences dans les transtypages.
Certaines modifications étaient liées au hack de prétraitement. Par exemple, voici l'une des fonctions modifiées (j'ai supprimé certaines définitions spécifiques à SQLite):
#define LARGEST_INT64 (0xffffffff|(((int64_t)0x7fffffff)<<32)) #define SMALLEST_INT64 (((int64_t)-1) - LARGEST_INT64) static int64_t doubleToInt64(double r){ /* ** Many compilers we encounter do not define constants for the ** minimum and maximum 64-bit integers, or they define them ** inconsistently. And many do not understand the "LL" notation. ** So we define our own static constants here using nothing ** larger than a 32-bit integer constant. */ static const int64_t maxInt = LARGEST_INT64; static const int64_t minInt = SMALLEST_INT64; if( r<=(double)minInt ){ return minInt; }else if( r>=(double)maxInt ){ return maxInt; }else{ return (int64_t)r; } }
Si nous supprimons
const
, ces constantes se transforment en variables
static
. Je ne comprends pas pourquoi quiconque ne se soucie pas de
const
rendre ces variables
static
. Si nous supprimons à la fois
static
et
const
, GCC les considérera à nouveau comme des constantes et nous obtiendrons le même résultat. En raison de ces variables
static const
stat, les changements dans trois fonctions sur treize se sont révélés faux, mais je ne les ai pas corrigés.
SQLite utilise de nombreuses variables globales, et la majorité des vraies optimisations
const
sont liées à cela: comme remplacer une comparaison par une variable par une comparaison avec une constante, ou faire reculer partiellement la boucle d'une étape (pour comprendre quel type d'optimisations ont été faites, j'ai utilisé
Radare ). Quelques changements ne valent pas la peine d'être mentionnés.
SQLite3ParseUri()
contient 487 instructions, mais
const
n'a apporté qu'une seule modification: a pris ces deux comparaisons:
test %al, %al je <SQLite3ParseUri+0x717> cmp $0x23, %al je <SQLite3ParseUri+0x717>
Et échangé:
cmp $0x23, %al je <SQLite3ParseUri+0x717> test %al, %al je <SQLite3ParseUri+0x717>
Repères
SQLite est livré avec un test de régression pour mesurer les performances, et je l'ai exécuté des centaines de fois pour chaque version du code en utilisant les paramètres de génération SQLite standard. Temps d'exécution en secondes:
Personnellement, je ne vois pas beaucoup de différence. J'ai supprimé
const
de tout le programme, donc s'il y avait une différence notable, il était facile de le remarquer. Cependant, si les performances sont extrêmement importantes pour vous, même une petite accélération peut vous plaire. Faisons une analyse statistique.
J'aime utiliser le test Mann-Whitney U pour de telles tâches. Il est similaire au test t plus connu, conçu pour déterminer les différences entre les groupes, mais est plus résistant aux variations aléatoires complexes qui se produisent lors de la mesure du temps sur les ordinateurs (en raison de changements de contexte imprévisibles, d'erreurs dans pages de mémoire, etc.). Voici le résultat:
Le test U a révélé une différence statistiquement significative dans les performances. Mais - une surprise! - La version sans
const
s'est avérée plus rapide, d'environ 60 ms, soit 0,5%. Il semble que le petit nombre d '«optimisations» effectuées ne valait pas l'augmentation du volume de code. Il est peu probable que
const
activé des optimisations majeures, telles que l'auto-vectorisation. Bien sûr, votre kilométrage peut dépendre de divers indicateurs du compilateur, ou de sa version, ou de la base de code, ou d'autre chose. Mais il me semble honnête de dire que même si
const
amélioré les performances de C, je ne l'ai pas remarqué.
Alors, à quoi sert const?
Pour tous ses défauts,
const
en C / C ++ est utile pour assurer la sécurité des types. En particulier, si vous utilisez
const
en combinaison avec la sémantique de déplacement et
std::unique_pointer
, vous pouvez implémenter la propriété explicite du pointeur. L'incertitude de la propriété du pointeur était un énorme problème dans les anciennes bases de code C ++ de plus de 100 KLOC, donc je suis reconnaissant de
const
pour le résoudre.
Cependant, avant d'aller au-delà de l'utilisation de
const
pour assurer la sécurité des types. J'ai entendu dire qu'il était jugé correct d'utiliser
const
plus activement possible pour améliorer les performances. J'ai entendu dire que si les performances étaient vraiment importantes, il fallait refactoriser le code pour ajouter plus de
const
, même si le code devenait moins lisible. Cela semblait raisonnable à l'époque, mais depuis lors, j'ai réalisé que ce n'était pas vrai.