2 Punkte von GN⁺ 2024-04-20 | 1 Kommentare | Auf WhatsApp teilen

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 -Zcallconv hinzugefügt, mit dem sich die Calling Convention von extern "Rust"-Funktionen festlegen lässt
    • -Zcallconv=legacy ist die aktuelle Vorgehensweise
    • -Zcallconv=fast ist 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=legacy verwendet
    • 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
    • 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 ret zurück
      • statt ret muss zu diesem Block verzweigt werden
    • 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=legacy erzeugt, 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 rustc bereits 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 Bit
      • bool hat 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" oder extern "fastcall" in Betracht kommen

1 Kommentare

 
GN⁺ 2024-04-20
Hacker-News-Kommentare

Zusammenfassung:

  • Beim Entwerfen einer optimierten Calling Convention ist es wichtig, die Leistung direkt zu messen. Code, der seltsam aussieht, kann in der Praxis tatsächlich am schnellsten sein.
  • Heutige CPUs optimieren die von C-Compilern erzeugten Instruction-Traces, daher kann es hilfreich sein, wie ein C-Compiler häufig über den Stack zu übergeben.
  • Inlining ist so erfolgreich, dass Funktionsaufrufe nur noch seltene Grenzen darstellen; deshalb kann man an diesen Grenzen etwas Unregelmäßigkeit zulassen, um anderes zu vereinfachen.
  • Rust-Structs müssen Referenzen auf ihre Felder bereitstellen können und können daher größer sein als in C. Eine Struct mit 8 Feldern vom Typ Option<u8> ist in Rust 16 Byte groß, in C 9 Byte.
  • In Rust kann man eine manuelle Implementierung bauen, die C entspricht, aber sie lässt sich nicht auf &Option<T> oder &mut Option<T> abbilden.
  • Rust hat noch keine Calling Convention für Semantik auf Rust-Ebene. Apple hatte einen Anreiz, so etwas aufzubauen, aber für Rust gibt es keine solche Unterstützung.
  • Interoperabilität zwischen Go und Rust lässt sich derzeit erreichen, indem Zig als Zwischenschicht verwendet wird.
  • Der aktuelle Rust-Compiler führt aggressives Inlining und Optimierungen durch, daher ist fraglich, ob es sich lohnt, dieses Problem zu lösen.
  • Für das Debugging kann man Bedenken über Flags in Cargo.toml vermeiden. Felder nach Größe zu sortieren ist eine einfache Optimierung und kann über repr deaktiviert werden.