Pourquoi const n'accélère-t-il pas le code C / C ++?


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; // const on the local variable printf("%d\n", x); constFunc(&x); printf("%d\n", x); } 

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() :

 // x is just a read-only pointer to something that may or may not be a constant void constFunc(const int *x) { // local_var is a true constant const int local_var = 42; // Definitely undefined behaviour by C rules doubleIt((int*)&local_var); // Who knows if this is UB? doubleIt((int*)x); } void doubleIt(int *x) { *x *= 2; } 

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:

  1. À quelques exceptions près, le compilateur est obligé de l'ignorer, car du code peut délier légalement const .
  2. 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) { // Needs to do more copying of data } void foo(const int *p) { // Doesn't need defensive copies } int main() { const int x = 42; // const-ness affects which overload gets called foo(&x); return 0; } 

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:

const
Sans const
Minimum
10 658
10 803
Médiane
11 571
11,519
Maximum
11 832
11 658
Moyenne
11,531
11 492

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:

constSans const
N100100
Catégorie moyenne (rang moyen)121,3879,62
Mann-whitney u2912
Z-5,10
Valeur p bilatérale<10 -6
La différence moyenne est HL
-0,056 s.
Intervalle de confiance à 95%
-0,077 ... -0,038 s.

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.

Source: https://habr.com/ru/post/fr464777/


All Articles