2 Punkte von GN⁺ 2024-04-20 | 1 Kommentare | Auf WhatsApp teilen
  • Rusts aktuelle Calling-Convention extern "Rust" stützt sich auf LLVMs C-Calling-Convention-Pfad und nutzt bei der Übergabe komplexer Werte Register zu konservativ, wodurch bessere Codegenerierung verpasst wird
  • Zentral ist die Idee, per Crate-weitem Flag -Zcallconv zwischen dem aktuellen Ansatz legacy und einem neuen registerzentrierten Ansatz fast zu unterscheiden und in optimierten Builds eine aggressivere ABI zu verwenden
  • Auch ohne LLVM direkt eine neue Calling-Convention hinzuzufügen, lässt sich die Platzierung von Argumenten steuern, indem man feste LLVM-Funktionssignaturen und poison-Werte verwendet, um ungenutzte Registerargumente kostenlos leer zu lassen
  • Rust-Typen wie Structs, Enums, Unions, bool und Result können anhand von effektiver Größe ohne Padding, Flattening, Bit-Packing und Heuristiken zur Aufteilung auf Stack und Register dichter übergeben werden
  • Wenn Funktionskörper, Borrow-Checker-Informationen und Profildaten in ABI-Entscheidungen einfließen, sind noch stärkere Optimierungen möglich, aber die Komplexität der ABI-Codegenerierung in rustc und fehlende LLVM-Expertise bleiben praktische Hürden

Calling-Convention-Optimierungen, die Rust derzeit verpasst

  • Die Calling Convention ist der Teil der ABI, der festlegt, wie Funktionsargumente und Rückgabewerte übergeben werden, welche Register verwendet werden und wie Prolog/Epilog sowie Unwinding behandelt werden
  • Rust definiert zwar eine eigene unspezifizierte Calling Convention, tatsächlich wird diese aber auf LLVMs eingebaute C-Calling-Convention abgesenkt und verlässt sich auf LLVM für die Generierung von Prolog und Epilog
  • rustc verhält sich konservativ, um LLVM-Funktionssignaturen zu erzeugen, wie sie auch Clang erzeugen könnte
    • Das kann die Wahrscheinlichkeit verringern, dass Debugger kaputtgehen
    • Es kann auch die Wahrscheinlichkeit senken, LLVM-Bugs in ABI-Codegenerierungspfaden auszulösen, die Clang kaum nutzt
  • Auf ELF-basierten Systemen schreibt DWARF die Linux-C-ABI nicht fest fest, daher wird die Debuggbarkeit im Rahmen des Artikels nicht als Kernproblem betrachtet
  • Ein einfaches Beispiel ist fn extract(arr: [i32; 3]) -> i32, bei dem ein 12-Byte-Array nicht in Registern, sondern über einen Pointer übergeben wird
    • Mit extern "C" wird dasselbe [i32; 3] gepackt in rdi und rsi übergeben
    • Das ist ein Fall, in dem Rusts Standardpfad noch konservativer ist als die Linux-C-ABI

-Zcallconv: Trennung zwischen legacy und fast

  • Die aktuelle Calling Convention von extern "Rust" bleibt erhalten, aber über das Crate-Kompilierungsflag -Zcallconv wird ausgewählt, welche Calling Convention verwendet wird
    • -Zcallconv=legacy: aktueller Ansatz
    • -Zcallconv=fast: neuer, zu entwerfender registerzentrierter Ansatz
    • -O könnte automatisch -Zcallconv=fast setzen
  • Die Calling Convention fast platziert Argumente nicht in C-ABI-Reihenfolge, was für Menschen verwirrend sein kann, die bei x86 die übliche Registerreihenfolge erwarten
  • Auf Targets wie WASM, die keine Konzepte von Registern und Spilling haben, wird -Zcallconv=fast womöglich nicht unterstützt
  • In Debug-Builds ohne Optimierung kann fast schlechteren Code erzeugen, daher ist eine Aktivierung dort womöglich nicht sinnvoll
  • Für Funktionspointer und extern "Rust" {}-Blöcke sind gesonderte Einschränkungen nötig
    • Das Flag gilt Crate-weit, aber bei Funktionszeigern lässt sich schwer ausdrücken, welche extern "Rust"-Variante sie verwenden
    • Aufrufe über Funktionspointer können als langsamer und seltener Pfad betrachtet werden und -Zcallconv=legacy erzwingen
    • Falls nötig, werden Shims erzeugt, die zwischen Calling Conventions umwandeln
    • Wegen Pfaden, die unmangled Symbole aufrufen können, könnten auch #[no_mangle]-Symbole die Legacy-Calling-Convention verwenden

