Warum beschleunigt const C / C ++ - Code nicht?


Vor einigen Monaten erwähnte ich in einem Beitrag, dass dies ein Mythos ist, als ob const dabei hilft, Compiler-Optimierungen in C und C ++ zu ermöglichen . Ich entschied, dass diese Aussage erklärt werden sollte, insbesondere weil ich selbst vorher an diesen Mythos geglaubt hatte. Ich beginne mit Theorie und künstlichen Beispielen und gehe dann zu Experimenten und Benchmarks über, die auf einer echten Codebasis basieren - SQLite.

Einfacher Test


Beginnen wir mit dem einfachsten und offensichtlichsten Beispiel für die Beschleunigung von C-Code mit const . Angenommen, wir haben zwei Funktionsdeklarationen:

 void func(int *x); void constFunc(const int *x); 

Angenommen, es gibt zwei Versionen des Codes:

 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); } 

Um printf() auszuführen, muss der Prozessor *x über einen Zeiger aus dem Speicher abrufen. Offensichtlich kann die Ausführung von constByArg() etwas schneller sein, da der Compiler weiß, dass *x eine Konstante ist, sodass der Wert nicht erneut constFunc() muss, nachdem constFunc() dies getan hat. Richtig? Sehen wir uns den von GCC generierten Assembler-Code mit aktivierten Optimierungen an:

 $ gcc -S -Wall -O3 test.c $ view test.s 

Und hier ist das vollständige Assembler-Ergebnis für 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 

Der einzige Unterschied zwischen dem für byArg() und constByArg() generierten Assembler-Code besteht darin, dass constByArg() wie im Quellcode einen call constFunc@PLT hat. const selbst macht keinen Unterschied.

Okay, das war GCC. Vielleicht brauchen wir einen intelligenteren Compiler. Sag Clang.

 $ clang -S -Wall -O3 -emit-llvm test.c $ view test.ll 

Hier ist der Zwischencode. Es ist kompakter als Assembler, und ich werde beide Funktionen löschen, damit Sie verstehen, was ich unter "kein Unterschied, außer dem Aufruf" verstehe:

 ; 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, die (Typ) funktioniert


Und hier ist der Code, in dem das Vorhandensein von const wirklich wichtig ist:

 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); } 

Der Assembler-Code für localVar() , der zwei außerhalb von constLocalVar() optimierte Anweisungen enthält:

 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 

Die LLVM-Middleware ist etwas sauberer. load vor dem zweiten Aufruf von printf() wurde außerhalb von constLocalVar() optimiert:

 ; 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 } 

Daher hat constLocalVar() den Neustart *x erfolgreich ignoriert, aber Sie werden möglicherweise etwas Seltsames bemerken: In den Körpern localVar() und constLocalVar() derselbe Aufruf von constFunc() . Wenn der Compiler herausfinden kann, dass constFunc() *x in constLocalVar() nicht constLocalVar() , warum kann er dann nicht verstehen, dass der gleiche Funktionsaufruf *x in localVar() nicht localVar() ?

Die Erklärung ist, warum die Verwendung von const in C als Optimierung unpraktisch ist. In C hat const im Wesentlichen zwei mögliche Bedeutungen:

  • Dies kann bedeuten, dass eine Variable ein schreibgeschütztes Pseudonym für einige Daten ist, die konstant sein können oder nicht.
  • oder es kann bedeuten, dass die Variable wirklich eine Konstante ist. Wenn Sie const von einem Zeiger auf einen konstanten Wert lösen und dann darauf schreiben, erhalten Sie ein undefiniertes Verhalten. Andererseits gibt es kein Problem, wenn const ein Zeiger auf einen Wert ist, der keine Konstante ist.

Hier ist ein erklärendes Beispiel für die Implementierung von 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() gab constFunc() einen const Zeiger auf eine nicht- const Variable. Da die Variable anfangs nicht const , kann sich constFunc() als Lügner herausstellen und die Variable zwangsweise ändern, ohne UB zu initiieren. Daher kann der Compiler nicht davon ausgehen, dass constFunc() Variable nach der Rückgabe von constFunc() denselben Wert hat. Die Variable in constLocalVar() Tat const , daher kann der Compiler nicht davon ausgehen, dass sie nicht geändert wird, da diesmal UB für constFunc() , sodass der Compiler const aufhebt und in die Variable schreibt.

Die Funktionen byArg() und constByArg() aus dem ersten Beispiel sind hoffnungslos, da der Compiler nicht constByArg() , ob *x const .

