Dieser Tweet machte gerade die Runde.
Die Funktion bestimmt, ob ein Buchstabe Whitespace ist (Leerzeichen, Tab, Zeilenumbruch, Seitenumbruch etc).
Der Standardweg steckt hinter
#if BORING. Der andere Weg ist deutlich aufregender: Da die Zahlenwerte von Whitespace im ASCII-Code keine 32 Stellen auseinanderliegen, funktioniert man kurzerhand eine 32-Bit-Zahl zu einem 32-Bit-Lookup um. Ist ein Bit gesetzt, ist das ASCII-Zeichen an dieser Position (plus 1) Whitespace, sonst nicht.
Aufregender ist das, aber ist es auch
schneller?
Eine wörtliche Übersetzung vorausgesetzt: Absolut!
- In vielen textbasierten Formaten können Token durch Whitespace getrennt werden. Also wird so eine Prüfung hinter jedem Token benötigt!
- Gerade weil sich in textbasierten Formaten Leerzeichen und Tabs mit Zeilenumbrüchen abwechseln, ist das naive if Gift für die Sprungvorhersage.
- Anstelle einer langen Befehlskette haben wir eine kurze Kette aus Basisarithmetik und Shifts, die jeweils in einem Takt oder weniger abschließen.
Aber wir haben die Rechnung ohne den Compiler gemacht.
Schauen wir uns das Disassembly der naiven Funktion an – vereinfacht auf Leerzeichen, Tab, Carriage Return und Line Feed (damit ich nicht so viel tippen muss) und für x86-64.
Code: Alles auswählen
bool isSpace(char c) {
return c == ' ' || c == '\t' || c == '\n' || c == '\r';
}
Hier GCC -O2:
Code: Alles auswählen
isSpace(char):
cmp dil, 32
sete al
cmp dil, 9
sete dl
or al, dl
jne .L1
cmp dil, 10
sete al
cmp dil, 13
sete dl
or eax, edx
.L1:
ret
… da kommt überhaupt nur ein einziger Sprung vor! GCC führt zwar Vergleiche aus, kombiniert die Ergebnisse dann aber mit OR. Das ist
verdammt schnell. Messt selber.
Dann kommt Clang -O2:
Code: Alles auswählen
isSpace(char):
add dil, -9
cmp dil, 23
ja .LBB0_2
mov eax, 8388627
mov ecx, edi
shr eax, cl
and al, 1
ret
.LBB0_2:
xor eax, eax
ret
Wenn man das nach C++ rückübersetzt, sieht es so aus:
Code: Alles auswählen
auto c2 = static_cast<unsigned int>(c) - 23;
if(c2 > 23)
return fasle;
return static_cast<bool>((0x800013 >> c2) & 1);
Überflüssig zu sagen: Clang hat die langweilige Variante
automatisch in die aufregende Verwandelt! Nur mit leicht anderen Zahlen, weil es 9 abzieht statt 1 (so sind die Konstanten kleiner, was andere Vorteile mit sich bringt).
Nun Visual C++ /O2:
Code: Alles auswählen
bool isSpace(char) PROC
cmp cl, 32
ja SHORT $LN5@isSpace
mov rax, 0x100002600
bt rax, rcx
jae SHORT $LN5@isSpace
mov al, 1
ret 0
$LN5@isSpace:
xor al, al
ret 0
Das sieht aus wie die aufregende Variante (zumal die Konstante wieder darin vorkommt), aber dahinter steckt mehr.
Diese Version zieht allen anderen – auch dem „aufregenden“ Code – die Socken aus. Sie verwendet nämlich den
BT Befehl (
Bit Test), der Shift + AND + Vergleich in einen einzigen Befehl kombiniert. Der heiße Pfad ist hier – insbesondere in einer Schleife – zwei Takte kürzer. Gegen das hier hat auch der handgeschriebene Code keine Chance.
Zusammenfassung:
- GCC: ★☆☆
Bringt naiven Code fast auf das Niveau von handoptimiertem Code.
- Clang: ★★☆
Bringt naiven Code auf das Niveau von handoptimiertem Code.
- Visual C++: ★★★
Übertrifft sogar handoptimierten Code.
An dieser Stelle könnten wir aufhören.
Aber jetzt beginnt das Lernen erst so richtig!
Ich hatte die Funktion ja vereinfacht, so dass sie nicht auf Vertical Tab und Form Feed testet. Damit ich nicht so viel tippen muss. Wenn ich diese Prüfungen hinzufüge, dann müssten Clang und Visual C++ ja bloß zwei weitere Bits zu ihren Konstanten hinzufügen. Sonst bleibt alles gleich.
Oder?
Code: Alles auswählen
bool isSpace(char c) {
return c == ' ' || c == '\t' || c == '\v' || c == '\f' || c == '\n' || c == '\r';
}
Der olle GCC -O2:
Code: Alles auswählen
cmp dil, 32
ja .L2
movabs rdx, 4294973952
mov eax, 1
bt rdx, rdi
jnc .L2
ret
.L2:
cmp dil, 10
sete al
cmp dil, 13
sete dl
or eax, edx
ret
Was ist denn
da passiert?! GCC nutzt nun ebenfalls den heißen
BT-Befehl – aber verkorkst alles drumherum so dermaßen, dass ich gar nicht weiß, wo ich anfangen soll.
Clang -O2:
Code: Alles auswählen
sSpace(char):
add dil, -9
cmp dil, 23
ja .LBB0_2
mov eax, 8388639
mov ecx, edi
shr eax, cl
and al, 1
ret
.LBB0_2:
xor eax, eax
ret
Clang hat
tatsächlich nur zwei neue Bits hinzugefügt. Aber sein Code bleibt weiterhin um ein Vielfaches langsamer als Visual C++’s vorheriger.
Apropos Visual C++:
Code: Alles auswählen
bool isSpace(char) PROC
cmp cl, 32
je SHORT $LN3@isSpace
sub cl, 9
cmp cl, 4
jbe SHORT $LN3@isSpace
xor al, al
ret 0
$LN3@isSpace:
mov al, 1
ret 0
Visual C++ ist plötzlich dumm geworden und optimiert zu
… keine Spur mehr von
BT.
Quintessenz
Compiler sind nicht „schlau“. Sie führen keine „schlauen“ Optimierungen durch. Was sie tun ist lediglich Mustererkennung: Ein Programmierer sieht, dass der Compiler schlechten Code für
isSpace erzeugt. Also programmiert er in den Compiler ein:
„Wenn das Muster auftritt, dass eine Variable gegen vier Werte verglichen wird, und diese vier Werte sind Leerzeichen/Tab/Zeilenumbruch/Zeilenvorschub, dann lösche den Ausdruck und ersetze ihn durch <cleverste Befehlsfolge, die dem Entwickler gerade einfällt>.“
Die Tabellen für Mustererkennung nehmen absurde Ausmaße an. Durchsucht den Quelltext eures Compilers mal nach
cos – da müsstet ihr auf Hunderte Muster à „
ersetze cos(0) durch 1.0“ treffen.
Darum würde ich die Bewertung nun eher so vergeben:
- GCC: ☆☆☆
Hat ein paar Optimierungen einprogrammiert, aber sobald man eine Kleinigkeit ändert, geht der Code richtig in die Brüche.
- Visual C++: ★☆☆
Wie GCC, nur dass hier der Entwickler schlau genug war, direkt BT hardzucoden.
- Clang: ★★☆
Produziert zwar nie optimalen Code, aber zumindest scheint hier ein halbwegs variables Regelwerk einprogrammiert worden sein anstelle starrer Muster. … oder doch nicht?
Nun wäre noch zwei Fragen zu klären:
Ist die handoptimierte Version besser als die automatisch optimierte?
Sie liefert auf allen Compilern vergleichbare Leistung. Sie ist nie optimal, aber auch nie katastrophal langsam.
Kann man auf allen Compilern händisch die optimale Leistung erreichen?
Auf GCC nicht. Das erzeugt kein
BT.
Auf allen anderen führt das hier zu quasi-optimalem Code:
Code: Alles auswählen
bool isSpace(char c) {
auto const u = static_cast<unsigned>(c);
return (u <= 63) & (0 != (0x100003E00 & (uint64_t(1) << u)));
}
Code: Alles auswählen
cmp dil, 64
setb cl
movabs rax, 4294983168
bt rax, rdi
setb al
and al, cl
ret
(
https://godbolt.org/z/TfssZ8)
… aber stattdessen solltet ihr den Compiler-Entwickler eures Vertrauens fragen, ob er das nicht direkt in den Compiler hardcoden möchte.