- 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
-Zcallconvzwischen dem aktuellen Ansatzlegacyund einem neuen registerzentrierten Ansatzfastzu 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,
boolundResultkö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 inrdiundrsiübergeben - Das ist ein Fall, in dem Rusts Standardpfad noch konservativer ist als die Linux-C-ABI
- Mit
-Zcallconv: Trennung zwischen legacy und fast
- Die aktuelle Calling Convention von
extern "Rust"bleibt erhalten, aber über das Crate-Kompilierungsflag-Zcallconvwird ausgewählt, welche Calling Convention verwendet wird-Zcallconv=legacy: aktueller Ansatz-Zcallconv=fast: neuer, zu entwerfender registerzentrierter Ansatz-Okönnte automatisch-Zcallconv=fastsetzen
- Die Calling Convention
fastplatziert 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=fastwomöglich nicht unterstützt - In Debug-Builds ohne Optimierung kann
fastschlechteren 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=legacyerzwingen - 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
- Das Flag gilt Crate-weit, aber bei Funktionszeigern lässt sich schwer ausdrücken, welche
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 mitsret-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,doubleoder<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
retausgefü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
rmetaabgelegt 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-linuxsind 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
xmm0bisxmm7 - Bei tatsächlicher Pointer-Übergabe wird das jeweilige
i64zuptr - Bei
doubleersetzt 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
poisonals gerade bequemsten Wert behandeln - Wird
poisonan 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 inrcxerhält und die übrigen 13 Register mitpoisonbelegt werden, nach Optimierung überhaupt keinen zusätzlichen Code
- LLVM darf
- 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 Bitboolhat 1 Bitcharhat technisch 21 Bit, wird zur Vereinfachung aber wie ein Alias füru32behandelt
- Structs mit vielen
bool-Feldern können mehrereboolin 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)betrachtenOption<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
u128oderf64, 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
boolwird mit bis zu 64 Werten pro Register bitgepackt
Beispiel einer komplexen Rust-Funktion und aktuelle Grenzen von rustc
- Im
do_thing-Beispiel mitOption<usize>,&dyn Context,&str,[char; 6]und einerOptions-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, i32xmm0: i32, i32, i32, i32xmm1: 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 } &strwird zu{ ptr, i64 }[char; 6]wird zu[6 x i32]Optionswird 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
Resultgibt es eigenen Optimierungsspielraum- Wenn
?über mehrere Funktionsebenen hinweg propagiert wird, kann es zu vielen redundanten Registerbewegungen kommen - Ist
Resultzu 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 auchIntoaufgerufen wird, sind knifflig, aber umsetzbar
- Wenn
Optimierungsabhängige ABI
- Anders als C kann Rust bei
-Zcallconv=fastden 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
&Tnicht aufbewahrt wird, nicht in einen Raw Pointer umgewandelt wird,Tklein ist undT: Freezegilt, 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
- Ist der Schlüssel ein Typ wie
- 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
-Zcallconvaber 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
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.
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.
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.
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.
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.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.
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.
Function AruftFunction B,Function C,Function Dauf 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.
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
boolinnerhalb 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.
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 manPoint3Dinrax,rdi,rsilegen, 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
Resulteinen Fehler enthält.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 wiemystruct.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 achtOption-Feldern, die jeweilsNoneoderSome(u8)sind: In C ließe sich das mit acht 1-Bit-bools und achtuint8_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
&Foohat, muss der Compiler&Foo::some_field, also ein&Option, erzeugen können, und dieses&Optionmuss dieselbe Form haben wie alle anderen&Options im Programm.Daher muss das innere
Optiondasselbe Layout haben wie andereOptions im Programm: seinen eigenen Discriminant-Bit auf Byte-Größe aufgerundet plus dasu8. Selbst wenn tatsächlich nie ein&Foo::some_fielderzeugt wird, zahlt die Struktur diese Kosten.Bei
Optionmit größeren Typen wird es noch schlimmer. In einer Struktur mit achtOption-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
Optionkann die Rust-Struktur 128 Byte groß sein, die C-Struktur 72 Byte.Natürlich kann man mit einem
u8für gepackte Discriminants und achtMaybeUninits sowie selbst geschriebenen Funktionen, die von&FooaufOption<&T>und von&mut FooaufOption<&mut T>abbilden, dieselbe Darstellung wie in C implementieren. Nur als&Optionoder&mut Optiongeht das nicht.https://play.rust-lang.org/?version=stable&mode=debug&editio...
Im Grunde wurde hier ein benutzerdefinierter Typ beschrieben, der acht
Options enthält; sobald Performance wichtig wird, muss man die interneOption-Verarbeitung selbst bauen.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=legacyerzeugt, 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?
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.
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.
extern "C"und ruft sie dann aus Go so auf, wie man C aufrufen würde.Die Gegenrichtung kenne ich nicht gut.
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
cgoerlauben 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.
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.
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?
.post-title:transform: skewY(-2deg) translate(-1rem, -0.4rem);element()(https://developer.mozilla.org/en-US/docs/Web/CSS/element) verwenden, tatsächlich war es aber eine stark verkleinerte Kopie des Artikeltextsh1, h2, h3, h4, h5, h6sindtransform: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:.25exangewendetAls 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
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
dyn Trait, können nicht inlined werden, daher würde eine solche Änderung helfenWenn man Calls günstiger machen kann, muss man nicht so aggressiv inlinen, was auch bei Codegröße und Kompilierzeit helfen kann
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?