1 Punkte von GN⁺ 2 시간 전 | 1 Kommentare | Auf WhatsApp teilen
  • Async Rust ermöglicht executor-unabhängigen Code, der sowohl auf Servern als auch auf Mikrocontrollern laufen kann, aber durch die vom Compiler erzeugten Zustandsmaschinen fällt insbesondere im Embedded-Bereich die Zunahme der Binärgröße deutlich auf
  • Schon ein einfaches Beispiel wie bar() mit zwei await-Stellen erzeugt 360 Zeilen MIR und die Zustände Unresumed, Returned, Panicked, Suspend0, Suspend1, während die synchrone Version nur 23 Zeilen benötigt
  • Wenn beim erneuten poll eines bereits abgeschlossenen Future statt panic einfach Poll::Pending zurückgegeben wird, lässt sich der Vertrag ohne unsafe Verhalten erfüllen; in Experimenten sank die Binärgröße von Embedded-Firmware um 2 % bis 5 %
  • Selbst async { 5 } ohne await erzeugt derzeit eine Zustandsmaschine mit standardmäßig drei Zuständen, aber eine Optimierung auf stets Poll::Ready(5) reduziert die Größe von Embedded-Binaries um 0,2 %
  • Das vorgeschlagene Project Goal soll im Compiler an der Entfernung von Panics nach Abschluss im Release-Modus, am Wegfall der Zustandsmaschine für Async-Blöcke ohne await, am Inlining von Futures mit nur einem await und am Zusammenfalten identischer Zustände arbeiten

Das Problem der Compiler-seitigen Aufblähung in Async Rust

  • Async Rust ermöglicht executor-unabhängigen Code, der gleichzeitig auf Servern und Mikrocontrollern laufen kann, doch auf kleinen Mikrocontrollern fällt die zunehmende Binärgröße besonders stark ins Gewicht
  • Der Rust-Blog stellte async/await als Zero-Cost-Abstraktion vor, tatsächlich erzeugt async aber viel Aufblähung; dasselbe Problem gibt es auch auf Desktop und Server, dort fällt es wegen größerer Speicher- und Rechenressourcen jedoch weniger auf
  • Nach Workarounds, um Aufblähung beim Schreiben von async-Code zu vermeiden, wurde nun ein Project Goal eingereicht, um das Problem im Compiler zu lösen
  • Nicht Teil des Umfangs ist das Problem, dass Futures unnötig groß werden und zu viel kopiert wird

Aufbau der erzeugten Futures

  • Der Beispielcode lässt foo() async { 5 } zurückgeben, und bar() führt foo().await + foo().await aus
  • bar hat zwei await-Stellen, daher braucht die Zustandsmaschine mindestens zwei Zustände, tatsächlich werden jedoch mehr erzeugt
  • Der Rust-Compiler kann MIR in mehreren Passes dumpen; der Pass coroutine_resume ist der letzte async-spezifische MIR-Pass
    • Async bleibt in MIR erhalten, aber nicht mehr in LLVM IR, daher findet die Umwandlung von async in eine Zustandsmaschine im MIR-Pass statt
  • Die Funktion bar erzeugt 360 Zeilen MIR, während die synchrone Version nur 23 Zeilen benötigt
  • Das vom Compiler ausgegebene CoroutineLayout ist faktisch eine Menge von Zuständen in Enum-Form
    • Unresumed: Startzustand
    • Returned: abgeschlossener Zustand
    • Panicked: Zustand nach einer Panic
    • Suspend0: erste await-Stelle, speichert das foo-Future
    • Suspend1: zweite await-Stelle, speichert das erste Ergebnis und das zweite foo-Future
  • Future::poll ist eine sichere Funktion, daher darf ein erneuter Aufruf nach Abschluss des Future kein UB auslösen
    • Aktuell wird nach Suspend1 Ready zurückgegeben und das Future in den Zustand Returned versetzt
    • Wird in diesem Zustand erneut poll aufgerufen, tritt eine Panic auf
  • Der Zustand Panicked scheint dazu zu dienen, ein erneutes poll eines Future zu verhindern, nachdem eine Panic innerhalb einer async-Funktion per catch_unwind abgefangen wurde
    • Nach einer Panic kann sich das Future in einem unvollständigen Zustand befinden, daher könnte erneutes poll zu UB führen
    • Dieser Mechanismus ist Mutex Poisoning sehr ähnlich
    • Für diese Interpretation des Zustands Panicked gibt es schwer belastbare Dokumentation; die Sicherheit dafür liegt bei etwa 90 %

