Trampolin-Springen
« | 26 Feb 2023 | »Bei meinem Rust-Kurs hat mich eine Aussage während des Vortrages gestört:
C++ erzeugt oft schlechten Maschinen-Code mit Trampolines.
Das ist etwas weit hergeholt.
Sprungbretter damals und heute
Wer unter DOS mit
X86 16 bit Assembler
gearbeitet hat, der kannte das Problem, dass “Conditional-Jumps”
(also IF Sprünge) nur +/- 127 Bytes weit reisen konnten.
Oft erhielt man die Fehlermeldung, dass das Ziel für den Sprung zu
weit entfernt war.
Jetzt konnte man entweder selbst mitten im Code eine “Zwischensprungstation” einrichten, die zuerst angesprungen wurde um von dort zum richtigen Ziel weiterzuspringen … oder man ließ diese Arbeit dem Assembler selbst per Direktive erledigen.
Dank 32 (und mehr)-Bit Codes sind diese Trampoline weitgehend ausgestorben.
C++ generiert heute jedoch manchmal eigene Hilfsfunktionen, die sich ebenfalls Trampoline nennen, die zuerst “angesprungen” werden und zum eigentlichen Ziel umleiten.
Warum passiert das?
Funktoren
Funktoren und std::function Bindings sind solche “gewollten” Umschlagplätze. Schließlich kapselt man hier einen Funktionsaufruf bewusst in ein “Interface” um es später generisch aufrufen zu können.
void(int,char)) F[[function: void target(int,char)]] O[[method: void obj::target(int,char)]] L[[lambda: [](int,char)]] STD --trampoline
C call--> F STD --trampoline
this call--> O STD --trampoline
lambda call/inline--> L
Mehrfachvererbung mit virtuellen Methoden
Wenn eine Klasse von einer Basisklasse erbt, dann setzt C++ bei der neuen
Klasse die Basis an den Anfang und fügt dahinter die neuen Elemente an.
Das gilt auch für die V-Table
und man kann immer sagen, dass ein this
Pointer gleich dem base-class
Pointer ist.
Erbt eine Klasse aber von zwei Basen, dann hat die zweite Basis, das Problem,
dass sie nicht am “Anfang” der neuen Klasse stehen kann, weil dort schon die
erste steht.
Doch ein von außen kommender Aufruf über einen Pointer zur zweiten Basis
würde eben auf den B-Teil von C zeigen.
Hier generiert jetzt der Compiler das B-Interface, das zum C-Interface
weiterspringt, wo aber vorher noch der this
Pointer angepasst wird, um
wieder auf C zu zeigen.
Und das kann man leider auf der Ebene nicht besser machen.
1struct A 2{ 3 virtual void foo() { } 4 int a; 5}; 6struct B 7{ 8 virtual void bar() { } 9 int b; 10}; 11struct C : A, B 12{ 13 virtual void foo() override { } 14 virtual void bar() override { } 15}; 16 17int main() 18{ 19 C c; 20 C* ptr_c = &c; 21 22 A* ptr_a = static_cast<A*>(ptr_c); 23 // result: ptr_a == ptr_c 24 25 B* ptr_b = static_cast<B*>(ptr_c); 26 // result: ptr_b == ptr_c + sizeof(A) 27 28 ptr_a->foo(); 29 // no trampoline required, directly call C::foo 30 31 ptr_b->bar(); 32 // invokes trampoline, this_ptr = ptr_b - sizeof(A) 33 34 return 0; 35}
trampoline/] end Cfoo --> Aimpl Cbar --> Bimpl Afoo --direct call
A* == C*---> Cfoo Bbar --call
B* --> T --new C*
pointer--> Cbar
Optimierungen des Compilers
Es ist aber ein Irrtum zu glauben, dass wir deshalb immer über Trampolines zur Implementierung springen, denn wenn wir mal beim richtigen Interface-Pointer sind, springen wir stets zum endgültigen Ziel ohne Umwege.
Aus diesem Grund wird Vererbung in C++ immer häufiger über Templates gelöst und virtuelle Funktionen samt Trampolines kommen nicht mehr zum Einsatz.
Zusätzlich haben die Compiler immer weitere Verbesserungen erhalten,
um Typeninformationen an Variablen zu binden.
Selbst wenn jemand ein B* ptr = &c
ausführt und danach B
Aufrufe
macht, kann der Compiler sich merken, dass es in Wahrheit ein C*
ist
und statisch das richtige Ziel ohne Trampolin aufrufen.
Ähnliches gilt für Dispatcher in Functor und Lambda Codes.
Fazit
Ja, Trampoline existieren in C++ als ABI-Sicherheitsgarantie, und der Compiler mag sie als 10-Byte große Bröckchen einstreuen, doch das bedeutet nicht, dass sie immer genutzt werden.
Rust kennt keine Mehrfachvererbung und kompiliert alle Codes statisch, womit das höchste Maß an Optimierung möglich ist. Gleiches passiert, wenn man in C++ ebenso vorgeht. Man kann daher nicht behaupten, Rust wäre hier von Grund auf besser bei der Codegenerierung.
Einen Punkt muss man allerdings Rust zuschreiben: C++ nutzt virtuelle
Methoden leider schon häufig in Klassen der Standard-Bibliothek wie
iostreams
und das bedeutet, dass es hier schon zu Trampolines kommen
kann (nicht muss!).
In Rust hingegen sind alle Aufrufe direkt, bis man explizit dyn
einsetzt, was von Grund auf Trampoline-frei ist.
Der Nachteil ist jedoch: Wenn man in Rust z.B.
COM Interfaces
implementieren will oder muss, dann muss man das über unsafe-C-Codes selbst
machen …
und das heißt, man darf sich dann quasi Trampolines selbst zusammenschrauben.
Und auf der Ebene verlasse ich mich lieber auf Compiler-generierte
Trampolines mit best möglicher Optimierung.
Also bitte keine Anschuldigungen, wenn man Worst-Cases von Sprache A mit Best-Cases von Sprache B vergleicht. Ich weiß, das macht man im Marketing häufig … seriös ist das aber nicht.