Trampolin-Springen

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.

flowchart LR STD(std::function
void&#40int,char&#41) F[[function: void target&#40int,char&#41]] O[[method: void obj::target&#40int,char&#41]] L[[lambda: &#91&#93&#40int,char&#41]] 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}
flowchart TB subgraph class C : public A, B Aimpl(C impl of foo) Bimpl(C impl of bar) subgraph V-Table class A Afoo[[A::foo]] end subgraph V-Table class B Bbar[[B::bar]] end subgraph V-Table class C Cfoo[[C::foo]] Cbar[[C::bar]] end T[/B to C
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.