Muss poll nach Abschluss wirklich panicen?

  • Ein Future im Zustand Returned panicet derzeit, aber das ist nicht zwingend erforderlich
    • Erforderlich ist nur, dass kein UB entsteht
  • Eine Panic ist vergleichsweise teuer und fügt einen Pfad mit Nebenwirkungen hinzu, der sich nur schwer wegoptimieren lässt
  • Wenn ein abgeschlossenes Future bei erneutem poll einfach Poll::Pending zurückgibt, lässt sich der Vertrag des Typs Future ohne unsafe Verhalten erfüllen
  • In einem Experiment mit entsprechend geändertem Compiler wurde bei async-Embedded-Firmware eine 2 % bis 5 % kleinere Binärgröße gemessen
  • Vorgeschlagen ist, dieses Verhalten wie overflow-checks = false bei Integer-Overflow über einen Schalter anzubieten
    • In Debug-Builds würde weiterhin gepanict, um fehlerhaftes Verhalten sofort sichtbar zu machen
    • In Release-Builds ließen sich kleinere Futures erzielen
  • Bei Verwendung von panic=abort könnte sich eventuell sogar der Zustand Panicked selbst entfernen lassen; die Auswirkungen müssen noch genauer geprüft werden

Auch ohne await wird immer eine Zustandsmaschine erzeugt

  • foo() gibt nur async { 5 } zurück, daher wäre die optimale manuelle Implementierung ein Future ohne Zustand, das immer Poll::Ready(5) zurückgibt
  • Im vom Compiler erzeugten MIR existieren jedoch weiterhin die drei Standardzustände Unresumed, Returned, Panicked
    • Beim poll wird der Discriminant des aktuellen Zustands geprüft und verzweigt
    • Erfolgt nach Abschluss ein weiteres poll, kommt es zur Panic mit `async fn` resumed after completion
  • In diesem Fall lässt sich optimieren, indem gar keine Zustandsmaschine erzeugt und stattdessen jedes Mal Poll::Ready(5) zurückgegeben wird
  • Eine experimentelle Implementierung im Compiler reduzierte die Größe von Embedded-Binaries um 0,2 %
    • Die Einsparung ist nicht groß, aber da die Optimierung einfach ist, könnte sie sich dennoch lohnen
  • Die Optimierung ändert das Verhalten leicht, betroffen wären aber nur Executor, die sich nicht an den Vertrag halten
    • Der aktuelle Compiler panicet bei späterem poll
    • Nach der Optimierung würde das Future immer Ready zurückgeben

LLVM allein reicht nicht aus

  • Selbst wenn die MIR-Ausgabe ineffizient ist, kann LLVM sie manchmal vollständig bereinigen, aber nur unter eingeschränkten Bedingungen
    • Das Future muss hinreichend einfach sein
    • opt-level=3 muss verwendet werden
  • Werden Futures komplexer, kann LLVM sie nicht mehr vollständig entfernen, und in idiomatischem async Rust wachsen Komplexität und Schachtelungstiefe schnell
  • In Umgebungen wie Embedded oder wasm, in denen oft auf Größe optimiert wird, kann LLVM dies nicht alles wegoptimieren
  • Godbolt-Beispiel: https://godbolt.org/z/58ahb3nne
    • In der erzeugten Assemblerausgabe erkennt LLVM, dass foo 5 zurückgibt, kann die Antwort von bar aber nicht zu 10 optimieren
    • Auch der Aufruf der poll-Funktion von foo bleibt erhalten
    • Grund dafür sind potenzielle Panic-Pfade, die der Compiler nicht vollständig auflösen kann
    • LLVM weiß nicht, dass foo tatsächlich nur einmal aufgerufen wird und keine Panic auslöst
  • Wenn die Panic-Verzweigung im IR auskommentiert wird, optimiert LLVM deutlich besser: https://godbolt.org/z/38KqjsY8E
  • Statt auf nachträgliche Optimierungen durch LLVM zu hoffen, sollte der Compiler LLVM besseren Input liefern

