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;
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()
:
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:
- Mit wenigen Ausnahmen ist der Compiler gezwungen, dies zu ignorieren, da einige Codes die Konstante legal lösen können.
- 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) {
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:
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:
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.