LLVM indirekt steuern

  • Ideal wäre es, LLVM die Calling Convention direkt vorzugeben, etwa im Sinne von „dieses Argument in dieses Register, dieser Rückgabewert in jenes Register“, aber das Hinzufügen einer Calling Convention in LLVM erfordert viel C++-Code
  • Stattdessen kann man mit folgendem Vorgehen einen ähnlichen Effekt wie mit einer eigenen Calling Convention erzielen
    • Pro Target-Triple wird die maximale Anzahl von Werten bestimmt, die in Registern übergeben werden können
    • Es wird entschieden, ob der Rückgabewert in Ausgaberegistern liegt oder per zusätzlichem ptr-Argument mit sret-Attribut by-reference zurückgegeben werden muss
    • Zu große by-value-Argumente werden auf by-reference abgesenkt
    • Es wird festgelegt, welche Argumente in Register gehen, um die Registerraumauslastung zu maximieren
    • Die übrigen Argumente werden auf den Stack gelegt
    • Die LLVM-IR-Funktionssignatur besteht aus nichtaggregierten Argumenten wie i64, ptr, double oder <2 x i64>
    • Im Funktionsprolog werden die Registereingaben in Argumente auf Rust-Ebene dekodiert
    • Im Exit-Block der Funktion wird der Rückgabewert in das nötige Ausgabeformat kodiert und dann ret ausgeführt
    • Für non-polymorphe, nicht-inline Funktionen, deren Adresse genommen werden kann, werden Legacy-Shims erzeugt, um die Identität von Funktionszeigern zu bewahren
  • Das Problem zu entscheiden, welche Werte in Register kommen, entspricht dem Rucksackproblem (knapsack problem) und ist NP-schwer; eine reale Implementierung braucht daher Heuristiken
  • Diese Information sollte nicht zu spät berechnet werden, sondern in rmeta abgelegt werden, um Neuberechnung zu vermeiden
  • Da Rusts ABI zwischen Releases bricht, passt die Bedingung, dass Code aus verschiedenen Rust-Compiler-Versionen nicht miteinander gelinkt werden soll, bereits zur heutigen Situation

Grenzen der Registerübergabe in LLVM

  • LLVM versucht, aggregate by-value-Argumente bei der Übergabe an eine Funktion so weit wie möglich in Register zu „explodieren“
  • Auf x86 kann LLVM ungefähr Folgendes in Registern als Eingaben übergeben
    • 6 Ganzzahlen
    • 8 SSE-Vektoren
    • Für Rückgaben die Hälfte davon: 3 Ganzzahlen und 4 Vektoren
  • Auf aarch64-unknown-linux sind für Eingaben und Ausgaben jeweils 8 Ganzzahlen und 8 Vektoren möglich
  • Man kann alle -Zcallconv=fast-Funktionen auf x86 so entwerfen, dass sie dieselbe Anzahl an by-register-Argumenten haben
    • 6 Argumente für Integer-Register
    • 8 Vektorargumente von xmm0 bis xmm7
    • Bei tatsächlicher Pointer-Übergabe wird das jeweilige i64 zu ptr
    • Bei double ersetzt dieses einen <2 x i64>-Slot
  • Auch wenn die meisten Funktionen keine 176 Byte übertragen, lassen sich zusätzliche Kosten vermeiden, indem für ungenutzte Argumente LLVM-poison übergeben wird
    • LLVM darf poison als gerade bequemsten Wert behandeln
    • Wird poison an ein Registerargument übergeben, kann LLVM das als „Wert, der bereits in diesem Register lag“ behandeln, sodass das Register nicht angerührt werden muss
    • Im Beispiel erzeugt Code, bei dem load_rcx() einen Pointer in rcx erhält und die übrigen 13 Register mit poison belegt werden, nach Optimierung überhaupt keinen zusätzlichen Code
  • Dieser Ansatz erlaubt fast vollständige Kontrolle über die Argumentübergabe, aber die ideale Situation, in der Ein- und Ausgabe dieselben Register nutzen, ist je nach Architektur verschieden
    • ARM und RISC-V liegen näher an einem Modell mit identischen Ein- und Ausgaberegistern
    • x86 nicht, aber durch andere Annahmen über die Registerallokationsreihenfolge lassen sich unnötige Registerverschiebungen reduzieren