Future-Inlining funktioniert nicht gut

  • Inlining ist wichtig, weil es nachfolgende Optimierungspässe ermöglicht, doch die erzeugten Rust-Futures werden derzeit in frühen Phasen nicht inlined
  • Erst nachdem jedes Future seine Implementierung erhalten hat, bekommen LLVM und Linker eine Chance zum Inlining, doch wegen der vorherigen Probleme ist dieser Zeitpunkt zu spät
  • Die direkteste Inlining-Gelegenheit ist ein bar(), das lediglich foo(blah).await ausführt
    • Das ist ein Muster, das häufig auftritt, wenn per Trait abstrahiert wird
    • Der aktuelle Compiler erzeugt dafür eine Zustandsmaschine für bar und ruft darin die Zustandsmaschine von foo auf
    • Effizienter wäre es, wenn bar selbst das foo-Future wäre
  • Mit Preamble und Postamble wird es komplexer
    • Beispiel: bar(input) erzeugt über input > 10 ein blah, awaited dann foo(blah) und wendet auf das Ergebnis * 2 an
    • Das ist typisch, wenn async-Funktionen in eine andere Signatur transformiert werden, besonders in Trait-Implementierungen
  • Auch diese Form von bar benötigt keinen eigenen async-Zustand
    • Über die einzelne await-Stelle hinaus muss außer den von foo eingefangenen Werten nichts erhalten bleiben
    • Allerdings kann bar dann nicht einfach direkt foo selbst sein, sondern den Großteil seines Zustands an foo delegieren
  • In einer manuellen Implementierung könnte BarFut die Zustände Unresumed { input } und Inlined { foo: FooFut } haben
    • Beim ersten poll wird die Preamble ausgeführt, foo(blah) erzeugt und in den Zustand Inlined gewechselt
    • Danach wird auf das Ergebnis von foo.poll(cx) die Postamble angewendet
  • Würde sich Code bis zur ersten await-Stelle vorab ausführen lassen, könnte auch der Zustand Unresumed entfallen, aber es ist garantiert, dass ein Future vor dem ersten poll nichts tut, daher ist das nicht veränderbar
  • Wenn sich Eigenschaften eines gerade gepollten Future abfragen ließen, wären weitere Inlining-Optimierungen möglich
    • Wüsste man etwa, dass ein Future beim ersten poll immer ready zurückgibt, müsste das aufrufende Future für diese await-Stelle keinen eigenen Zustand anlegen
    • Wendet man solche Optimierungen rekursiv an, ließen sich viele Futures zu deutlich einfacheren Zustandsmaschinen zusammenfalten
  • In der aktuellen Struktur von rustc scheint das nicht möglich zu sein, weil jeder async-Block separat transformiert wird und die relevanten Daten danach nicht erhalten bleiben
  • Future-Inlining wurde bislang noch nicht experimentell umgesetzt, dürfte aber Binärgröße und Performance deutlich verbessern

Identische Zustände zusammenfalten

  • Für jede await-Stelle in einem async-Block erhält die Zustandsmaschine einen zusätzlichen Zustand
  • Der folgende Code ist natürlich, erzeugt aber zwei identische Zustände, weil in beiden Verzweigungen dieselbe async-Funktion awaited wird
    • CommandId::A => send_response(123).await
    • CommandId::B => send_response(456).await
  • In diesem Fall enthält das CoroutineLayout jeweils _s0 und _s1, die denselben Coroutine-Typ von send_response speichern, und es entstehen die beiden Zustände Suspend0 und Suspend1
  • Die MIR dieser Funktion umfasst 456 Zeilen, viele Basic Blocks sind dabei faktisch Duplikate
  • Refaktoriert man den Code manuell so, dass zunächst nur der Antwortwert berechnet und danach einmal send_response(response).await aufgerufen wird, verschwinden die doppelten Zustände
    • CommandId::A wird zu 123
    • CommandId::B wird zu 456
    • Danach folgt send_response(response).await
  • Nach dem Refactoring enthält das CoroutineLayout nur noch ein gespeichertes Future und nur noch einen Zustand Suspend0
  • Die gesamte MIR-Länge sinkt auf 302 Zeilen, und die Duplikate verschwinden
  • Daher erscheint ein Optimierungspass nützlich, der identische Codepfade und Zustände erkennt und zu einem zusammenfaltet
    • Diese Optimierung dürfte sich gut mit einem Future-Inlining-Pass kombinieren lassen

Experiment-Links und weitere Benchmarks

Bitte um Unterstützung für das Project Goal

  • Diese Arbeit wurde als Project Goal eingereicht, um sie im Compiler voranzutreiben: https://rust-lang.github.io/rust-project-goals/2026/async-statemachine-optimisation.html
  • Ohne Finanzierung ist es schwierig, größere Teile der Arbeit umzusetzen; daher wird partielle oder vollständige Unterstützung von Unternehmen oder Organisationen benötigt, die von diesem Vorhaben profitieren würden
  • Kontakt: dion@tweedegolf.com
  • Umfang der Arbeiten und benötigte Finanzierung sind flexibel, aber mit 30.000 € ließe sich vermutlich das Ganze oder ein erheblicher Teil davon umsetzen