Aber woher kam die Inkonsistenz? Wenn der Compiler davon ausgehen kann, dass constFunc() sein Argument beim Aufruf von constLocalVar() nicht ändert, kann er dieselben Optimierungen auf constFunc() -Aufrufe anwenden, oder? Nein. Der Compiler kann nicht davon ausgehen, dass constLocalVar() jemals aufgerufen wird. Wenn dies nicht der Fall ist (z. B. weil es sich nur um ein zusätzliches Ergebnis des Codegenerators oder der constFunc() kann constFunc() die Daten leise ändern, ohne UB zu initiieren.

Möglicherweise müssen Sie die obigen Beispiele und Erklärungen mehrmals lesen. Mach dir keine Sorgen, dass es absurd klingt - es ist. Leider ist das Schreiben in const Variablen die schlechteste Art von UB: Meistens weiß der Compiler nicht einmal, ob es sich um UB handelt. Wenn der Compiler const sieht, sollte er daher von der Tatsache ausgehen, dass jemand es irgendwo ändern kann, was bedeutet, dass der Compiler const zur Optimierung verwenden kann. In der Praxis ist dies der Fall, da viele echte C-Codes eine Ablehnung von const im Stil von "Ich weiß, was ich tue" enthalten.

Kurz gesagt, es gibt viele Situationen, in denen der Compiler const zur Optimierung verwenden darf, einschließlich des Abrufs von Daten aus einem anderen Bereich mithilfe eines Zeigers oder des Platzierens von Daten auf einem Heap. Oder noch schlimmer, normalerweise in Situationen, in denen der Compiler const nicht verwenden kann, ist dies nicht erforderlich. Zum Beispiel kann jeder Compiler mit Selbstachtung ohne const verstehen, dass in diesem Code x eine Konstante ist:

 int x = 42, y = 0; printf("%d %d\n", x, y); y += x; printf("%d %d\n", x, y); 

Daher ist const für die Optimierung fast unbrauchbar, weil:

  1. Mit wenigen Ausnahmen ist der Compiler gezwungen, dies zu ignorieren, da einige Codes die Konstante legal lösen können.
  2. In den meisten der oben genannten Ausnahmen kann der Compiler immer noch verstehen, dass die Variable eine Konstante ist.

C ++


Wenn Sie in C ++ schreiben, kann const die Codegenerierung durch Funktionsüberladung beeinflussen. Sie können const und non- const Überladungen derselben Funktion haben, und non- const kann beispielsweise (von einem Programmierer, nicht von einem Compiler) optimiert werden, um weniger zu kopieren.

 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; } 

Einerseits glaube ich nicht, dass dies in der Praxis häufig in C ++ - Code angewendet wird. Andererseits muss ein Programmierer Annahmen treffen, die dem Compiler nicht zur Verfügung stehen, damit sie wirklich einen Unterschied machen, da sie nicht durch die Sprache garantiert werden.

Experimentieren Sie mit SQLite3


Genug Theorie und weit hergeholte Beispiele. Welche Auswirkung hat const auf die reale Codebasis? Ich habe mich entschieden, mit SQLite DB (Version 3.30.0) zu experimentieren, weil:

  • Es verwendet const.
  • Dies ist eine nicht triviale Codebasis (über 200 KLOC).
  • Als Datenbank enthält sie eine Reihe von Mechanismen, die mit der Verarbeitung von Zeichenfolgenwerten beginnen und mit der Konvertierung von Zahlen in das Datum enden.
  • Es kann mit einer begrenzten Prozessorlast getestet werden.

Darüber hinaus haben der Autor und die an der Entwicklung beteiligten Programmierer bereits Jahre damit verbracht, die Produktivität zu verbessern, sodass wir davon ausgehen können, dass sie nichts Offensichtliches übersehen haben.

Vorbereitung


Ich habe zwei Kopien des Quellcodes erstellt . Eine im normalen Modus kompiliert und die zweite mit einem Hack vorverarbeitet, um const in einen Leerlaufbefehl umzuwandeln:

 #define const 

(GNU) sed kann dies mit dem Befehl sed -i '1i#define const' *.c *.h über jede Datei sed -i '1i#define const' *.c *.h .

SQLite verkompliziert die Dinge ein wenig und verwendet Skripte, um während des Builds Code zu generieren. Glücklicherweise verursachen Compiler beim Mischen von Code mit const und ohne const viel Rauschen, sodass Sie die Skripte sofort bemerken und konfigurieren können, um meinen Anti- const Code hinzuzufügen.

Ein direkter Vergleich der kompilierten Codes ist nicht sinnvoll, da eine kleine Änderung das gesamte Speicherschema beeinflussen kann, was zu einer Änderung der Zeiger und Funktionsaufrufe im gesamten Code führt. Daher habe ich eine zerlegte Besetzung ( objdump -d libSQLite3.so.0.8.6 ) als Größe der Binärdatei und des mnemonischen Namens jeder Anweisung verwendet. Zum Beispiel diese Funktion:

 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) 

Wird zu:

 SQLite3_blob_read 7lea 5jmpq 4nopl 

Beim Kompilieren habe ich die SQLite-Assemblyeinstellungen nicht geändert.

Kompilierte Code-Analyse


Für libSQLite3.so belegte die Version mit const 4.740.704 Bytes, ungefähr 0,1% mehr als die Version ohne const mit 4.736.712 Bytes. In beiden Fällen wurden 1374 Funktionen exportiert (ohne die Hilfsfunktionen auf niedriger Ebene im PLT), und 13 wiesen Unterschiede in den Besetzungen auf.