Rust-Typen besser an Register anpassen

  • Beim Umgang mit Rust-Structs und -Unions wird angenommen, dass rustc Benutzertypen bereits in grundlegende Aggregate und Unions zerlegt hat, und anschließend wird entschieden, welche Teile in Register kommen
  • Bei Rückgabewerten ist nicht die Gesamtgröße des Structs entscheidend, sondern die effektive Größe ohne Padding
    • [(u64, u32); 2] ist insgesamt 32 Byte groß, davon sind 8 Byte Padding
    • Nach Flattening zu (u64, u32, u64, u32) und Sortierung nach Größe zu (u64, u64, u32, u32) ergibt sich eine Größe von 24 Byte
    • Das passt in x86 in 3 Integer-Rückgaberegister
  • Die effektive Größe wird als Anzahl nicht-undef-Bits definiert
    • [(u64, u32); 2] hat 192 Bit
    • bool hat 1 Bit
    • char hat technisch 21 Bit, wird zur Vereinfachung aber wie ein Alias für u32 behandelt
  • Structs mit vielen bool-Feldern können mehrere bool in einem Register per Bit-Packing zurückgeben
  • Auf der Argumentseite ist die Lage schwieriger; dafür lassen sich Heuristiken wie diese anwenden
    • Argumente, deren effektive Größe größer ist als der gesamte by-register-Eingaberaum, werden auf by-reference abgesenkt
    • Auf x86 beträgt der gesamte Eingaberaum 176 Byte bzw. 1408 Bit
    • Enums werden in ein Paar aus Discriminant und Union umgewandelt
      • Option<i32> lässt sich intern als (union { i32, () }, i1) betrachten
      • Option<Option<i32>> als (union { i32, (), () }, i2)
    • Unions können uninitialisierte Bits beliebig verändern und werden daher meist wie ein u8-Array übergeben
    • Eine Union mit nur einer nichtleeren Variante wird durch diese Variante ersetzt
    • Umgewandelte Argumente werden in Primitive wie Pointer, Integer, Float und Bool geflattet
    • Felder, die größer sind als ein kleines Argumentregister, wie u128 oder f64, können aufgespalten werden
    • Die Liste der Primitive wird nach effektiver Größe sortiert, und das größte Prefix, das in Register passt, wird ausgewählt
    • Der Rest wird auf den Stack gelegt
    • Ist der Teil für den Stack größer als ein kleines Vielfaches der Pointer-Größe, wird er zu pointer-on-the-stack abgesenkt, um Speichertraffic zu reduzieren
    • Per Register übergebene Werte werden von groß nach klein platziert, und bool wird mit bis zu 64 Werten pro Register bitgepackt

Beispiel einer komplexen Rust-Funktion und aktuelle Grenzen von rustc

  • Im do_thing-Beispiel mit Option<usize>, &dyn Context, &str, [char; 6] und einer Options-Struct können nach Flattening und Sortierung alle rohen LLVM-Argumente in Register passen
  • Die rohen Argument-LLVM-Typen im Beispiel haben folgende Form
    • gprs: i64, ptr, ptr, ptr, i64, i32, i32
    • xmm0: i32, i32, i32, i32
    • xmm1: i32, i1, i1, i1, i1
  • Im Funktionsprolog werden die Primitive entnommen und wieder zu Werten auf Rust-Ebene zusammengesetzt
    • Option<usize> wird zu { i64, i1 }
    • Ein Trait-Object wird zu { ptr, ptr }
    • &str wird zu { ptr, i64 }
    • [char; 6] wird zu [6 x i32]
    • Options wird zu { i32, i1, i1, i1 }
  • Wenn man die Instruktionen, die Argumentwerte tatsächlich materialisieren, mit !dbg-Metadaten versieht, kann gdb beim Ausgeben von Argumentwerten bessere Ergebnisse liefern
  • Der aktuelle rustc übergibt an LLVM für dieselbe Funktion 8 pointergroße Parameter und nutzt dadurch zwar alle 6 Integer-Register, muss aber 2 Werte über den Stack übergeben

Rückgabewerte und Optimierungsspielraum bei Result

  • Dieses Design deckt nicht alle denkbaren Calling-Convention-Optimierungen ab
  • In manchen Fällen könnten zusätzliche Register wie AVX-Register auf x86 genutzt werden
  • Auch eine Übergabe von Structs teils in Registern, teils auf dem Stack, könnte erwogen werden
  • Für die Rückgabe von Result gibt es eigenen Optimierungsspielraum
    • Wenn ? über mehrere Funktionsebenen hinweg propagiert wird, kann es zu vielen redundanten Registerbewegungen kommen
    • Ist Result zu groß, um in Register zu passen, muss bei jedem ? im Call-Stack das ok-Bit aus dem Speicher geladen und geprüft werden
    • Als Alternative könnte der Fehler über einen Out-Parameter-Pointer laufen, während Payload der ok-Variante und is-ok-Bit als Option<T> zurückgegeben werden
    • Die Details von ?, wenn dabei auch Into aufgerufen wird, sind knifflig, aber umsetzbar

Optimierungsabhängige ABI

  • Anders als C kann Rust bei -Zcallconv=fast den Funktionskörper einsehen, wenn es die ABI erzeugt, die der Aufrufer sieht
  • Eine Crate kann für jede Funktion die exakte ABI aus Sicht der Registerübergabe bekanntgeben
  • Die einfachste Optimierung besteht darin, ungenutzte Argumente aus der ABI zu entfernen
    • Wenn eine Funktion keinen ihrer Parameter nutzt, werden dafür keine Register in der ABI belegt
  • Wenn ein Argument vom Typ &T nicht aufbewahrt wird, nicht in einen Raw Pointer umgewandelt wird, T klein ist und T: Freeze gilt, kann statt der Referenz direkt das Pointee by-value übergeben werden
  • APIs wie HashMap::get() sind Kandidaten dafür
    • Ist der Schlüssel ein Typ wie i32, muss derzeit die Ganzzahl auf den Stack spilled und ein Pointer darauf übergeben werden
    • Dieser Speichertraffic ließe sich vermeiden
  • Eine profilbasierte ABI wäre noch aggressiver
    • Häufigere Argumente könnten in der Registerzuweisungsreihenfolge priorisiert werden
    • Selbst wenn ein großes Struct per Referenz übergeben wird, könnte der Aufrufer 3 heiße i64-Felder vorab laden und sowohl per Pointer als auch per Register übergeben
    • Der Callee müsste diese Loads ohnehin durchführen und hätte daher keine Zusatzkosten
    • Ein Instrumentierungsprofil könnte sogar das Klonen einer Funktion rechtfertigen, die sich nur in der ABI unterscheidet