1 Kommentare

 
GN⁺ 2 시간 전
Lobste.rs-Kommentare
  • War ein deutlich konstruktiverer Beitrag, als ich nur anhand des Titels erwartet hatte

    • Ich halte ihn im Grunde einfach für zutreffend. Sieben Jahre nach dem MVP-Release gab es beim Sprachdesign oder der Compiler-Implementierung kaum Fortschritte, und nachdem die Leute, die das MVP hauptsächlich gebaut hatten, ungefähr zur gleichen Zeit ihr Engagement im Projekt zurückfuhren, ist die Weiterentwicklung danach praktisch zum Stillstand gekommen.
      Hoffentlich bekommt jemand, der daran arbeiten will, die nötige Unterstützung
  • I want to work on this in the compiler and as such have submitted it as a Project Goal

    Stop generating statemachines that don’t have to be there
    Make the compiler’s job easier by removing panic paths and branches
    Make statemachines smaller

    Gut zu sehen, dass dieses Problem angegangen wird. Ich habe schon ein paar Mal Beiträge gesehen, die meinten, dass rustc LLVM zu viel Code übergibt und dann erwartet, dass der Optimierer alles richtet; dieser hier fordert insbesondere auch finanzielle Unterstützung für die Arbeit daran

  • Mein Gott, ich war dumm
    Ich hatte async immer für grundsätzlich „aufgebläht“ gehalten, weil es in irgendeiner Form Laufzeitumgebung, Task-Tracking und Polling zur Prüfung auf Abschluss braucht. Dieser Overhead ist ja nicht null
    Ich bin davon ausgegangen, dass sich die hier gemeinte „Zero-Cost-Abstraktion“ auf das Sprachfeature bezieht und getrennt von der zusätzlich eingebauten Runtime zu betrachten ist
    Ich bin nicht einmal auf die Idee gekommen, mir anzusehen, was rustc überhaupt ausgibt, bevor es an LLVM geht

  • Für Leute, die mit async Rust nicht vertraut sind:

    It's amazing how we can write executor agnostic code that can run concurrently on huge servers and tiny microcontrollers.

    Das stimmt wirklich. Selbst ein verschachtelter Baum aus async-Aufrufen wird nach maximaler Optimierung zu einer einzigen Struktur mit interner Zustandsmaschine zusammengefaltet. Wirklich clever gemacht

  • Wenn man diesen Fall in einem Release-Build erreicht, entsteht dann eine Art Deadlock? Oder könnte es auch zu Leaks kommen, weil Tasks auf etwas warten, das immer Pending bleibt?

    • Genau. Solche Futures sind dann steckengeblieben und werden nie fertig. Allerdings kann man in diesen Zustand nur aus bereits fehlerhaftem Low-Level-async-Code geraten, und Code, der abgeschlossene Futures nicht korrekt nachverfolgt, erzeugt vermutlich ohnehin schon Leaks und Deadlocks
      Mit .await kann man nicht falsch pollen
  • Ein paar Gedanken dazu:

    1. Der Beitrag wirkt auf mich wie das Argument, mehr Optimierungslogik aus LLVM herauszuziehen und auf die MIR-Ebene zu verlagern. Ich verstehe zum Beispiel, warum das Inlining von async-Funktionen in MIR einfacher ist als in LLVM. Wenn man das für async auf MIR-Ebene geschafft hat, wäre es dann nicht sinnvoll, die Logik auch auf synchrone Funktionen zu verallgemeinern und einige der LLVM-Optimierungspässe zu entfernen? Mir ist klar, dass das ein großes Vorhaben wäre; das ist weniger eine praktische Frage als eine Richtungsfrage. Ab einem gewissen Komplexitätsgrad des Frontend-/Midend-Compilers scheint es vielleicht besser, einen guten Teil der generischen LLVM-Optimierungen an anderer Stelle unterzubringen
    2. Ich bin immer noch kein Fan von panic=unwind. Außer bei einigen Test-Harnesses habe ich kaum Vorteile gesehen, die die Kosten gegenüber panic=abort aufwiegen. Selbst bei Test-Harnesses könnte man unter Linux vielleicht eine ähnliche Wahl ermöglichen, indem man auf obskure Weise clone nutzt und den Ausführungs-Thread mit wait statt mit pthread_join behandelt. Aber da kann ich auch falschliegen
  • Ist der Link bei sonst noch jemandem gerade kaputt? Bearbeitung: Der Blogpost ist etwa eine halbe Sekunde sichtbar und springt dann auf eine 404-Seite
    Bearbeitung 2: Ich bin in die Liste der Blogposts gegangen und habe ein bisschen herumgeklickt; selbst wenn ich den Beitrag dort öffne, lande ich auf einer 404-Seite. Wie kann man einen Blog, der statisch ist oder es zumindest sein sollte, so kaputtmachen?

    • Der Tonfall wirkt etwas unnötig unhöflich und aggressiv. Websites können nun mal Bugs haben; es zu melden ist sinnvoll, aber dieser Kommentar klingt schon ziemlich gehässig
      Ich habe übrigens offenbar dieselben Reproduktionsschritte ausprobiert und bei mir kam überhaupt kein 404 vor. Getestet auf Handy und Desktop, mit aktiviertem und deaktiviertem JavaScript. Das deutet darauf hin, dass das beobachtete Verhalten komplizierter gewesen sein könnte, als es auf den ersten Blick aussah