In diesem Artikel wird подробно erläutert, wie sich die Calling Convention der Sprache Rust verbessern ließe.
Probleme mit Rusts aktueller Calling Convention
- Rust hat derzeit keine klar definierte Calling Convention
- Tatsächlich wird die standardmäßige C-Calling-Convention von LLVM verwendet
- Rust versucht derzeit konservativ LLVM-Funktionssignaturen zu erzeugen, wie sie auch Clang erzeugen würde
- für die Kompatibilität mit Debuggern
- um LLVM-Bugs zu vermeiden
- Das ist jedoch zu konservativ, sodass selbst für einfache Funktionen schlechter Code erzeugt wird
fn extract(arr: [i32; 3]) -> i32 { arr[1] }
- Der obige Code sollte über Register übergeben werden, wird aber per Pointer übergeben
- Rust ist konservativer als das C-ABI. Wenn
extern "C"angegeben wird, erfolgt die Übergabe über Register.
Vorschlag für eine neue Calling Convention
- Für
extern "Rust"-Funktionen bleibt die bestehende Calling Convention erhalten - Es wird ein Flag
-Zcallconvhinzugefügt, mit dem sich die Calling Convention vonextern "Rust"-Funktionen festlegen lässt-Zcallconv=legacyist die aktuelle Vorgehensweise-Zcallconv=fastist die neu zu entwerfende Vorgehensweise
- Warum sollte die bestehende Calling Convention beibehalten werden?
- Zur einfacheren Fehlersuche wird nicht in C-ABI-Reihenfolge angeordnet
- Einige Targets wie WASM könnten nicht unterstützt werden
- In Debug-Builds könnte es bedeutungslos sein
- Hinweise zu Funktionszeigern und
extern "Rust" {}-Blöcken- Da es sich um ein Crate-weites Flag handelt, kann es nicht auf Funktionszeiger angewendet werden
- Aufrufe über Funktionszeiger sind langsam und selten, daher wird
-Zcallconv=legacyverwendet - Falls nötig, wird ein Shim erzeugt, um die Calling Convention umzuwandeln
- Bei direktem Aufruf wie
extern "Rust" { fn my_func() -> i32; }- Es können nur nicht mangelte Symbole aufgerufen werden
#[no_mangle]-Funktionen verwenden die bestehende Calling Convention
Nutzung von LLVM
- Ideal wäre es, die Calling Convention direkt in LLVM angeben zu können, praktisch ist das jedoch schwierig
- Man kann das mit folgendem Verfahren umgehen
- Für das jeweilige Target wird die maximale Anzahl an Werten ermittelt, die über Register übergeben werden können
- Es wird entschieden, wie Rückgabewerte übergeben werden. Wenn sie in Register passen, direkt, andernfalls per Referenz
- Unter den per Wert übergebenen Argumenten wird ausgewählt, welche per Referenz übergeben werden müssen
- solche, die größer sind als der über Register übergebene verfügbare Platz
- auf x86 etwa 176 Byte
- Um den Registerraum maximal auszunutzen, wird festgelegt, welche Argumente per Register übergeben werden
- Das ist ein NP-schweres Problem, daher sind Heuristiken nötig
- der Rest wird über den Stack übergeben
- LLVM-IR-Funktionssignaturen werden erzeugt
- über Register übergebene Argumente werden als nicht aggregierte Typen wie i64, ptr, double, <2 x i64> usw. dargestellt
- über den Stack übergebene Argumente folgen dem „Register-Eingabe“-Schema
- Ein Funktionsprolog wird erzeugt
- Rust-seitige Argumente werden aus den Register-Eingaben dekodiert, sodass dieselben
%ssa-Werte entstehen wie bei-Zcallconv=legacy - Für den Funktionskörper kann unabhängig von der Calling Convention derselbe Code erzeugt werden
- Unnötiger Dekodierungscode wird durch DCE entfernt
- Rust-seitige Argumente werden aus den Register-Eingaben dekodiert, sodass dieselben
- Ein Rückgabeblock wird erzeugt
- enthält phi-Anweisungen für denselben Rückgabetyp wie bei
-Zcallconv=legacy - kodiert in das benötigte Ausgabeformat und gibt per
retzurück - statt
retmuss zu diesem Block verzweigt werden
- enthält phi-Anweisungen für denselben Rückgabetyp wie bei
- Falls es nicht polymorphe, nicht inlinebare Funktionen gibt, die als Funktionszeiger verwendet werden könnten
- wenn sie außerhalb der Crate sichtbar sind oder als Funktionszeiger weitergegeben werden
- wird ein Shim mit
-Zcallconv=legacyerzeugt, der die eigentliche Implementierung per Tail Call aufruft - das ist nötig, um die Gleichheit von Funktionszeigern zu erhalten
Wie sich die Grenzen der Registerübergabe in LLVM ermitteln lassen
- Ein LLVM-Programm zur Ermittlung der maximal zulässigen Anzahl an Registerübergaben in LLVM
- Auf x86 sind 6 Ganzzahlen und 8 SSE-Vektoren als Eingabe sowie 3 Ganzzahlen und 4 SSE-Vektoren als Ausgabe möglich
- Auf aarch64 sind Eingabe und Ausgabe identisch: 8 Ganzzahlen und 8 Vektoren
- Alles darüber hinaus wird über den Stack übergeben
Behandlung von Structs und Enums in Rust
- Es wird angenommen, dass
rustcbereits auf grundlegende Aggregate und Unions zurückgeführt hat - Behandlung von Rückgabewerten
- Entscheidend ist nicht die Größe des Structs, sondern die tatsächliche Datengröße ohne Padding
[(u64, u32); 2]ist 32 Byte groß, aber ohne 8 Byte Padding sind es 24 Byte- Es wird die effektive Größe des Typs definiert
- die Anzahl undefinierter Bits ohne Padding
[(u64, u32); 2]hat 192 Bitboolhat 1 Bit
- Wenn die effektive Größe kleiner ist als der Platz in den Ausgaberegistern, wird per Wert zurückgegeben
- Auf x86 entsprechen 3 Ganzzahlen + 4 SSE = 88 Byte = 704 Bit
- Behandlung von Argumentregistern
- Ein Knapsack-Problem, also NP-schwer
- Einfache Heuristik
- Wenn die effektive Größe größer ist als der gesamte Eingaberegisterraum, wird per Referenz übergeben
- Enums werden durch ein Paar aus Discriminant und Union ersetzt
- Unions können uninitialisierte Bits berühren, daher werden sie als
u8-Array oder als eine einzelne nichtleere Variante übergeben - Es wird auf die grundlegendsten Elemente wie Pointer, Ganzzahlen, Fließkommazahlen, Boolesche Werte usw. abgeflacht
- Sortierung aufsteigend nach effektiver Größe
- Ein möglichst großes Präfix wird den Registern zugewiesen, der Rest dem Stack
- Wenn ein Teil der Stack-Eingaben größer ist als ein kleines Vielfaches der Pointer-Größe, wird er per Pointer auf dem Stack übergeben
- der Rest wird in der Reihenfolge vor der Sortierung direkt über den Stack übergeben
- Register-Übergaben werden in absteigender Größenreihenfolge zugewiesen
- Boolesche Werte werden jeweils zu 64 Stück bitgepackt
Meinung von GN+
- Persönlich finde ich Rusts aktuelle Calling Convention sehr enttäuschend. Es könnte deutlich bessere Performance als C++ liefern, tut das aber noch nicht
- Die Sprache Go hat so etwas bereits vor langer Zeit umgesetzt
- Warum Rust das nicht einführt
- ABI-Codegenerierung ist komplex und LLVM hilft dabei kaum
- Im Compiler-Team gibt es nicht viele Leute mit tiefem LLVM-Wissen
- Es gibt Bedenken bezüglich der Compile-Zeit, aber da es nur in optimierten Builds genutzt würde, ist das kein großes Problem
- Der Autor hat selbst keine Zeit, es direkt zu beheben, wäre aber auf Basis seiner LLVM-Expertise bereit, dem Rust-Compiler-Team zu helfen
- Alternativ könnte auch ein einfacher Wechsel zu
extern "C"oderextern "fastcall"in Betracht kommen
1 Kommentare
Hacker-News-Kommentare
Zusammenfassung:
Option<u8>ist in Rust 16 Byte groß, in C 9 Byte.&Option<T>oder&mut Option<T>abbilden.Cargo.tomlvermeiden. Felder nach Größe zu sortieren ist eine einfache Optimierung und kann überreprdeaktiviert werden.