Warum das noch nicht umgesetzt ist

  • Rust hat im Vergleich zu C++ weniger ABI-Zwänge und könnte daher besseren Code erzeugen; die Idee steht auch in Nähe zur real eingesetzten Go register ABI
  • Das erste Hindernis ist die Komplexität der ABI-Codegenerierung
    • LLVM bietet kaum nützliche Stellschrauben zur direkten Kontrolle
    • Auch innerhalb von rustc ist das kein besonders zugänglicher Bereich
    • Eine falsche Implementierung kann die Nutzbarkeit deutlich verschlechtern
  • Ein weiteres Hindernis ist fehlende Expertise
    • Unter den rustc-Mitwirkenden gibt es nur wenige, die LLVM-Semantik und Codegen-Eigenschaften tief genug verstehen, um guten Code zu erzeugen und LLVM nicht zum Absturz zu bringen
  • Auch die Kompilierzeit kann ein Problem sein
    • Je komplexer Funktionssignaturen werden, desto mehr Prolog-/Epilog-Code muss LLVM verarbeiten
    • Da -Zcallconv aber nur bei aktivierter Optimierung gedacht ist, wird das nicht als entscheidender Nachteil gesehen
  • Rusts ABI-Code ist ein Bereich mit niedrigem Bus-Faktor, und LLVM-Wissen kann direkt dabei helfen, dass das Rust-Compiler-Team stärker optimierten Code erzeugt

