Tree Borrows: Ein Modell der Aliasing-Regeln für unsafe Code in Rust
(plf.inf.ethz.ch)- Damit der Rust-Compiler Garantien für Pointer-Aliasing für Optimierungen nutzen kann, muss klar definiert sein, an welchen Stellen unsafe Code die Regeln verletzt
- Das bisherige Modell Stacked Borrows lieferte diesen Maßstab, konnte aber gängige Muster in realem unsafe Rust-Code und neuere Funktionen des Borrow Checkers nicht ausreichend abbilden
- Tree Borrows ersetzt die Kernstruktur von Stacked Borrows von einem Stack durch einen Baum und kann dadurch mehr gültige Muster ausdrücken
- Bei der Auswertung von 30.000 der am häufigsten verwendeten Rust-Crates gab es im Vergleich zu Stacked Borrows 54 % weniger abgelehnte Testfälle
- Mit einem Rocq-Beweis wurde gezeigt, dass die meisten bisherigen Optimierungen erhalten bleiben und auch neue Optimierungen wie Read-Read-Reorderings möglich sind
Notwendige Aliasing-Regeln in unsafe Rust
- Rust bietet mit seinem eigentumsbasierten Typsystem starke Garantien wie Speichersicherheit und die Vermeidung von Data Races
- Im Bereich von unsafe Code wird Sicherheit jedoch nicht automatisch garantiert; es braucht zusätzliche Regeln, die Programmierer einhalten müssen
- Der Compiler möchte die Garantien des Typsystems, insbesondere Informationen über Pointer-Aliasing, nutzen, um Optimierungen innerhalb von Funktionen zu verbessern
- Fehlerhaft geschriebener unsafe Code kann solche Optimierungen zunichtemachen, daher ist ein klarer Maßstab wichtig, welcher Code als „badly behaved“ gilt
- Die bestehende Arbeit Stacked Borrows definierte diesen Maßstab, hat aber Grenzen
- Sie lehnt mehrere Muster ab, die in echtem unsafe Rust-Code häufig vorkommen
- Sie bildet neu eingeführte fortgeschrittene Funktionen des Rust-Borrow-Checkers nicht ab
Ansatz von Tree Borrows und Ergebnisse der Evaluation
- Tree Borrows wird definiert, indem der zentrale Stack in Stacked Borrows durch einen Baum ersetzt wird
- Durch diese Strukturänderung werden die Einschränkungen des bisherigen Modells gelockert
- Bei der Auswertung von 30.000 der am häufigsten verwendeten Rust-Crates sank die Zahl der im Vergleich zu Stacked Borrows abgelehnten Testfälle um 54 %
- Ein Rocq-Beweis bestätigt außerdem Eigenschaften im Zusammenhang mit Optimierungen
- Die meisten Optimierungen, die Stacked Borrows erlaubte, bleiben erhalten
- Auch die wichtige neue Optimierung Read-Read-Reorderings wird möglich
- Tree Borrows erhielt den PLDI'25 Distinguished Paper Award
- Weiterführende Materialien
1 Kommentare
Kommentare auf Hacker News
Als Bonus gibt es auch einen aktuellen Vortrag von Ralf Jungs Gruppe, die versucht, die Ausführungssemantik von Rust in einem Rust-Dialekt in ausführbarer Form präzise zu spezifizieren: https://youtube.com/watch?v=yoeuW_dSe0o
Torvalds argumentiert schon lange, dass die strikten Aliasing-Regeln von C mehr schaden als nutzen, und das klingt überzeugend. Ein Beispiel ist hier: https://lore.kernel.org/all/CAHk-=wgq1DvgNVoodk7JKc6BuU1m9Un... Wer sich für das Thema interessiert, sollte auch den ganzen Thread lesen
Ob Rust grundlegend anders ist, wirkt nach meiner begrenzten Erfahrung nicht so. Zumindest gilt das umso mehr, wenn unsafe ins Spiel kommt
Ich halte sie für den Compiler nützlicher und für Programmierer weniger belastend. Außerdem gibt es in der Sprache tatsächlich einen Ausweg: Man kann rohe Pointer verwenden. Und es gibt auch Werkzeuge, um Code zu prüfen
Am Ende ist es, wie alles im Sprachdesign, ein Kompromiss, und vielleicht hat Rust hier einen neuen Sweet Spot für solche Optimierungen gefunden. Ob das stimmt, wird die Zeit zeigen
C hat mit
restricteine Art nukleare Option, die meiner Erfahrung nach bei clang und gcc nur dann einen Effekt hatte, wenn sie an Funktionsargumenten stand. Typbasierte Alias-Analyse ist im Allgemeinen schwer zu nutzen, und man kann auch nicht unendlich viele Kopien des Typsint64_terzeugen, noch würde man das wollen. Außerdem ist es lästig, dassmemcpyerzwungen wird, wenn man etwas als anderen Typ neu interpretieren willRust-Referenzen dagegen sind durch Lebensdauer, Gültigkeitsbereich und Veränderbarkeit fein abgegrenzt und kümmern sich wenig um den „physischen“ Typ selbst. Deshalb kann man denselben Speicher auch zwischen
&mut i32/&i32und&mut i64/&i64neu interpretieren und hin- und herwechseln. Solange eine unsafe-Abstraktion nicht gleichzeitig überlappende&mut-Referenzen ausgibt oder eine einzelne&mutin mehrere nicht überlappende&mutaufteilt, kann man mit gewöhnlichen sicheren Rust-Lese- und Schreibzugriffen halbe Werte oder mehrere Werte lesen und schreibenAlias-Analyse ist heute sehr wichtig, um gute Performance zu erreichen. Man sollte aber auch bedenken, dass die größten Gewinne aus den einfachsten Heuristiken stammen. Zum Beispiel: Zwei Loads, die denselben SSA-Wert als Pointer verwenden, müssen zwangsläufig aliasen
Aus LLVM-Sicht übernimmt BasicAA diese Rolle. Es ist eine Sammlung einfacher Heuristiken, die ungefähr sagt: „Wenn wir den Allokationspunkt eines Objekts verfolgen können, können wir Alias-Anfragen eindeutig beantworten, andernfalls wissen wir es nicht“
Die eigentliche Frage ist der Wert von Alias-Analysen, die über grundlegende und offensichtliche Prüfungen hinausgehen. Sobald Alias-Anfragen nicht mehr trivial aufzulösen sind, schrumpft in der Regel auch das, was man mit dem Ergebnis tun kann, erheblich, und es läuft fast nur noch darauf hinaus, Risiken bei Codebewegungen zu finden. Der Nutzen ist deutlich kleiner
Ein Experiment, das ich gern sehen würde, wäre, den gesamten Speedup zu messen, den eine theoretisch perfekte Alias-Analyse bringen würde. Meine Vermutung ist, dass es selbst bei Nicht-HPC-Code wie dem Linux-Kernel etwa 20% wären
[1] Das schließt keine heroischen Optimierungen wie Datenlayout-Transformationen ein, die man ohne hochwertige Alias-Analyse gar nicht erst versuchen würde. Da wir bereits wissen, dass eine solche Alias-Analyse praktisch nicht existiert, würde man solche Optimierungen auch nicht versuchen, und ich halte es nicht für sinnvoll, sie in den erwarteten Speedup einzurechnen
Cs Aliasing basiert ausschließlich auf Typen, daher auch der andere Name typbasierte Alias-Analyse oder TBAA
Ich habe eine Behauptung gefunden, dass
noaliaszur Laufzeit ungefähr 5% Performancegewinn beiträgt, aber die Quelle ist eindeutig sehr althttps://github.com/rust-lang/rust/issues/54878#issuecomment-...
https://news.ycombinator.com/item?id=22281205
https://news.ycombinator.com/item?id=17715399
Es klang so, als müsste der Compiler es ablehnen, wenn man aus
&mutein*mut i32erstellt und stattwrite(x)*x = 10verwendet, weil dann kein implizites zweiphasiges Borrowing genutzt wird; tatsächlich geht es aber durch*x = 10;einen Fehler, die Version mitwrite(x);dagegen nichtDer Fehler hat die Form „Undefined Behavior: attempting a write access using [...] but that tag does not exist in the borrow stack for this location“
rustc selbst hat keinen Grund, eines von beidem abzulehnen.
yist ein*mut, und aus Sicht des Typsystems zur Compile-Zeit hat es keine Borrow- oder Lebensdauerbeziehung zux, dem&mut.Der aktuelle Borrow-Checker verwendet eine restriktivere Analyse und erkennt genau diesen Konflikt zwischen Raw Pointer und veränderlicher Referenz nicht.
Nach praktischer Erfahrung ließ sie auch in [1] [2] sinnvollen Code zu, der unter Stacked Borrows illegal wäre.
[1] https://github.com/Voultapher/sort-research-rs/blob/main/wri... Miri-Spalte
[2] https://github.com/rust-lang/rust/blob/6b3ae3f6e45a33c2d95fa...
Es ist vom bereichsbasierten Borrow-Checker zum Borrow-Checker mit nicht-lexikalischen Lebensdauern gewechselt, und als nächste experimentelle Implementierung gibt es Polonius ebenfalls als Option. Sobald eine neue Implementierung produktionsreif ist, wird die alte allerdings verworfen, weil es keinen Grund gibt, sie auszuwählen.
Borrow-Checking ist schnell, und neue Implementierungen akzeptieren strikt mehr korrekte Programme.
Außerdem gibt es die Typen
RcundRefCell, mit denen man zum Preis von Runtime-Checks mehr Flexibilität bekommt.Sie unterscheiden sich alle bei Kosten und Fähigkeiten hinsichtlich Implementierung, Performance und Developer Experience.
Und was die meisten außerhalb von Rust tatsächlich anstreben, ist die Produktivität automatischer Ressourcenverwaltung: Man nutzt automatische Ressourcenverwaltung, wie auch immer sie umgesetzt ist, und kombiniert nur auf performancekritischen Pfaden eines der obigen Typsysteme damit.
In diesem Kontext kann man „Rust“ als nichts weiter ansehen als „die Invarianten, die Menschen normalerweise wollen“ plus „eine Sammlung von Optimierungen, die genau diese üblichen Invarianten annehmen, nicht mehr und nicht weniger“.
Der Großteil der Compile-Zeit geht für Trait-Resolution, Monomorphisierung, LLVM-Optimierungspässe und Linking drauf.
Vielleicht ist das eine dumme Frage, aber könnte man nicht mehrere Implementierungen in parallelen Threads laufen lassen und diejenige gewinnen lassen, die zuerst ein positives Ergebnis liefert?
Mehrere veränderliche Referenzen auf dieselbe Variable gleichzeitig per Pointer existieren zu lassen, ist undefiniertes Verhalten. So sieht es zumindest aus, falls ich die Absicht des Papers nicht missverstanden habe.
Der obige Code wird vom Rust-Compiler akzeptiert, verletzt aber Regeln. Die Frage ist, welche Regeln er verletzt.
Im Wesentlichen gilt: Was der Borrow-Checker akzeptiert, ist legal; unsafe kann auch Illegales oder undefiniertes Verhalten ausdrücken; und es gibt eine Regelmenge, die weiter ist als das, was der Borrow-Checker prüfen kann, aber dennoch legal und wohldefiniert bleibt.
Ziel dieser Forschung ist es, diese Regelmenge präzise zu spezifizieren. In groben Zügen läuft es auf „schreibbare Pointer dürfen keine Aliases haben“ hinaus, aber Details wie innere Pointer, Iterator-Invalidierung oder ob schon das Erzeugen eines schlechten Pointers problematisch ist oder erst dessen Verwendung, sind sehr schwierig.
Das frühere Stacked-Borrows-Paper war einfacher, aber restriktiver, sodass realer unsafe Code häufig nicht durch die Regeln kam. Tree Borrows ist weiter gefasst und erlaubt mehr Code, bleibt aber beweisbar sicher.
Tree Borrows schlägt genau eine solche Definition vor.
Wenn hier gesagt wird, „Code kann so etwas tun“, bedeutet das: „Man kann diesen Code schreiben, kompilieren und ausführen, und ohne etwas wie Tree Borrows gibt es keine Grundlage, um zu behaupten, dass mit diesem Code etwas nicht stimmt.“
Damit ist bereits akzeptiert, dass man sagen muss, dass solcher Code undefiniertes Verhalten ist – also dass etwas wie Tree Borrows nötig ist. Dieser Teil des Papers argumentiert, warum so etwas gebraucht wird.
https://play.rust-lang.org/?version=stable&mode=debug&editio...
Dort heißt es, dass Rust-Compiler-Entwickler offensichtlich Alias-Optimierungen unterstützen wollen und daher eine Möglichkeit brauchen, Gegenbeispiele wie das obige aus dem Betrachtungsbereich „auszuschließen“.
unsafeist für Fälle gedacht, in denen es schwierig ist, die Gültigkeit von Code mit Rusts Lebensdaueranalyse nachzuweisen, kann aber missbraucht werden, um weit mehr zu tun.