Einige Änderungen betrafen den Vorverarbeitungs-Hack. Hier ist zum Beispiel eine der geänderten Funktionen (ich habe einige für SQLite spezifische Definitionen entfernt):

 #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; } } 

Wenn wir const entfernen, werden diese Konstanten zu static Variablen. Ich verstehe nicht, warum jeder, der sich nicht für const interessiert, diese Variablen static machen const . Wenn wir sowohl static als auch const entfernen, betrachtet GCC sie erneut als Konstanten und wir erhalten das gleiche Ergebnis. Aufgrund solcher static const Variablen erwiesen sich Änderungen in drei von dreizehn Funktionen als falsch, aber ich habe sie nicht behoben.

SQLite verwendet viele globale Variablen, und die meisten echten Konstantenoptimierungen hängen damit zusammen: Wie das Ersetzen eines Vergleichs durch eine Variable durch einen Vergleich mit einer Konstanten oder das teilweise Zurückrollen der Schleife um einen Schritt (um zu verstehen, welche Art von Optimierungen vorgenommen wurden, habe ich Radare verwendet ). Einige Änderungen sind nicht erwähnenswert. SQLite3ParseUri() enthält 487 Anweisungen, aber const hat nur eine Änderung vorgenommen: Diese beiden Vergleiche wurden durchgeführt:

 test %al, %al je <SQLite3ParseUri+0x717> cmp $0x23, %al je <SQLite3ParseUri+0x717> 

Und getauscht:

 cmp $0x23, %al je <SQLite3ParseUri+0x717> test %al, %al je <SQLite3ParseUri+0x717> 

Benchmarks


SQLite wird mit einem Regressionstest geliefert, um die Leistung zu messen. Ich habe ihn hunderte Male für jede Version des Codes unter Verwendung der Standard-SQLite-Build-Einstellungen ausgeführt. Ausführungszeit in Sekunden:

const
Ohne const
Minimum
10.658
10.803
Median
11.571
11.519
Maximum
11.832
11.658
Durchschnitt
11.531
11.492

Persönlich sehe ich keinen großen Unterschied. Ich habe const aus dem gesamten Programm entfernt. Wenn es also einen merklichen Unterschied gab, war es leicht zu bemerken. Wenn Ihnen die Leistung jedoch extrem wichtig ist, kann Ihnen auch eine winzige Beschleunigung gefallen. Lassen Sie uns eine statistische Analyse durchführen.

Ich verwende gerne den Mann-Whitney-U-Test für solche Aufgaben. Er ähnelt dem bekannteren t-Test, mit dem Unterschiede in Gruppen ermittelt werden sollen, ist jedoch widerstandsfähiger gegen komplexe zufällige Abweichungen, die bei der Zeitmessung auf Computern auftreten (aufgrund unvorhersehbarer Kontextwechsel, Fehler in) Gedächtnisseiten usw.). Hier ist das Ergebnis:

constOhne const
N.100100
Mittlere Kategorie (mittlerer Rang)121,3879,62
Mann-Whitney u2912
Z.-5.10
2-seitiger p-Wert<10 -6
Der durchschnittliche Unterschied beträgt HL
-0,056 s.
95-Prozent-Konfidenzintervall
-0,077 ... -0,038 s.

Test U ergab einen statistisch signifikanten Leistungsunterschied. Aber - eine Überraschung! - Die Version ohne const erwies sich um etwa 60 ms, dh um 0,5%, als schneller. Es scheint, dass die geringe Anzahl der vorgenommenen „Optimierungen“ die Erhöhung der Codemenge nicht wert war. Es ist unwahrscheinlich, dass const wichtige Optimierungen wie die automatische Vektorisierung aktiviert const . Natürlich kann Ihr Kilometerstand von verschiedenen Flags im Compiler oder von seiner Version oder von der Codebasis oder von etwas anderem abhängen. Aber es scheint mir ehrlich zu sein, dass ich dies nicht bemerkt habe, auch wenn const die Leistung von C verbessert hat.

Wofür wird const benötigt?


Trotz aller Mängel ist const in C / C ++ nützlich, um die Typensicherheit zu gewährleisten. Insbesondere wenn Sie const in Kombination mit Verschiebungssemantik und std::unique_pointer , können Sie den expliziten Zeigerbesitz implementieren. Die Unsicherheit des Zeigerbesitzes war ein großes Problem in älteren C ++ - Codebasen über 100 KLOC, daher bin ich const dankbar, dass er es gelöst hat.

Bevor ich jedoch über die Verwendung von const hinausging, um die const zu gewährleisten. Ich habe gehört, dass es als richtig angesehen wurde, const so aktiv wie möglich zu verwenden, um die Leistung zu verbessern. Ich habe gehört, dass man, wenn die Leistung wirklich wichtig ist, den Code umgestalten musste, um mehr const hinzuzufügen, selbst wenn der Code weniger lesbar wurde. Es klang damals vernünftig, aber seitdem wurde mir klar, dass dies nicht stimmte.

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


All Articles