1 Kommentare

 
GN⁺ 2024-04-20
Meinungen auf Hacker News
  • Der Kern bei der Optimierung von Calling Conventions ist nicht, sich im Kopf auszumalen, welche Form gut wirkt, sondern Performance zu messen.
    Code ist gut, wenn er schnell ist – nicht, wenn er schnell aussieht.
    Was der Autor als schlechten Code bezeichnet, kann aus völlig unintuitiven Gründen am schnellsten sein, und das findet man nur heraus, wenn man es in großen Benchmarks misst.
    Ein Grund, warum eine schlecht aussehende Calling Convention gut funktioniert, ist, dass sie Argument-Register schont und es dem Register-Allocator etwas leichter macht.
    Außerdem sind heutige CPUs auf den Befehlsfluss optimiert, den C-Compiler erzeugen; wenn man also Code im Stil eines C-Compilers erzeugt, der – insbesondere wie MSVC – überraschend oft über den Stack übergibt, kann man den Sweet Spot der CPU treffen.
    Da Inlining so gut funktioniert, werden Aufrufe auf heißen Pfaden zu seltenen Grenzen, und wenn diese Grenze etwas unordentlich ist, dafür aber andere Dinge einfacher macht, ist das in Ordnung.
    Das heißt nicht, dass die Änderung hier schlecht ist, aber nur anhand seltsam aussehenden Codes ohne Messungen zu diskutieren, ist seltsam.
    Ich habe beruflich Calling Conventions in JavaScriptCore optimiert, und in realem, großem Code gewann erstaunlich oft Stack-Passing-Code, der schlecht aussah.

    • Ich stimme stark zu, dass Code nicht immer tatsächlich schnell ist, nur weil er schnell aussieht.
      Allerdings sollten Messergebnisse zur Performance nicht das einzige Kriterium sein.
      In der Formulierung, dass „heutige“ CPUs optimiert seien, ist das wichtige Wort „heutige“; CPUs verändern sich ständig, daher sollte eine Calling Convention ein langfristiges Design sein.
      Deshalb ist es leider vorteilhaft, nicht zu weit von dem abzuweichen, was C++ tut. Zukünftige Prozessoroptimierungen werden sich wahrscheinlich ebenfalls daran orientieren.
      Gleichzeitig ist es gut, allgemeine Prinzipien zu berücksichtigen, die sich nicht so leicht ändern – etwa Argument-Register zu schonen –, um die Calling Convention robust und zukunftsorientiert zu machen.
      Es fühlt sich etwas seltsam an, das zu sagen, weil Rust in den letzten Jahren beim Budget für Seltsamkeit (https://steveklabnik.com/writing/the-language-strangeness-bu...) offenbar zu konservativ geworden ist. Letztlich kann man nicht besser werden, ohne anders zu sein.
    • Ob Register-Passing schneller ist, hängt auch vom Funktionskörper ab.
      Wenn eine Funktion gleich zu Beginn die Adresse eines Parameters nimmt und sie an eine unbekannte Funktion weitergibt, muss er ohnehin auf den Stack ausgelagert werden.
      Es wäre interessant, Optimierungen der Calling Convention auf Basis des Funktionskörpers zu sehen. Bei statischen Funktionen in C dürfte das sicher sein, solange ihre Adresse nicht genommen wird.
    • Diese Erfahrung lässt sich nicht vollständig übertragen.
      Ein JIT ist bei diesem Problem im Vorteil, weil er schon vor dem Erzeugen der ersten Assemblerzeile viele Informationen über die tatsächlich laufende CPU gesammelt hat.
      Bei rein statisch kompiliertem Code kennt man die zur Laufzeit verfügbare Architektur-Feature-Menge nicht, sodass man gerade in dem Code, den man am stärksten optimieren möchte, häufig auf Inlining-Barrieren trifft.
    • Zur Performance kann neben der Ausführungsgeschwindigkeit auch die Binärgröße gehören.
      Aktuell scheint Rust auf kleinen Plattformen in diesem Punkt schwach zu sein, und die Calling Convention könnte im Zusammenhang mit Result-Rückgaben helfen.
    • Der Originaltext handelt größtenteils von x86, und Intel hat über Jahrzehnte erstaunliche Ingenieursarbeit geleistet, damit hässlicher x86-Code auf dem eigenen Silizium, das die Leute kaufen, schnell läuft.
      Trotzdem frage ich mich, ob die empirischen Vorteile von Stack-Passing auch auf ARMV8-CPUs mit vielen Registern oder auf RISC-V weiterhin gelten.
  • Ein vernünftiger Entwurf, aber die Unterscheidung zwischen caller-saved und callee-saved fehlt, und er enthält den häufigen Fehler, einen Teil der Eingaberegister auch für Ausgaben zuzuweisen.
    Auch die Erwartung, dass Debugger eine andere Calling Convention als C verstehen, ist optimistisch. Was auch immer DWARF enkodieren kann: In der Praxis dürfte das krachend scheitern.
    Wenn man das ABI abhängig von Optimierungseinstellungen ändert, interagiert das sehr schlecht mit separater Kompilierung.
    Argumente wie beim Bin Packing umzusortieren, würde funktionieren, erhöht aber die Compiler-Komplexität deutlich; unklar ist, ob es gegenüber einer ersten passenden Platzierung von links nach rechts den Aufwand wert ist. Für Entwickler wird es außerdem schwerer vorherzusagen, wohin Argumente gehen.
    Die grobe Richtung, unterschiedliche Calling Conventions für Funktionen zu haben, deren Adresse entkommt, und solche, bei denen das nicht der Fall ist, ist sinnvoll. Auch der Ansatz, einen Prolog abzutrennen, der Impedanzanpassung betreibt, funktioniert gut.
    Rust sollte bereit sein, eine andere Calling Convention als C zu haben, aber ich weiß nicht, ob es eine einzige hartkodierte Convention sein sollte, die alle Funktionen verwenden. Es wirkt natürlicher, das ins Typsystem aufzunehmen; wenn Entwickler die Calling Convention kontrollieren können, verschwindet einer der Performance-Vorteile von Assembler.

    • Mich würde interessieren, warum es so problematisch sein soll, einen Teil der Eingaberegister als Ausgaberegister zu verwenden.
      Aus Sicht des Aufrufers müssen die Ausgaberegister zwischen zwei Funktionsaufrufen ohnehin freigemacht werden, und bei System-Calling-Conventions ist das ziemlich verbreitet.
      Vielleicht geht es darum, es dem Aufgerufenen leichter zu machen, Ausgabewerte vorzubereiten, während die Eingabewerte unverändert bleiben. Dann kann ich nachvollziehen, die Ausgaberegister ans Ende der Eingabereihenfolge zu legen, um Überlappungen zu vermeiden; aber ich sehe nicht recht, warum jede Überlappung vollständig verboten werden müsste.
    • Wenn man Entwicklern die Kontrolle über die Calling Convention gibt, verhindert man zugleich Optimierungen, die in einer Kette wie Function A ruft Function B, Function C, Function D auf die Argumente der Zwischenfunktionen auf andere Conventions umstellen, um Overhead zu reduzieren.
      Ich frage mich, welche Semantik sowohl solche Optimierungen bewahrt als auch Kontrolle erlaubt – oder ob das faktisch eine Illusion bleibt.
      Tatsächlich ist Assembler kein Ziel der meisten Compiler-Optimierungen und hat deshalb Performance-Nachteile. Auch Optimierungen wie „das Verhalten betrachten, feststellen, dass es vollständig redundant ist, und es komplett entfernen“ bekommt man oft nicht; wir haben eben nicht mehr die 1990er.
      Wenn solche Optimierungen aber ohnehin nicht einmal in Betracht kommen, sehe ich Inline-Assembler nur bei profilgesteuerter Optimierung klar im Nachteil. Denn Anwendungsentwickler kennen das Verhalten ihres Codes vollständig, Compiler-Entwickler nicht.
      Call-Overhead lässt sich beseitigen, indem man mehr Assembler schreibt, bis die relevanten heißen Grenzen abgedeckt sind.
    • DWARF enkodiert derzeit überhaupt keine maßgeschneiderten Calling Conventions.
    • Bin Packing kann es eher langsamer machen, insbesondere bei bool, weil es Abhängigkeitsketten erzeugen kann.
      Auf x64 scheint es für bool-Werte kaum etwas Besseres zu geben, als sie zuerst in Register zu laden, zu shiften und dann per OR ins Ergebnis zu schreiben.
      Der einfache Ansatz erzeugt eine Abhängigkeitskette der Länge 64 und kann eine Strafe von 64 Zyklen verursachen; mit Geschick lässt sich das vielleicht auf 6 Zyklen, realistisch eher auf etwa 12 Zyklen reduzieren.
      Aber es stellt sich auch die Frage, woher die 64 bool-Werte kommen. So viele Register gibt es nicht, also muss man sie am Ende wieder vom Stack lesen.
      Wenn die Rust-ABI bool innerhalb von Structs ohnehin schon so dicht packt, müsste man das sowieso tun, aber genau weiß ich es nicht.
      Und der Aufrufer muss danach alles wieder entpacken.
      Es dürfte einfacher sein, dem Compiler beizubringen, Werte in den Ergebnisbereich auf dem Stack auslaufen zu lassen, und wahrscheinlich ist das auch performanter.
    • Moderne Prozessoren können meist Reads direkt nach Stores problemlos weiterleiten, und es gibt diverse Tricks, um den Stack-Zustand zu verfolgen.
      Daher frage ich mich, wie viel es tatsächlich bringt, Werte in Register zu legen.
  • C-Calling-Conventions sind ziemlich mäßig.
    Es stimmt zwar, dass man die C-Calling-Convention nicht ändern kann, aber dadurch wird es nicht weniger schade.
    Man sollte alle verfügbaren caller-saved Register für Argumente und Rückgabewerte nutzen, aber in der traditionellen SysV-ABI werden für Rückgabewerte nur ein, manchmal zwei Register verwendet.
    Wenn man struct Point3D { long x, y, z } zurückgibt, könnte man Point3D in rax, rdi, rsi legen, spillt es aber auf den Stack.
    Andere Systeme haben andere Tricks. Wenn ich mich richtig erinnere, setzt SBCL beim Beenden einer Funktion das Carry-Flag, wenn sie mehrere Werte zurückgibt. Es wäre doch praktisch, das Carry-Flag etwa dafür zu nutzen, anzuzeigen, ob ein Result einen Fehler enthält.

    • „Mäßig“ ist eine starke Formulierung, aber bei Rückgabewerten stimmt sie.
      Die C-Calling-Convention unterstützt im Grunde das, was C unterstützt: die Rückgabe eines einzelnen Arguments. Selbst Struct-Rückgaben nicht wirklich sauber.
      In C läuft das eher auf „hättest du das nicht erwartet?“ hinaus, und in C++ heißt es dann: „Inline es doch einfach.“
      Memory-Spills passieren dagegen tatsächlich. Beispielsweise ließen der großzügige Registerraum und die Register-Windows von SPARC in einfachen Funktionen viele ungenutzte Register übrig, und das Spillen des Register-Rings führte zu großem Stack-Verbrauch, der den Cache störte.
      Selbst wenn es auf x86 viele mov-Instruktionen gibt, die Daten dorthin verschieben, wo sie „gebraucht werden“, war das am Ende oft schneller.
      Wenn man nur den Code des Aufgerufenen betrachtet, möchte man sagen: „Dieses Argument hier, jener Rückgabewert dort, das ist garantiert schneller.“ Aber man kennt den Aufrufer nicht.
      Man kann nicht garantieren, dass die Argumentvorbereitung unverändert durchgereicht wird oder dass der Rückgabewert heiß konsumiert wird. Wenn man zum Beispiel struct Point { x: i32, y: i32, z: i32 } als Argument/Rückgabewert verwendet und der Aufrufer in einer Schleife etwas wie mystruct.deepinside.point[i] = func(mystruct.deepinside.point[i]) tut, kann das Hinein- und Herauslegen in Register Overhead erzeugen oder Vektorisierung verhindern.
      Der Aufgerufene kann das nicht wissen; die einzige Ausnahme ist, wenn der Compiler beide Seiten sieht und inlinen kann.
      Die am niedrigsten hängende Frucht bei Calls scheint zu sein, die in fast allen C-ABIs verankerte Annahme abzuschaffen, dass eine Funktion genau einen primitiven Wert zurückgibt. Für den Rest braucht es viele Benchmarks und Statistiken zur Codegenerierung.
  • In Rust gibt es ein weiteres unerfreuliches Detail, durch das Strukturen größer werden, als man gern hätte.
    Man denke an eine Foo-Struktur mit acht Option-Feldern, die jeweils None oder Some(u8) sind: In C ließe sich das mit acht 1-Bit-bools und acht uint8_ts in insgesamt 9 Byte darstellen.
    In Rust wird daraus achtmal ein 1-Byte-Discriminant plus uint8_t, also 16 Byte.
    Der Grund ist, dass die Struktur das Ausleihen ihrer Felder ermöglichen muss. Wenn man ein &Foo hat, muss der Compiler &Foo::some_field, also ein &Option, erzeugen können, und dieses &Option muss dieselbe Form haben wie alle anderen &Options im Programm.
    Daher muss das innere Option dasselbe Layout haben wie andere Options im Programm: seinen eigenen Discriminant-Bit auf Byte-Größe aufgerundet plus das u8. Selbst wenn tatsächlich nie ein &Foo::some_field erzeugt wird, zahlt die Struktur diese Kosten.
    Bei Option mit größeren Typen wird es noch schlimmer. In einer Struktur mit acht Option-Feldern wird jeder Discriminant auf 2 Byte aufgerundet, sodass es insgesamt 32 Byte werden; ein Viertel davon, und wenn man die ungenutzten Bits der Discriminants mitzählt fast die Hälfte, wird als Padding dazwischen verschwendet. Die entsprechende C-Struktur käme mit 18 Byte aus.
    Mit Option kann die Rust-Struktur 128 Byte groß sein, die C-Struktur 72 Byte.
    Natürlich kann man mit einem u8 für gepackte Discriminants und acht MaybeUninits sowie selbst geschriebenen Funktionen, die von &Foo auf Option<&T> und von &mut Foo auf Option<&mut T> abbilden, dieselbe Darstellung wie in C implementieren. Nur als &Option oder &mut Option geht das nicht.
    https://play.rust-lang.org/?version=stable&mode=debug&editio...

    • Die C-Version muss man ebenfalls selbst implementieren, daher ist es nicht allzu seltsam, dass man es in Rust genauso selbst machen muss.
      Im Grunde wurde hier ein benutzerdefinierter Typ beschrieben, der acht Options enthält; sobald Performance wichtig wird, muss man die interne Option-Verarbeitung selbst bauen.
    • Die entsprechende C-Version muss ebenfalls selbst implementiert werden.
      Dass Rust bequeme Features bietet, die man nutzen kann, wenn sie zum Ziel passen, lässt sich schwer als Nachteil werten.
      Der beschriebene Anwendungsfall ist vergleichsweise selten, und wenn es tatsächlich ein Performance-Engpass ist, ist es in Rust kein großes Problem, etwas mehr Zeit in die Implementierung zu stecken.
      Da der Typ Option<_> im normalen Gebrauch sehr große Vorteile bietet, ist es schwer, das als „unerfreuliches Detail“ von Rust zu sehen.
  • Es heißt, wenn die Adresse einer nicht-polymorphen, nicht-inline Funktion als Funktionszeiger genommen werden kann, werde ein Shim mit -Zcallconv=legacy erzeugt, der die eigentliche Implementierung sofort per Tail Call aufruft; die Absicht, Funktionszeiger-Gleichheit zu erhalten, ist nachvollziehbar.
    Aber wenn der Legacy-Shim eine Funktion mit Rust-Calling-Convention per Tail Call aufruft, kann er doch die Unterschiede beim Rückgabewert der Calling Convention nicht korrigieren, oder?

    • Stimmt. Leute neigen dazu, die Rückgabehälfte einer Calling Convention zu vergessen; das wirkt wie ein nachvollziehbarer Tippfehler.
  • Etwas anderes Thema, aber ich frage mich, ob derzeit Interoperabilität zwischen Go und Rust möglich ist.
    Ich erinnere mich, einmal einen Fall gesehen zu haben, bei dem das mit Zig dazwischen geschafft wurde, kann ihn aber nicht finden. Es gibt Legacy-Rust-Code, den ich nach und nach nach Go migrieren möchte.

    • Das ist möglich. Man kann mit CGO über extern "C"-FFI Rust-Funktionen aufrufen.
      Ich habe auf der RustConf 2023 vorgestellt, wie wir das in der GitHub-Code-Suche verwenden (https://www.youtube.com/watch?v=KYdlqhb267c), und später gehört, dass etwa 1Password etwas Ähnliches macht.
      Typen über die C-Interop-Grenze zu bewegen ist mühsam und macht keinen Spaß, aber es ist möglich und erlaubt Code-Wiederverwendung.
    • Um aus Go heraus Rust aufzurufen, deklariert man die Rust-Funktion als extern "C" und ruft sie dann aus Go so auf, wie man C aufrufen würde.
      Die Gegenrichtung kenne ich nicht gut.
    • Managed und unmanaged Memory zu mischen, ist normalerweise keine kluge Idee.
      Managed Code muss Speicher besitzen können, den er freigibt oder verschiebt, und unmanaged Code muss ableiten können, wann Speicher freigegeben oder verschoben wird.
      Dinge wie cgo erlauben es, FFI-Aufrufe aus Go-Managed-Code in unmanaged Memory zu mischen, aber das hat seinen Preis.
      In Implementierungen, bei denen die sich gegenseitig aufrufenden Sprachen keinen Garbage Collector teilen, tritt dieses Problem immer auf.
      Managed und unmanaged Code zu mischen ist eine alte Idee und zugleich weiterhin ein aktiv erforschtes Thema.
      Sofern die eingebettete Runtime nicht dafür ausgelegt ist, ist es fast immer eine schlechte Idee, von unmanaged Code aus Managed Code aufzurufen; normalerweise liegt dazwischen eine Serialisierungsschicht.
    • Da ich ziemlich viel Rust und Swift verwenden muss, bin ich am Ende bei der Methode gelandet, Byte-Arrays serialisierter protobufs über ganz gewöhnliche Funktionsaufrufe hin und her zu reichen.
      Wenn das mein Hauptjob wäre, fände ich das vielleicht nicht toll, aber ich hatte es satt, nach ein paar Wochen wieder zum Code zurückzukehren und mich nicht mehr daran zu erinnern, was wie funktioniert.
    • Als ziemlich verfluchtes Beispiel habe ich neulich aus Rust heraus über C als Zwischenschicht Go-Code aufgerufen.
      Ich habe eine zustandsbehaftete Rust-Closure als Callback an Go-Code übergeben und sie in eine Funktion der Go-Standardbibliothek gesteckt, inklusive Panic-Unwinding innerhalb der Rust-Closure.
      https://github.com/Voultapher/sort-research-rs/commit/df6c91...
  • Ich habe lange mit Element untersuchen herumgesucht, um herauszufinden, wie die Abschnittsüberschriften schräg gesetzt wurden, bin aber in den Safari-Tools nicht weitergekommen. Wie zum Teufel wurde das gemacht?

    • Der Stil liegt auf dem Element .post-title: transform: skewY(-2deg) translate(-1rem, -0.4rem);
    • In dem Zusammenhang dachte ich, die Minimap würde die CSS-Funktion element() (https://developer.mozilla.org/en-US/docs/Web/CSS/element) verwenden, tatsächlich war es aber eine stark verkleinerte Kopie des Artikeltexts
    • Auf h1, h2, h3, h4, h5, h6 sind transform:skewY(-2deg) translate(-1rem,0rem);, transform-origin:top;, font-style:italic;, text-decoration-line:underline;, text-decoration-color:goldenrod;, text-underline-offset:4%;, text-decoration-thickness:.25ex angewendet
  • Als Gegenbeispiel gibt es den Artikel von 2019 „How Swift Achieved Dynamic Linking Where Rust Couldn't“
    https://faultlore.com/blah/swift-abi/
    Es ist schade, dass Rust noch keine Calling Convention für Semantik auf Rust-Niveau hat, aber zugleich zeigt der Artikel, wie enorm der Arbeitsaufwand ist, um dorthin zu gelangen
    Apple war stark motiviert, Swift zu einer praktischen Systemsprache zu machen, auf die Anwendungen sich verlassen können, aber Rust hat keine solche Förderung
    HN-Diskussion: https://news.ycombinator.com/item?id=21488415

    • Fairerweise sollte man auch darauf hinweisen, dass der Swift-Ansatz Laufzeitkosten hat
      Es wäre schön, wenn Rust mehr Unterstützungsoptionen für diesen Trade-off hätte, und nicht nur auf Dinge wie https://github.com/rust-lang/rfcs/pull/3470 beschränkt wäre
  • Wenn der aktuelle Rust-Compiler aggressiv inlined und anschließend optimiert, frage ich mich, ob sich dieser Aufwand lohnt
    Ist die aufgerufene Funktion klein, wird sie inlined; ist sie groß, verbringt man ziemlich viel Zeit in der Funktion, sodass der Call-Overhead gering sein dürfte

    • Laufzeitfunktionen, etwa dyn Trait, können nicht inlined werden, daher würde eine solche Änderung helfen
      Wenn man Calls günstiger machen kann, muss man nicht so aggressiv inlinen, was auch bei Codegröße und Kompilierzeit helfen kann
    • Vermutlich lohnt es sich
      Komplexe Funktionen, die sich nicht gut fürs Inlining eignen, greifen wahrscheinlich einige Male auf den Speicher zu, und diese Zugriffe sind vermutlich der Flaschenhals
      Übergabe über den Stack verschärft diesen Flaschenhals, weil dadurch Cache-Druck sowie Loads/Stores zunehmen
      Wenn Rust bei einem erheblichen Anteil der Funktionsaufrufe Argumente optimal übergeben kann, spart das nicht nur ein paar Zyklen für L1-Zugriffe, sondern kann die CPU auch schneller zum eigentlichen Speicherflaschenhals bringen
      Vielleicht bringt das ein paar Prozent, aber ich trinke gerade Wein und rechne nicht nach
  • Kann jemand erklären, was es mit der Merkhilfe „Diana’s silk dress cost $89“ aus den x86-Referenzmaterialien auf sich hat?