Rust: Durch die LLVM-Schleifenoptimierung können sichere Programme abstürzen

Erstellt am 29. Sept. 2015  ·  97Kommentare  ·  Quelle: rust-lang/rust

Das folgende Snippet stürzt ab, wenn es im Release-Modus auf Current Stable, Beta und Nightly kompiliert wird:

enum Null {}

fn foo() -> Null { loop { } }

fn create_null() -> Null {
    let n = foo();

    let mut i = 0;
    while i < 100 { i += 1; }
    return n;
}

fn use_null(n: Null) -> ! {
    match n { }
}


fn main() {
    use_null(create_null());
}

https://play.rust-lang.org/?gist=1f99432e4f2dccdf7d7e&version=stable

Dies basiert auf dem folgenden Beispiel für das Entfernen einer Schleife durch LLVM, auf die ich aufmerksam gemacht wurde: https://github.com/simnalamburt/snippets/blob/12e73f45f3/rust/infinite.rs.
Was zu passieren scheint, ist, dass, da C es LLVM ermöglicht, Endlosschleifen zu entfernen, die keine Nebenwirkungen haben, wir am Ende ein match ausführen, das bewaffnet werden muss.

A-LLVM C-bug E-medium I-needs-decision I-unsound 💥 P-medium T-compiler WG-embedded

Hilfreichster Kommentar

Für den Fall, dass jemand Testfallcode Golf spielen wollte:

pub fn main() {
   (|| loop {})()
}

Mit dem -Z insert-sideeffect @ @sfanxiang in https://github.com/rust-lang/rust/pull/59546 hinzugefügt wurde, wird die Schleife

Vor:

main:
  ud2

nach:

main:
.LBB0_1:
  jmp .LBB0_1

Alle 97 Kommentare

Die LLVM-IR des optimierten Codes ist

; Function Attrs: noreturn nounwind readnone uwtable
define internal void @_ZN4main20h5ec738167109b800UaaE() unnamed_addr #0 {
entry-block:
  unreachable
}

Diese Art der Optimierung verstößt gegen die Hauptannahme, die normalerweise für unbewohnte Typen gelten sollte: Es sollte unmöglich sein, einen Wert dieses Typs zu haben.
rust-lang / rfcs # 1216 schlägt vor, solche Typen in Rust explizit zu behandeln. Dies kann effektiv sein, um sicherzustellen, dass LLVM sie niemals verarbeiten muss, und um den entsprechenden Code einzufügen, um bei Bedarf eine Divergenz sicherzustellen (IIUIC dies könnte mit geeigneten Attributen oder intrinsischen Aufrufen erreicht werden).
Dieses Thema wurde kürzlich auch in der LLVM-Mailingliste behandelt: http://lists.llvm.org/pipermail/llvm-dev/2015-July/088095.html

Triage: Ich-nominiert

Scheint schlecht! Wenn LLVM keine Möglichkeit hat, "Ja, diese Schleife ist wirklich unendlich" zu sagen, müssen wir möglicherweise nur warten, bis sich die vorgelagerte Diskussion erledigt hat.

Eine Möglichkeit, zu verhindern, dass Endlosschleifen wegoptimiert werden, besteht darin, unsafe {asm!("" :::: "volatile")} in ihnen hinzuzufügen. Dies ähnelt dem in der LLVM-Mailingliste vorgeschlagenen llvm.noop.sideeffect intrinsic, kann jedoch einige Optimierungen verhindern.
Um den Leistungsverlust zu vermeiden und dennoch zu gewährleisten, dass divergierende Funktionen / Schleifen nicht wegoptimiert werden, sollte es meiner Meinung nach ausreichen, eine leere, nicht optimierbare Schleife (dh loop { unsafe { asm!("" :::: "volatile") } } ) einzufügen, wenn unbewohnte Werte vorhanden sind Umfang.
Wenn LLVM den Code optimiert, der so weit abweichen soll, dass er nicht mehr abweicht, stellen solche Schleifen sicher, dass der Kontrollfluss immer noch nicht fortgesetzt werden kann.
In einem "glücklichen" Fall, in dem LLVM den divergierenden Code nicht optimieren kann, wird eine solche Schleife von DCE entfernt.

Hat das etwas mit # 18785 zu tun? Es geht um eine unendliche Rekursion, um UB zu sein, aber es scheint, dass die fundamentale Ursache ähnlich sein könnte: LLVM betrachtet das Nicht-Anhalten nicht als Nebeneffekt. Wenn eine Funktion keine anderen Nebenwirkungen hat als das Nicht-Anhalten, ist sie gerne optimiert es weg.

@geofft

Es ist das gleiche Problem.

Ja, sieht so aus, als wäre es dasselbe. Weiter unten in dieser Ausgabe zeigen sie, wie man undef bekommt, von dem ich annehme, dass es nicht schwer ist, einen (scheinbar sicheren) Programmabsturz zu verursachen.

: +1:

Absturz oder möglicherweise noch schlimmeres Herzblut https://play.rust-lang.org/?gist=15a325a795244192bdce&version=stable

Ich habe mich gefragt, wie lange es dauert, bis jemand dies meldet. :) Meiner Meinung nach wäre die beste Lösung natürlich, wenn wir LLVM sagen könnten, dass sie nicht so aggressiv gegenüber potenziell Endlosschleifen sein sollen. Ansonsten können wir meiner Meinung nach nur eine konservative Analyse in Rust selbst durchführen, um festzustellen, ob:

  1. Die Schleife wird ODER beendet
  2. Die Schleife hat Nebenwirkungen (E / A-Operationen usw., ich vergesse genau, wie dies in C definiert ist).

Beides sollte ausreichen, um undefiniertes Verhalten zu vermeiden.

Triage: P-Medium

Wir würden gerne sehen, was LLVM tun wird, bevor wir uns viel Mühe geben, und dies scheint in der Praxis relativ unwahrscheinlich zu sein (obwohl ich dies auch bei der Entwicklung des Compilers persönlich getroffen habe). Es gibt keine Probleme mit der Rückwärtsinkomatizität, über die man sich Sorgen machen muss.

Zitat aus der LLVM-Mailinglistendiskussion:

 The implementation may assume that any thread will eventually do one of the following:
   - terminate
   - make a call to a library I/O function
   - access or modify a volatile object, or
   - perform a synchronization operation or an atomic operation

 [Note: This is intended to allow compiler transformations such as removal of empty loops, even
  when termination cannot be proven. — end note ]

@dotdash Der von Ihnen zitierte Auszug stammt aus der C ++ - Spezifikation. Es ist im Grunde die Antwort auf "wie es [mit Nebenwirkungen] in C definiert ist" (ebenfalls vom Standardausschuss bestätigt: http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1528 .htm).

In Bezug auf das erwartete Verhalten des LLVM-IR gibt es einige Verwirrung. https://llvm.org/bugs/show_bug.cgi?id=24078 zeigt, dass es keine genaue und explizite Spezifikation der Semantik von Endlosschleifen in LLVM IR zu geben scheint. Es stimmt mit der Semantik von C ++ überein, höchstwahrscheinlich aus historischen Gründen und aus praktischen Gründen (ich habe es nur geschafft, https://groups.google.com/forum/#!topic/llvm-dev/j2vlIECKkdE aufzuspüren, die sich anscheinend auf eine Zeit bezieht Wenn Endlosschleifen nicht entfernt wurden, einige Zeit bevor die C / C ++ - Spezifikationen aktualisiert wurden, um dies zu ermöglichen.

Aus dem Thread geht hervor, dass der Wunsch besteht, C ++ - Code so effektiv wie möglich zu optimieren (dh auch die Möglichkeit zu berücksichtigen, Endlosschleifen zu entfernen), aber im selben Thread haben mehrere Entwickler (einschließlich einiger, die aktiv zu LLVM beitragen) zeigte Interesse an der Fähigkeit, Endlosschleifen beizubehalten, wie sie für andere Sprachen benötigt werden.

@ ranma42 Ich bin mir dessen bewusst, ich habe dies nur als Referenz zitiert, da eine Möglichkeit, dies zu

Ist das ein Problem mit der Gesundheit? Wenn ja, sollten wir es als solches kennzeichnen.

Ja, anhand des Beispiels von Spielplatz Link

@bluss

Die Richtlinie lautet, dass Probleme mit falschem Code, die auch Probleme mit der Solidität sind (dh die meisten von ihnen), mit I-wrong .

Um die vorherige Diskussion noch einmal zusammenzufassen: Hier gibt es zwei Möglichkeiten, die ich sehen kann:

  • Warten Sie, bis LLVM eine Lösung gefunden hat.
  • Führen Sie no-op asm-Anweisungen überall dort ein, wo es eine Endlosschleife oder eine Endlosrekursion geben kann (# 18785).

Letzteres ist irgendwie schlecht, weil es die Optimierung hemmen kann, deshalb möchten wir es etwas sparsam machen - im Grunde überall dort, wo wir die Kündigung nicht selbst nachweisen können. Sie können sich auch vorstellen, wie LLVM optimiert wird - dh nur dann, wenn wir ein Szenario erkennen können, das LLVM als Endlosschleife / Rekursion betrachtet -, aber dies würde (a) die Verfolgung von LLVM und (b) erfordern ) erfordern tieferes Wissen, als ich zumindest besitze.

Warten Sie, bis LLVM eine Lösung gefunden hat.

Was ist der LLVM-Fehler, der dieses Problem verfolgt?

Randnotiz: while true {} zeigt dieses Verhalten . Vielleicht sollten die Flusen standardmäßig auf Fehler aktualisiert werden und eine Notiz erhalten, die besagt, dass dies derzeit ein undefiniertes Verhalten aufweisen kann?

Beachten Sie außerdem, dass dies für C. LLVM ungültig ist. Wenn Sie dieses Argument verwenden, bedeutet dies, dass ein Fehler in clang vorliegt.

void foo() { while (1) { } }

void create_null() {
        foo();

        int i = 0;
        while (i < 100) { i += 1; }
}

__attribute__((noreturn))
void use_null() {
        __builtin_unreachable();
}


int main() {
        create_null();
        use_null();
}

Dies stürzt mit Optimierungen ab; Dies ist ein ungültiges Verhalten nach dem C11-Standard:

An iteration statement whose controlling expression is not a constant
expression, [note 156] that performs no  input/output  operations,
does  not  access  volatile  objects,  and  performs  no synchronization or
atomic operations in its body, controlling expression, or (in the case of
a for statement) its expression-3, may be   assumed   by   the
implementation to terminate. [note 157]

156: An omitted controlling expression is replaced by a nonzero constant,
     which is a constant expression.
157: This  is  intended  to  allow  compiler  transformations  such  as
     removal  of  empty  loops  even  when termination cannot be proven. 

Beachten Sie, dass "dessen steuernder Ausdruck kein konstanter Ausdruck ist" - while (1) { } , 1 ein konstanter Ausdruck ist und daher möglicherweise nicht entfernt wird .

Ist das Entfernen der Schleife ein Optimierungsdurchlauf, den wir einfach entfernen könnten?

@ubsan

Haben Sie einen Fehlerbericht dafür in LLVMs Bugzilla gefunden oder einen ausgefüllt? Es scheint, dass in C ++ Endlosschleifen, die _can_ niemals beenden können, undefiniertes Verhalten sind, aber in C sind sie definiertes Verhalten (entweder können sie in einigen Fällen sicher entfernt werden oder in anderen nicht).

Ich wiederhole mich von # 42009: Dieser Fehler kann unter Umständen dazu führen, dass eine extern aufrufbare Funktion ausgegeben wird, die überhaupt keine Maschinenanweisungen enthält. Das sollte niemals passieren. Wenn LLVM daraus schließt, dass ein pub fn niemals mit korrektem Code aufgerufen werden kann, sollte es mindestens eine Trap-Anweisung als Hauptteil dieser Funktion ausgeben.

Der LLVM-Fehler hierfür ist https://bugs.llvm.org/show_bug.cgi?id=965 (2006 eröffnet).

@zackw LLVM hat eine Flagge dafür: TrapUnreachable . Ich habe dies nicht getestet, aber es sieht so aus, als ob das Hinzufügen von Options.TrapUnreachable = true; zu LLVMRustCreateTargetMachine Ihr Anliegen ansprechen sollte. Es ist wahrscheinlich, dass dies so niedrig ist, dass es standardmäßig durchgeführt werden kann, obwohl ich keine Messungen durchgeführt habe.

@ oli-obk Es ist leider nicht nur ein Loop-Deletion-Pass. Das Problem ergibt sich aus allgemeinen Annahmen, zum Beispiel: (a) Zweige haben keine Nebenwirkungen, (b) Funktionen, die keine Anweisungen mit Nebenwirkungen enthalten, haben keine Nebenwirkungen, und (c) Aufrufe von Funktionen ohne Nebenwirkungen können verschoben werden oder gelöscht.

Es sieht so aus, als gäbe es einen Patch: https://reviews.llvm.org/D38336

@sunfishcode , sieht so aus, als ob Ihr LLVM-Patch unter https://reviews.llvm.org/D38336 am 3. Oktober "akzeptiert" wurde. Können Sie ein Update darüber geben, was dies für den Veröffentlichungsprozess von LLVM bedeutet? Was ist der nächste Schritt über die Akzeptanz hinaus und haben Sie eine Vorstellung davon, welche zukünftige LLVM-Version diesen Patch enthalten wird?

Ich habe mit einigen Leuten offline gesprochen, die vorgeschlagen haben, dass wir einen llvmdev-Thread haben. Der Thread ist hier:

http://lists.llvm.org/pipermail/llvm-dev/2017-October/118558.html

Es ist nun abgeschlossen, mit dem Ergebnis, dass ich zusätzliche Änderungen vornehmen muss. Ich denke, die Änderungen werden gut sein, obwohl ich etwas mehr Zeit dafür brauche.

Vielen Dank für das Update und vielen Dank für Ihre Bemühungen!

Beachten Sie, dass https://reviews.llvm.org/rL317729 in LLVM gelandet ist. Für diesen Patch ist ein Folge-Patch geplant, mit dem Endlosschleifen standardmäßig ein definiertes Verhalten aufweisen. AFAICT muss also nur warten, bis dies für uns im Upstream behoben ist.

@zackw Ich habe jetzt # 45920 erstellt, um das Problem von Funktionen zu beheben, die keinen Code enthalten.

@bstrie Ja, der erste Schritt ist gelandet, und ich arbeite am zweiten Schritt, damit LLVM standardmäßig Endlosschleifen definiert. Es ist eine komplexe Änderung, und ich weiß noch nicht, wie lange es dauern wird, bis sie abgeschlossen ist, aber ich werde hier Updates veröffentlichen.

@jsgf Immer noch Repro. Haben Sie den Release-Modus ausgewählt?

@ Kennethytm Woops,

Beachten Sie, dass https://reviews.llvm.org/rL317729 in LLVM gelandet ist. Für diesen Patch ist ein Folge-Patch geplant, mit dem Endlosschleifen standardmäßig ein definiertes Verhalten aufweisen. AFAICT muss also nur warten, bis dies für uns im Upstream behoben ist.

Seit diesem Kommentar sind einige Monate vergangen. Weiß jemand, ob der Follow-up-Patch passiert ist oder noch passieren wird?

Alternativ scheint es, dass die intrinsische llvm.sideeffect in der von uns verwendeten LLVM-Version vorhanden ist: Können wir dies selbst beheben, indem wir Rust-Endlosschleifen in LLVM-Schleifen übersetzen, die die intrinsische Nebenwirkung enthalten?

Wie zu sehen ist https://github.com/rust-lang/rust/issues/38136 und https://github.com/rust-lang/rust/issues/54214 , ist dies besonders schlecht bei den kommenden panic_implementation , als logische Implementierung davon wird loop {} , und dies würde alle Vorkommen von panic! UB ohne unsafe Code machen. Was… ist vielleicht das Schlimmste, was passieren könnte.

Ich bin gerade auf dieses Problem in einem anderen Licht gestoßen. Hier ist ein Beispiel:

pub struct Container<'f> {
    string: &'f str,
    num: usize,
}

impl<'f> From<&'f str> for Container<'f> {
    #[inline(always)]
    fn from(string: &'f str) -> Container<'f> {
        Container::from(string)
    }
}

fn main() {
    let x = Container::from("hello");
    println!("{} {}", x.string, x.num);

    let y = Container::from("hi");
    println!("{} {}", y.string, y.num);

    let z = Container::from("hello");
    println!("{} {}", z.string, z.num);
}

Dieses Beispiel unterscheidet zuverlässig von Stable, Beta und Nightly und zeigt, wie einfach es ist, nicht initialisierte Werte eines beliebigen Typs zu erstellen. Hier ist es auf dem Spielplatz .

@SergioBenitez das Programm nicht segfault, es endet mit einem Stapelüberlauf (Sie müssen es im Debug-Modus ausführen). Dies ist das richtige Verhalten, da Ihr Programm nur unendlich rekursiv ist und unendlich viel Stapelspeicherplatz benötigt, der irgendwann den verfügbaren Stapelspeicherplatz überschreiten wird. Minimales Arbeitsbeispiel .

In Release-Builds kann LLVM davon ausgehen, dass Sie keine unendliche Rekursion haben, und diese optimieren ( mwe ). Dies hat nichts mit AFAICT-Schleifen zu tun, sondern mit https://stackoverflow.com/a/5905171/1422197

@gnzlbg Sorry, aber du bist nicht korrekt.

Das Programm segfault im Release-Modus. Das ist der ganze Punkt; dass eine Optimierung zu fehlerhaftem Verhalten führt - dass LLVM und Rusts Semantik hier nicht übereinstimmen -, dass ich mit rustc ein sicheres Rust-Programm schreiben und kompilieren kann, das es mir ermöglicht, nicht initialisierten Speicher zu verwenden, beliebigen Speicher zu untersuchen und willkürlich zwischen Typen zu wechseln, was gegen diese Regeln verstößt die Semantik der Sprache. Das ist der gleiche Punkt, der in diesem Thread dargestellt wird. Beachten Sie, dass das ursprüngliche Programm auch im Debug-Modus keinen Segfault aufweist.

Sie scheinen auch vorzuschlagen, dass hier eine andere Optimierung ohne Schleife stattfindet. Dies ist unwahrscheinlich, wenn auch weitgehend irrelevant, obwohl es in diesem Fall möglicherweise ein separates Problem rechtfertigt. Ich vermute, dass LLVM die Schwanzrekursion bemerkt, sie als Endlosschleife behandelt und sie wieder optimiert, genau das, worum es in diesem Problem geht.

@gnzlbg Wenn Sie Ihre Optimierungsoption geringfügig von der unendlichen Rekursion entfernen ( hier ), wird ein nicht initialisierter Wert von NonZeroUsize generiert (der sich als… 0 herausstellt, also ein ungültiger Wert).

Und genau das hat

Sind wir uns einig, dass das Programm

Wenn ja, kann ich im Beispiel von @SergioBenitez keine loop s finden , daher weiß ich nicht, wie sich dieses Problem darauf loop s). Wenn ich falsch liege, verweisen Sie mich bitte auf die loop in Ihrem Beispiel.

Wie bereits erwähnt, geht LLVM davon aus, dass keine unendliche Rekursion stattfinden kann (es wird davon ausgegangen, dass alle Threads schließlich beendet werden), aber das wäre ein anderes Problem als dieses.

Ich habe weder die Optimierungen, die LLVM durchführt, noch den generierten Code für eines der Programme überprüft. Beachten Sie jedoch, dass ein Segfault nicht fehlerfrei ist, wenn nur der Segfault auftritt. Insbesondere Stapelüberläufe, die abgefangen werden (durch Stapeltests + eine nicht zugeordnete Schutzseite nach dem Ende des Stapels) und keine Probleme mit der Speichersicherheit verursachen, werden ebenfalls als Segfaults angezeigt. Segfaults können natürlich

@rkruppe Mein Programm ist fehlerhaft, weil eine Referenz auf einen zufälligen Speicherort erstellt werden durfte und die Referenz anschließend gelesen wurde. Das Programm kann trivial modifiziert werden, um stattdessen einen zufälligen Speicherort zu schreiben, und ohne allzu große Schwierigkeiten einen bestimmten Speicherort zu lesen / schreiben.

@gnzlbg Das Programm führt im Release-Modus keinen Stapelüberlauf durch. Im Freigabemodus führt das Programm keine Funktionsaufrufe durch. Der Stapel wird auf eine endliche Anzahl von Malen geschoben, nur um Einheimische zuzuweisen.

Das Programm stapelt im Freigabemodus keinen Überlauf.

Damit? Das einzige, was zählt, ist, dass das Beispielprogramm, das im Grunde fn foo() { foo() } , eine unendliche Rekursion hat, die von LLVM nicht zugelassen wird.

Das einzige, was zählt, ist, dass das Beispielprogramm, das im Grunde genommen fn foo () {foo ()} ist, eine unendliche Rekursion hat, die von LLVM nicht zugelassen wird.

Ich weiß nicht, warum du das sagst, als würde es irgendetwas lösen. LLVM, das unendliche Rekursionen und Schleifen von UB berücksichtigt und entsprechend optimiert, aber in Rust sicher ist, ist der springende Punkt dieser ganzen Ausgabe!

Autor von https://reviews.llvm.org/rL317729 hier, der bestätigt, dass ich den Folge-Patch noch nicht implementiert habe.

Sie können heute einen @llvm.sideeffect -Aufruf einfügen, um sicherzustellen, dass die Schleifen nicht entfernt werden. Das könnte einige Optimierungen deaktivieren, aber theoretisch nicht zu viele, da den Hauptoptimierungen beigebracht wurde, wie man sie versteht. Wenn man @llvm.sideeffect Aufrufe in alle Schleifen oder Dinge einfügt , die sich in Schleifen verwandeln könnten (Rekursion, Abwickeln, Reicht dies theoretisch aus, um das Problem hier zu beheben.

Natürlich wäre es schöner, den zweiten Patch an Ort und Stelle zu haben, so dass dies nicht notwendig ist. Ich weiß nicht, wann ich das wieder umsetzen werde.

Es gibt einen kleinen Unterschied, aber ich bin mir nicht sicher, ob es materiell ist oder nicht.

Rekursion

#[allow(unconditional_recursion)]
#[inline(never)]
pub fn via_recursion<T>() -> T {
    via_recursion()
}

fn main() {
    let a: String = via_recursion();
}
define internal void @_ZN10playground4main17h1daf53946e45b822E() unnamed_addr #2 personality i32 (i32, i32, i64, %"unwind::libunwind::_Unwind_Exception"*, %"unwind::libunwind::_Unwind_Context"*)* <strong i="9">@rust_eh_personality</strong> {
_ZN4core3ptr13drop_in_place17h95538e539a6968d0E.exit:
  ret void
}

Schleife

#[inline(never)]
pub fn via_loop<T>() -> T {
    loop {}
}

fn main() {
    let b: String = via_loop();
}
define internal void @_ZN10playground4main17h1daf53946e45b822E() unnamed_addr #2 {
start:
  unreachable
}

Meta

Rust 1.29.1, Kompilieren im Release-Modus, Anzeigen des LLVM-IR.

Ich denke nicht, dass wir im Allgemeinen eine Rekursion erkennen können (Merkmalsobjekte, C FFI usw.), daher müssten wir auf so ziemlich jeder Anrufseite llvm.sideeffect sei denn, wir können beweisen, dass der Anruf erfolgt Website wird nicht wiederkehren. Um zu beweisen, dass keine Rekursionen für die Fälle vorliegen, in denen dies nachgewiesen werden kann, ist eine interprozedurale Analyse erforderlich, mit Ausnahme der trivialsten Programme wie fn main() { main() } . Es ist möglicherweise gut zu wissen, welche Auswirkungen die Implementierung dieses Fixes hat und ob es alternative Lösungen für dieses Problem gibt.

@gnzlbg Das stimmt, obwohl Sie die @ llvm.sideeffects eher an den Einträgen von Funktionen als an den Aufrufstellen platzieren könnten.

Seltsamerweise kann ich das SEGFAULT im Testfall von @SergioBenitez nicht lokal reproduzieren.

Sollte es bei einem Stapelüberlauf nicht auch eine andere Fehlermeldung geben? Ich dachte, wir hätten Code zum Drucken von "Der Stapel ist übergelaufen" oder so?

@RalfJung hast du es im Debug-Modus versucht? (Ich kann den Stapelüberlauf im Debug-Modus auf meinen Maschinen und auf dem Spielplatz zuverlässig reproduzieren. Vielleicht müssen Sie einen Fehler beheben, wenn dies für Sie nicht der Fall ist.) In --release wird kein Stapelüberlauf angezeigt, da der gesamte Code falsch optimiert ist.


@sunfishcode

Das ist wahr, obwohl Sie die @ llvm.sideeffects eher an den Einträgen von Funktionen als an den Aufrufstellen platzieren könnten.

Es ist schwer zu sagen, was der beste Weg wäre, ohne genau zu wissen, welche Optimierungen llvm.sideeffects verhindern. Lohnt es sich zu versuchen, so wenig @llvm.sideeffects wie möglich zu generieren? Wenn nicht, ist es möglicherweise am einfachsten, es in jeden Funktionsaufruf einzufügen. Andernfalls hängt IIUC davon ab, was die Anrufseite tut, ob @llvm.sideeffect benötigt wird:

trait Foo {
    fn foo(&self) { self.bar() }
    fn bar(&self);
}

struct A;
impl Foo for A {
    fn bar(&self) {} // not recursive
}
struct B;
impl Foo for B {
    fn bar(&self) { self.foo() } // recursive
}

fn main() {
    let a = A;
    a.bar(); // Ok - no @llvm.sideeffect needed anywhere
    let b = B;
    b.bar(); // We need @llvm.sideeffect on this call site
    let c: &[&dyn Foo] = &[&a, &b];
    for i in c {
        i.bar(); // We need @lvm.sideeffect here too
    }
}

AFAICT, wir müssen @llvm.sideeffect in die Funktionen einfügen, um zu verhindern, dass sie entfernt werden. Selbst wenn sich diese "Optimierungen" gelohnt hätten, denke ich nicht, dass sie mit dem aktuellen Modell einfach zu tun sind. Selbst wenn dies der Fall wäre, würden diese Optimierungen darauf beruhen, dass nachgewiesen werden kann, dass es keine Rekursion gibt.

Hast du es im Debug-Modus versucht? (Ich kann den Stapelüberlauf im Debug-Modus auf meinen Maschinen und auf dem Spielplatz zuverlässig reproduzieren. Vielleicht müssen Sie einen Fehler beheben, wenn dies für Sie nicht der Fall ist.)

Sicher, aber im Debug-Modus führt LLVM die Schleifenoptimierungen nicht durch, sodass es kein Problem gibt.

Wenn der Programmstapel im Debug-Modus überläuft, sollte dies keine LLVM-Lizenz zum Erstellen von UB geben. Das Problem ist herauszufinden, ob das endgültige Programm UB hat, und vom Starren auf die IR kann ich nicht sagen. Es gibt Fehler, aber ich weiß nicht warum. Aber es scheint mir ein Fehler zu sein, ein Stapelüberlaufprogramm in ein Programm zu "optimieren", das fehlerhaft ist.

Wenn der Programmstapel im Debug-Modus überläuft, sollte dies keine LLVM-Lizenz zum Erstellen von UB geben.

Aber es scheint mir ein Fehler zu sein, ein Stapelüberlaufprogramm in ein Programm zu "optimieren", das fehlerhaft ist.

In C wird angenommen, dass ein Ausführungsthread beendet, flüchtige Speicherzugriffe, E / A oder eine synchronisierende atomare Operation ausführt. Es wäre für mich überraschend, wenn sich LLVM-IR nicht zufällig oder beabsichtigt so entwickeln würde, dass es dieselbe Semantik hat.

Der Rust-Code enthält einen Ausführungsthread, der niemals beendet wird und keine der Operationen ausführt, die erforderlich sind, damit dies nicht UB in C ist. Ich würde vermuten, dass wir dasselbe LLVM-IR generieren wie ein C-Programm mit undefiniertem Verhalten Daher finde ich es nicht verwunderlich, dass LLVM dieses Rust-Programm falsch optimiert.

Es gibt Fehler, aber ich weiß nicht warum.

LLVM entfernt die unendliche Rekursion, so dass das Programm, wie @SergioBenitez fortfährt mit:

Es wurde erlaubt, eine Referenz auf einen zufälligen Speicherort zu konstruieren, und die Referenz wurde anschließend gelesen.

Der Teil des Programms, der dies tut, ist dieser:

let x = Container::from("hello");  // invalid reference created here
println!("{} {}", x.string, x.num);  // invalid reference dereferenced here

Wenn Container::from eine unendliche Rekursion startet, die LLVM zu dem Schluss kommt, dass dies niemals passieren kann, wird sie durch einen zufälligen Wert ersetzt, der dann dereferenziert wird. Sie können eine der vielen Möglichkeiten sehen, wie dies falsch optimiert wird: https://rust.godbolt.org/z/P7Snex Auf dem Spielplatz (https://play.rust-lang.org/?gist=f00d41cc189f9f6897d429350f3781ec&version=stable&mode) = release & edition = 2015) Aufgrund dieser Optimierung tritt bei der Veröffentlichung von Debug-Builds eine andere Panik auf, aber UB ist UB ist UB.

Der Rust-Code enthält einen Ausführungsthread, der niemals beendet wird und keine der Operationen ausführt, die erforderlich sind, damit dies nicht UB in C ist. Ich würde vermuten, dass wir dasselbe LLVM-IR generieren wie ein C-Programm mit undefiniertem Verhalten Daher finde ich es nicht verwunderlich, dass LLVM dieses Rust-Programm falsch optimiert.

Ich hatte den Eindruck, dass Sie oben argumentiert haben, dass dies nicht der gleiche Fehler ist wie das Endlosschleifenproblem. Es scheint, dass ich Ihre Nachrichten dann falsch verstanden habe. Entschuldigung für die Verwirrung.

Es scheint also ein guter nächster Schritt zu sein, einige llvm.sideffect in die von uns erzeugte IR zu streuen und einige Benchmarks durchzuführen?

In C wird angenommen, dass ein Ausführungsthread beendet, flüchtige Speicherzugriffe, E / A oder eine synchronisierende atomare Operation ausführt.

Übrigens ist dies nicht ganz richtig - eine Schleife mit einer konstanten Bedingung (wie while (true) { /* ... */ } ) wird vom Standard explizit zugelassen, auch wenn sie keine Nebenwirkungen enthält. Dies ist in C ++ anders. LLVM implementiert den C-Standard hier nicht korrekt.

Ich hatte den Eindruck, dass Sie oben argumentiert haben, dass dies nicht der gleiche Fehler ist wie das Endlosschleifenproblem.

Das Verhalten nicht terminierender Rust-Programme ist immer definiert, während das Verhalten nicht terminierender LLVM-IR-Programme nur definiert ist, wenn bestimmte Bedingungen erfüllt sind.

Ich dachte, dass es bei diesem Problem darum geht, die Implementierung von Rust für Endlosschleifen so zu korrigieren, dass das Verhalten des generierten LLVM-IR definiert wird, und dafür klang @llvm.sideeffect wie eine ziemlich gute Lösung.

@SergioBenitez erwähnte, dass man auch nicht terminierende Rust-Programme mit Rekursion erstellen kann, und @rkruppe argumentierte, dass unendliche Rekursion und Endlosschleifen gleichwertig sind, so dass beide der gleiche Fehler sind.

Ich bin nicht anderer Meinung, dass diese beiden Probleme zusammenhängen oder sogar der gleiche Fehler sind, aber für mich sehen diese beiden Probleme etwas anders aus:

  • In Bezug auf die Lösung wenden wir eine Optimierungsbarriere ( @llvm.sideeffect ) ausschließlich auf nicht terminierende Schleifen an, um sie auf jede einzelne Rust-Funktion anzuwenden.

  • Wertmäßig sind unendlich viele loop s nützlich, da das Programm niemals beendet wird. Bei unendlicher Rekursion hängt es von der Optimierungsstufe ab, ob das Programm beendet wird (z. B. ob LLVM die Rekursion in eine Schleife umwandelt oder nicht), und wann und wie das Programm beendet wird, hängt von der Plattform ab (Stapelgröße, geschützte Schutzseite usw.). Das Beheben beider Probleme ist erforderlich, damit die Rust-Implementierung einwandfrei funktioniert. Wenn der Benutzer jedoch im Falle einer unendlichen Rekursion beabsichtigt, dass sein Programm für immer wiederkehrt, ist eine solide Implementierung immer noch "falsch" in dem Sinne, dass sie nicht immer für immer wiederkehrt.

In Bezug auf die Lösung wenden wir eine Optimierungsbarriere (@ llvm.sideeffect) ausschließlich auf nicht terminierende Schleifen an, um sie auf jede einzelne Rust-Funktion anzuwenden.

Die Analyse, die erforderlich ist, um zu zeigen, dass ein Schleifenkörper tatsächlich Nebenwirkungen hat (nicht nur potenziell , wie bei Aufrufen externer Funktionen) und daher keine Einfügung von llvm.sideeffect ist ziemlich schwierig, wahrscheinlich in ungefähr derselben Reihenfolge von Größe, die dasselbe für eine Funktion zeigt, die Teil einer unendlichen Rekursion sein kann. Es ist auch schwierig zu beweisen, dass eine Schleife beendet wird, ohne vorher viele Optimierungen vorzunehmen, da die meisten Rust-Schleifen Iteratoren beinhalten. Ich denke also, wir würden llvm.sideeffect in die überwiegende Mehrheit der Loops stecken, unabhängig davon. Zugegeben, es gibt einige Funktionen, die keine Schleifen enthalten, aber es scheint mir immer noch kein qualitativer Unterschied zu sein.

Wenn ich das Problem richtig verstehe, sollte es ausreichen, um den Fall der Endlosschleife zu beheben, llvm.sideeffect in loop { ... } und while <compile-time constant true> { ... } einzufügen, wobei der Hauptteil der Schleife kein break enthält

Ich weiß nicht, was ich gegen die unendliche Rekursion tun soll, aber ich stimme RalfJung zu, dass die Optimierung einer unendlichen Rekursion in einen nicht verwandten Segfault kein wünschenswertes Verhalten ist.

@ Zackw

Wenn ich das Problem richtig verstehe, sollte es ausreichen, llvm.sideeffect in die Schleife {...} und while einzufügen, um den Endlosschleifenfall zu beheben{...} wobei der Hauptteil der Schleife keine Unterbrechungsausdrücke enthält.

Ich denke nicht, dass es so einfach ist, zB loop { if false { break; } } ist eine Endlosschleife, die einen break Ausdruck enthält, aber wir müssen @llvm.sideeffect einfügen, um zu verhindern, dass llvm ihn entfernt. AFAICT müssen wir @llvm.sideeffect einfügen, es sei denn, wir können beweisen, dass die Schleife immer endet.

@gnzlbg

loop { if false { break; } } ist eine Endlosschleife, die einen Umbruchausdruck enthält. Wir müssen jedoch @llvm.sideeffect einfügen, um zu verhindern, dass llvm ihn entfernt.

Hm, ja, das ist mühsam. Aber wir müssen nicht perfekt sein, sondern nur konservativ korrekt. Eine Schleife wie

while spinlock.load(Ordering::SeqCst) != 0 {}

(aus der std::sync::atomic -Dokumentation) würde leicht erkennen, dass kein @llvm.sideeffect , da die Steuerungsbedingung nicht konstant ist (und eine Atomlastoperation besser als Nebeneffekt für LLVM-Zwecke gelten sollte oder wir haben größere Probleme). Die Art der endlichen Schleife, die von einem Programmgenerator ausgegeben werden kann,

loop {
    if /* runtime-variable condition */ { break }
    /* more stuff */
}

sollte auch nicht störend sein. Gibt es tatsächlich einen Fall, in dem die Regel "Keine Unterbrechungsausdrücke im Hauptteil der Schleife" falsch ist?

loop {
    if /* provably false at compile time */ { break }
}

?

Ich dachte, dass es bei diesem Problem darum geht, die Implementierung von Rust für Endlosschleifen so zu korrigieren, dass das Verhalten des generierten LLVM-IR definiert wird, und dafür klang @ llvm.sideeffect wie eine ziemlich gute Lösung.

Fair genug. Wie Sie bereits sagten, geht es bei dem Problem (der Nichtübereinstimmung zwischen Rust-Semantik und LLVM-Semantik) tatsächlich um die Nichtbeendigung und nicht um Schleifen. Ich denke, das sollten wir hier verfolgen.

@ Zackw

Wenn ich das Problem richtig verstehe, sollte es ausreichen, llvm.sideeffect in die Schleife {...} und while einzufügen, um den Endlosschleifenfall zu beheben{...} wobei der Hauptteil der Schleife keine Unterbrechungsausdrücke enthält. Dies erfasst den Unterschied zwischen C ++ - Semantik und Rust-Semantik für Endlosschleifen: In Rust darf der Compiler im Gegensatz zu C ++ nicht davon ausgehen, dass eine Schleife beendet wird, wenn sie zur Kompilierungszeit erkennbar ist, dass dies nicht der Fall ist. (Ich bin mir nicht sicher, wie sehr wir uns angesichts von Schleifen, bei denen der Körper in Panik geraten könnte, um die Richtigkeit sorgen müssen, aber das kann später immer noch verbessert werden.)

Was Sie beschreiben, gilt für C. In Rust darf jede Schleife divergieren. Alles andere wäre einfach nicht in Ordnung.

So zum Beispiel

while test_fermats_last_theorem_on_some_random_number() { }

ist ein in Ordnung befindliches Programm in Rust (aber weder in C noch in C ++), und es wird für immer wiederholt, ohne dass ein Nebeneffekt auftritt. Es müssen also alle Schleifen sein, außer denen, von denen wir nachweisen können, dass sie enden.

@ Zackw

Gibt es einen Fall, dass die Regel "Keine Unterbrechungsausdrücke im Hauptteil der Schleife" außerdem falsch ist?

Es ist nicht nur if /*compile-time condition */ . Der gesamte Kontrollfluss ist betroffen ( while , match , for , ...) und auch die Laufzeitbedingungen sind betroffen.

Aber wir müssen nicht perfekt sein, sondern nur konservativ korrekt.

Erwägen:

fn foo(x: bool) { loop { if x { break; } } }

Dabei ist x eine Laufzeitbedingung. Wenn wir hier nicht @llvm.sideeffect ausgeben, dann könnte, wenn der Benutzer irgendwo foo(false) schreibt, foo eingefügt werden und bei konstanter Weitergabe und Eliminierung von totem Code die Schleife in eine optimiert werden Endlosschleife ohne Nebenwirkungen, was zu einer Fehloptimierung führt.

Wenn dies sinnvoll ist, besteht eine Transformation, die LLVM ausführen darf, darin, foo durch foo_opt ersetzen:

fn foo_opt(x: bool) { if x { foo(true) } else { foo(false) } }

Dabei werden beide Zweige unabhängig voneinander optimiert, und der zweite Zweig wäre falsch optimiert, wenn wir nicht @llvm.sideeffect .

Das heißt, um @llvm.sideeffect weglassen zu können, müssten wir beweisen, dass LLVM diese Schleife unter keinen Umständen falsch optimieren kann. Der einzige Weg, dies zu beweisen, besteht darin, entweder zu beweisen, dass die Schleife immer endet, oder zu beweisen, dass sie, wenn sie nicht beendet wird, bedingungslos eines der Dinge tut, die Fehloptimierungen verhindern. Selbst dann könnten Optimierungen wie das Aufteilen / Schälen von Schleifen eine Schleife in eine Reihe von Schleifen umwandeln, und es würde ausreichen, wenn eine von ihnen nicht @llvm.sideeffect für eine Fehloptimierung hätte.

Alles an diesem Fehler klingt für mich so, als wäre es viel einfacher, ihn mit LLVM zu lösen als mit rustc . (Haftungsausschluss: Ich kenne die Codebasis eines dieser Projekte nicht wirklich.)

Soweit ich weiß, würde der Fix von LLVM die Optimierungen von "Weiterlaufen" (Nichtbeendigung beweisen || kann dies auch nicht beweisen) in "Ausführen" ändern, wenn Nichtbeendigung nachgewiesen werden kann (oder umgekehrt). Ich sage nicht, dass dies (in irgendeiner Weise) einfach ist, aber LLVM enthält bereits (ich denke) Code, um zu versuchen, die (Nicht-) Beendigung von Schleifen zu beweisen.

Auf der anderen Seite kann rustc nur @llvm.sideeffect hinzufügen, was möglicherweise mehr Auswirkungen auf die Optimierung hat, als „nur“ die Optimierungen zu deaktivieren, die die Nichtbeendigung unangemessen nutzen. Und rustc müsste neuen Code einbetten, um zu versuchen, die (Nicht-) Beendigung von Schleifen zu erkennen.

Ich würde also denken, der Weg nach vorne wäre:

  1. Fügen Sie @llvm.sideeffect zu jeder Schleife und jedem Funktionsaufruf hinzu, um das Problem zu beheben
  2. Korrigieren Sie LLVM, um keine falschen Optimierungen für nicht terminierende Schleifen durchzuführen, und entfernen Sie die @llvm.sideeffects

Was denkst du darüber? Ich hoffe, dass die Auswirkungen von Schritt 1 auf die Leistung nicht allzu schrecklich sind, selbst wenn sie nach der Implementierung von 2 verschwinden sollen.

@Ekleog darum geht es beim zweiten Patch von https://lists.llvm.org/pipermail/llvm-dev/2017-October/118595.html

Teil des Funktionsattributvorschlags ist zu
Ändern Sie die Standardsemantik von LLVM IR so, dass das Verhalten aktiviert ist
Endlosschleifen, und fügen Sie dann ein Attribut hinzu, das sich für Potential-UB entscheidet. Damit
Wenn wir das tun, wird die Rolle von @ llvm.sideeffect ein wenig
subtil - es wäre eine Möglichkeit für ein Frontend, eine Sprache wie C zu wählen
in Potential-UB für eine Funktion, aber dann für einzelne abmelden
Schleifen in dieser Funktion.

Um LLVM gegenüber fair zu sein, nähern sich Compiler-Autoren diesem Thema nicht aus der Perspektive von "Ich werde eine Optimierung schreiben, die beweist, dass Schleifen nicht terminiert sind, damit ich sie pedantisch optimieren kann!" Stattdessen tritt die Annahme, dass Schleifen entweder beendet werden oder Nebenwirkungen haben, bei einigen gängigen Compiler-Algorithmen auf natürliche Weise auf. Dies zu beheben ist nicht nur eine Optimierung des vorhandenen Codes. es wird eine erhebliche Menge neuer Komplexität erfordern.

Betrachten Sie den folgenden Algorithmus, um zu testen, ob ein Funktionskörper "keine Nebenwirkungen hat": Wenn eine Anweisung im Körper potenzielle Nebenwirkungen hat, kann der Funktionskörper Nebenwirkungen haben. Schön und einfach. Später werden Aufrufe von Funktionen "ohne Nebenwirkungen" gelöscht. Cool. Es wird jedoch davon ausgegangen, dass Verzweigungsanweisungen keine Nebenwirkungen haben. Daher scheint eine Funktion, die nur Verzweigungen enthält, keine Nebenwirkungen zu haben, obwohl sie möglicherweise eine Endlosschleife enthält. Hoppla.

Es ist reparabel. Wenn jemand anderes daran interessiert ist, ist meine Grundidee, das Konzept von "hat Nebenwirkungen" in unabhängige Konzepte von "hat tatsächliche Nebenwirkungen" und "kann nicht terminieren" aufzuteilen. Und dann gehen Sie den gesamten Optimierer durch und finden Sie alle Stellen, die sich für "Nebenwirkungen" interessieren, und finden Sie heraus, welche Konzepte sie tatsächlich benötigen. Und dann lehren Sie die Schleifenübergänge, Metadaten zu Zweigen hinzuzufügen, die nicht Teil einer Schleife sind, oder die Schleifen, in denen sie sich befinden, sind nachweislich endlich, um Pessimisierungen zu vermeiden.


Ein möglicher Kompromiss könnte darin bestehen, rustc @ llvm.sideeffect einfügen zu lassen, wenn ein Benutzer buchstäblich eine leere loop { } (oder ähnliche) oder bedingungslose Rekursion (die bereits eine Fussel hat) schreibt. Dieser Kompromiss würde es Menschen, die tatsächlich eine unendliche, wirkungslose Drehschleife beabsichtigen, ermöglichen, diese zu erhalten, während jeder Overhead für alle anderen vermieden wird. Natürlich würde dieser Kompromiss es nicht unmöglich machen, sicheren Code zum Absturz zu bringen, aber er würde wahrscheinlich die Wahrscheinlichkeit verringern, dass er versehentlich auftritt, und es scheint, dass er einfach zu implementieren sein sollte.

Stattdessen tritt die Annahme, dass Schleifen entweder beendet werden oder Nebenwirkungen haben, bei einigen gängigen Compiler-Algorithmen auf natürliche Weise auf.

Es ist jedoch völlig unnatürlich, wenn Sie überhaupt anfangen, über die Richtigkeit dieser Transformationen nachzudenken. Um ehrlich zu sein, ich denke immer noch, dass es ein großer Fehler von C war, diese Annahme jemals zuzulassen, aber gut.

Wenn eine Anweisung im Körper potenzielle Nebenwirkungen hat, kann der Funktionskörper Nebenwirkungen haben.

Es gibt einen guten Grund, warum "Nichtbeendigung" normalerweise als Effekt angesehen wird, wenn Sie anfangen, die Dinge formal zu betrachten. (Haskell ist nicht rein, es hat zwei Auswirkungen: Nichtbeendigung und Ausnahmen.)

Ein möglicher Kompromiss könnte darin bestehen, rustc @ llvm.sideeffect einfügen zu lassen, wenn ein Benutzer buchstäblich eine leere Schleife {} (oder eine ähnliche) oder bedingungslose Rekursion (die bereits eine Fluse hat) schreibt. Dieser Kompromiss würde es Menschen, die tatsächlich eine unendliche, wirkungslose Drehschleife beabsichtigen, ermöglichen, diese zu erhalten, während jeder Overhead für alle anderen vermieden wird. Natürlich würde dieser Kompromiss es nicht unmöglich machen, sicheren Code zum Absturz zu bringen, aber er würde wahrscheinlich die Wahrscheinlichkeit verringern, dass er versehentlich auftritt, und es scheint, dass er einfach zu implementieren sein sollte.

Wie Sie selbst bemerkt haben, ist dies immer noch falsch. Ich denke nicht, dass wir eine "Lösung" akzeptieren sollten, von der wir wissen , dass sie falsch ist. Compiler sind so ein wesentlicher Bestandteil unserer Infrastruktur, dass wir nicht nur hoffen sollten, dass nichts schief geht. Dies ist keine Möglichkeit, ein solides Fundament aufzubauen.


Was hier passiert , ist , dass der Begriff der Korrektheit wurde gebaut um das, was Compiler tat, statt beginnend mit „Was wollen wir von unseren Compiler“ und dann , dass ihre Spezifikation zu machen. Ein korrekter Compiler verwandelt ein Programm, das immer in ein Programm abweicht, das endet, Punkt. Ich finde das ziemlich selbstverständlich, aber da Rust ein vernünftiges Typensystem hat, ist dies sogar deutlich in den Typen zu sehen, weshalb das Problem regelmäßig auftaucht.

Angesichts der Einschränkungen, mit denen wir arbeiten (nämlich LLVM), sollten wir zunächst llvm.sideeffect an genügend Stellen hinzufügen, sodass bei jeder divergierenden Ausführung garantiert unendlich viele davon "ausgeführt" werden. Dann haben wir eine vernünftige (wie in, solide und korrekte) Grundlinie erreicht und können über Verbesserungen sprechen, indem wir diese Anmerkungen entfernen, wenn wir garantieren können, dass sie nicht benötigt werden.

Um meinen Punkt genauer zu machen, denke ich, dass das Folgende eine solide Rostkiste ist, bei der pick_a_number_greater_2 (nicht deterministisch) eine Art Big-Int zurückgibt:

fn test_fermats_last_theorem() -> bool {
  let x = pick_a_number_greater_2();
  let y = pick_a_number_greater_2();
  let z = pick_a_number_greater_2();
  let n = pick_a_number_greater_2();
  // x^n + y^n = z^n is impossible for n > 2
  pow(x, n) + pow(y, n) != pow(z, n)
}

pub fn diverge() -> ! {
  while test_fermats_last_theorem() { }
  // This code is unreachable, as proven by Andrew Wiles
  unsafe { mem::transmute(()) }
}

Wenn wir diese divergierende Schleife wegkompilieren, ist das ein Fehler und sollte behoben werden.

Wir haben noch nicht einmal Zahlen darüber, wie viel Leistung es kosten würde, dies naiv zu beheben. Bis dahin sehe ich keinen Grund, Programme wie das oben genannte absichtlich zu unterbrechen.

In der Praxis wird fn foo() { foo() } aufgrund von Ressourcenerschöpfung immer beendet. Da die abstrakte Rust-Maschine jedoch über einen unendlich großen Stapelrahmen (AFAIK) verfügt, ist es gültig, diesen Code in fn foo() { loop {} } umzuwandeln, was niemals der Fall sein wird beenden (oder viel später, wenn das Universum gefriert). Sollte diese Transformation gültig sein? Ich würde ja sagen, da wir sonst keine Tail-Call-Optimierungen durchführen können, wenn wir nicht die Beendigung nachweisen können, was unglücklich wäre.

Wäre es sinnvoll, ein intrinsisches unsafe , das besagt, dass eine bestimmte Schleife, Rekursion, ... immer endet? N1528 gibt ein Beispiel dafür, dass, wenn nicht angenommen werden kann, dass Schleifen beendet werden, die Schleifenfusion nicht auf durchläuft , da die verknüpften Listen zirkulär sein können und der Nachweis, dass eine verknüpfte Liste nicht zirkulär ist, moderne Compiler nicht können tun.

Ich stimme absolut zu, dass wir dieses Problem der Solidität endgültig beheben müssen. Die Vorgehensweise sollte jedoch die Möglichkeit berücksichtigen, dass " llvm.sideeffect überall hinzufügen, wo wir nicht beweisen können, dass es unnötig ist" die Codequalität von Programmen, die heute korrekt kompiliert werden, beeinträchtigen kann. Während solche Bedenken letztendlich durch die Notwendigkeit eines Sound-Compilers überwunden werden, kann es ratsam sein, so vorzugehen, dass die ordnungsgemäße Korrektur etwas verzögert wird, um Leistungsrückgänge zu vermeiden und die Lebensqualität für den durchschnittlichen Rust-Programmierer im Mittel zu verbessern Zeit. Ich schlage vor:

  • Wie bei anderen Korrekturen, die möglicherweise die Leistung beeinträchtigen, für langjährige Soliditätsfehler (# 10184) sollten wir die Korrektur hinter einem -Z-Flag implementieren, um die Auswirkungen auf die Leistung auf Codebasen in freier Wildbahn bewerten zu können.
  • Wenn sich herausstellt, dass die Auswirkungen vernachlässigbar sind, können wir den Fix standardmäßig aktivieren.
  • Aber wenn es echte Regressionen gibt, können wir diese Daten an LLVM-Leute weitergeben und versuchen, LLVM zuerst zu verbessern (oder wir könnten uns dafür entscheiden, die Regression zu essen und sie später zu beheben, aber auf jeden Fall würden wir eine fundierte Entscheidung treffen).
  • Wenn wir uns entscheiden, das Update aufgrund von Regressionen nicht standardmäßig zu aktivieren, können wir zumindest syntaktisch leere Schleifen mit llvm.sideeffect : Sie sind ziemlich häufig und werden falsch kompiliert, was dazu führt, dass mehrere Personen miserabel ausgeben Stundenlanges Debuggen seltsamer Probleme (# 38136, # 47537, # 54214, und sicherlich gibt es noch mehr). Obwohl diese Abschwächung keinen Einfluss auf den Soliditätsfehler hat, hätte sie für Entwickler einen spürbaren Vorteil, während wir die Knicke in der richtigen Weise ausarbeiten Bug-Fix.

Zugegebenermaßen wird diese Perspektive durch die Tatsache geprägt, dass dieses Thema seit Jahren besteht. Wenn es eine neue Regression wäre, wäre ich offener dafür, sie schneller zu beheben oder die PR, die sie eingeführt hat, zurückzusetzen.

Sollte dies in der Zwischenzeit unter https://doc.rust-lang.org/beta/reference/behavior-considered-undefined.html erwähnt werden, solange dieses Problem offen ist?

Wäre es sinnvoll, ein intrinsisches unsafe , das besagt, dass eine bestimmte Schleife, Rekursion, ... immer endet?

std::hint::reachable_unchecked ?

Übrigens bin ich auf dieses Schreiben von echtem Code für ein TCP-Nachrichtensystem gestoßen. Ich hatte eine Endlosschleife als Notlösung, bis ich einen echten Mechanismus zum Stoppen einführte, aber der Thread sofort beendet wurde.

Für den Fall, dass jemand Testfallcode Golf spielen wollte:

fn main() {
    (|| loop {})()
}

`` `
$ Cargo Run - Release
Illegale Anweisung (Core Dumped)

Für den Fall, dass jemand Testfallcode Golf spielen wollte:

pub fn main() {
   (|| loop {})()
}

Mit dem -Z insert-sideeffect @ @sfanxiang in https://github.com/rust-lang/rust/pull/59546 hinzugefügt wurde, wird die Schleife

Vor:

main:
  ud2

nach:

main:
.LBB0_1:
  jmp .LBB0_1

Der LLVM-Fehler, der dies verfolgt, ist übrigens https://bugs.llvm.org/show_bug.cgi?id=965 , den ich in diesem Thread noch nicht gesehen habe.

was ich noch nicht in diesem Thread gepostet gesehen habe.

https://github.com/rust-lang/rust/issues/28728#issuecomment -331460667 und https://github.com/rust-lang/rust/issues/28728#issuecomment -263956134

@RalfJung Können Sie den Hyperlink https://github.com/simnalamburt/snippets/blob/master/rust/src/bin/infinite.rs in der Problembeschreibung in https://github.com/simnalamburt/snippets/blob aktualisieren ? /12e73f45f3/rust/infinite.rs das? Der frühere Hyperlink war lange Zeit unterbrochen, da es sich nicht um einen Permalink handelte. Vielen Dank! 😛

@ Simnalamburt fertig, danke!

Das Erhöhen des MIR-Opt-Levels scheint die Fehloptimierung im folgenden Fall zu vermeiden:

pub fn main() {
   (|| loop {})()
}

--emit=llvm-ir -C opt-level=1

define void @_ZN7example4main17hf7943ea78b0ea0b0E() unnamed_addr #0 !dbg !6 {
  unreachable
}

--emit=llvm-ir -C opt-level=1 -Z mir-opt-level=2

define void @_ZN7example4main17hf7943ea78b0ea0b0E() unnamed_addr #0 !dbg !6 {
  br label %bb1, !dbg !10

bb1:                                              ; preds = %bb1, %start
  br label %bb1, !dbg !11
}

https://godbolt.org/z/N7VHnj

rustc 1.45.0-nightly (5fd2f06e9 2020-05-31)

pub fn oops() {
   (|| loop {})() 
}

pub fn main() {
   oops()
}

Es hat in diesem speziellen Fall geholfen, aber das Problem im Allgemeinen nicht gelöst. https://godbolt.org/z/5hv87d

Im Allgemeinen kann dieses Problem nur gelöst werden, wenn entweder rustc oder LLVM nachweisen können, dass eine reine Funktion vollständig ist, bevor relevante Optimierungen verwendet werden.

In der Tat habe ich nicht behauptet, dass es das Problem gelöst hat. Der subtile Effekt war für andere interessant genug, dass es auch hier erwähnenswert schien. -Z insert-sideeffect korrigiert weiterhin beide Fälle.

Auf der LLVM-Seite bewegt sich etwas: Es gibt einen Vorschlag, ein Attribut auf Funktionsebene hinzuzufügen, um die Fortschrittsgarantien zu steuern. https://reviews.llvm.org/D85393

Ich bin mir nicht sicher, warum alle (hier und in den LLVM-Threads) die Klausel über den Fortschritt nach vorne zu betonen scheinen.

Die Eliminierung der Schleife scheint eine direkte Folge eines Speichermodells zu sein: Berechnungen von Werten dürfen verschoben werden, solange sie stattfinden - vor der Verwendung des Werts. Wenn es nun einen Beweis gibt, dass der Wert nicht verwendet werden kann, ist dies der Beweis dafür, dass dies nicht vorher passiert, und der Code kann unendlich weit in die Zukunft verschoben werden und dennoch das Speichermodell erfüllen.

Wenn Sie mit Speichermodellen nicht vertraut sind, sollten Sie berücksichtigen, dass die gesamte Schleife in eine Funktion abstrahiert wird, die einen Wert berechnet. Ersetzen Sie nun alle Lesevorgänge des Werts außerhalb der Schleife durch einen Aufruf dieser Funktion. Diese Transformation ist sicherlich gültig. Wenn der Wert nicht verwendet wird, gibt es keine Aufrufe der Funktion, die die Endlosschleife ausführt.

Berechnungen von Werten dürfen verschoben werden, solange sie vor der Verwendung des Werts stattfinden. Wenn es nun einen Beweis gibt, dass der Wert nicht verwendet werden kann, ist dies der Beweis dafür, dass dies nicht vorher passiert, und der Code kann unendlich weit in die Zukunft verschoben werden und dennoch das Speichermodell erfüllen.

Diese Aussage ist nur dann korrekt, wenn die Berechnung dieser Berechnung garantiert ist. Die Nichtbeendigung ist ein Nebeneffekt, und genau wie Sie eine Berechnung, die auf stdout gedruckt wird (sie ist "nicht rein"), möglicherweise nicht entfernen, können Sie eine Berechnung, die nicht beendet wird, nicht entfernen.

Es ist nicht in Ordnung, den folgenden Funktionsaufruf zu entfernen, auch wenn das Ergebnis nicht verwendet wird:

fn sideeffect() -> u32 {
  println!("Hello!");
  42
}

fn main() {
  let _ = sideffect(); // May not be removed.
}

Dies gilt für jede Art von Nebenwirkung, und dies gilt auch dann, wenn Sie den Ausdruck durch einen loop {} ersetzen.

Die Behauptung über die Nichtkündigung als Nebenwirkung erfordert nicht nur eine Vereinbarung, die es ist (die nicht umstritten ist), sondern auch eine Vereinbarung darüber, wann sie eingehalten werden sollte.

Wenn die Schleife den Wert berechnet, wird eine Nichtbeendigung sicher beobachtet. Eine Nichtbeendigung wird nicht beobachtet, wenn Sie die Berechnungen neu anordnen dürfen, die nicht vom Ergebnis der Schleife abhängen.

Wie das Beispiel im LLVM-Thread.

x = y % 42;
if y < 0 return 0;
...

Die Terminierungseigenschaften der Division haben nichts mit Nachbestellungen zu tun. Die modernen CPUs werden versuchen, die Division, den Vergleich, die Verzweigungsvorhersage und das Vorabrufen der erfolgreichen Verzweigung parallel auszuführen. Es ist also nicht garantiert, dass Sie die zum Zeitpunkt der Rückgabe von 0 abgeschlossene Teilung beobachten, wenn y negativ ist. (Mit "beobachten" meine ich hier wirklich das Messen mit einem Oszillometer, bei dem sich die CPU befindet, nicht vom Programm)

Wenn Sie die abgeschlossene Division nicht beobachten können, können Sie die gestartete Division nicht beobachten. Daher kann die Unterteilung im obigen Beispiel normalerweise neu angeordnet werden. Dies kann ein Compiler tun:

if y < 0 return 0;
x = y % 42;
...

Ich sage "normalerweise", weil es vielleicht Sprachen gibt, in denen dies nicht erlaubt ist. Ich weiß nicht, ob Rust eine solche Sprache ist.

Reine Loops sind nicht anders.


Ich sage nicht, dass es kein Problem ist. Ich sage nur, dass die Vorwärtsfortschrittsgarantie nicht das ist, was dies zulässt.

Die Behauptung über die Nichtkündigung als Nebenwirkung erfordert nicht nur eine Vereinbarung (die nicht umstritten ist), sondern auch eine Vereinbarung darüber, wann sie eingehalten werden sollte.

Was ich zum Ausdruck bringe, ist der Konsens des gesamten Forschungsbereichs der Programmiersprachen und Compiler. Natürlich können Sie nicht zustimmen, aber dann können Sie auch Begriffe wie "Compiler-Korrektheit" neu definieren - dies ist nicht hilfreich für eine Diskussion mit anderen.

Was die zulässigen Beobachtungen sind, wird immer auf Quellenebene definiert. Die Sprachspezifikation definiert eine "abstrakte Maschine", die (idealerweise in akribischen mathematischen Details) beschreibt, wie die zulässigen beobachtbaren Verhaltensweisen eines Programms sind. In diesem Dokument werden keine Optimierungen behandelt.

Die Richtigkeit eines Compilers wird dann daran gemessen, ob die von ihm erstellten Programme nur beobachtbare Verhaltensweisen aufweisen, die laut Spezifikation das Quellprogramm haben kann. Auf diese Weise funktioniert jede einzelne Programmiersprache, die Korrektheit ernst nimmt, und nur so können wir genau erfassen, wenn ein Compiler korrekt ist.

Es liegt an jeder Sprache, zu definieren, was genau auf der Quellenebene als beobachtbar angesehen wird und welches Quellverhalten als "undefiniert" betrachtet wird und daher vom Compiler als niemals auftretend angenommen werden kann. Dieses Problem tritt auf, weil C ++ sagt, dass eine Endlosschleife ohne andere Nebenwirkungen ("stille Divergenz") ein undefiniertes Verhalten ist, aber Rust sagt so etwas nicht. Dies bedeutet, dass eine Nichtbeendigung in Rust immer beobachtbar ist und vom Compiler beibehalten werden muss. Die meisten Programmiersprachen treffen diese Wahl, da die C ++ - Wahl es sehr einfach machen kann, versehentlich undefiniertes Verhalten (und damit kritische Fehler) in ein Programm einzuführen. Rust verspricht, dass aus sicherem Code kein undefiniertes Verhalten entstehen kann. Da sicherer Code Endlosschleifen enthalten kann, müssen Endlosschleifen in Rust definiert (und somit beibehalten) werden.

Wenn diese Dinge verwirrend sind, schlage ich vor, Hintergrundinformationen zu lesen. Ich kann "Typen und Programmiersprachen" von Benjamin Pierce empfehlen. Sie werden wahrscheinlich auch viele Blog-Beiträge finden, obwohl es schwierig sein kann zu beurteilen, wie gut der Autor wirklich informiert ist.

Der Vollständigkeit halber, wenn Ihr Teilungsbeispiel in geändert wurde

x = 42 % y;
if y <= 0 { return 0; }

dann hoffe ich, dass Sie zustimmen würden, dass die Bedingung nicht über die Division angehoben werden kann, da dies das beobachtbare Verhalten ändern würde, wenn y Null ist (vom Absturz bis zur Rückgabe von Null).

In gleicher Weise in

x = if y == 0 { loop {} } else { y % 42 };
if y < 0 { return 0; }

Die abstrakte Rust-Maschine ermöglicht das Umschreiben als

if y == 0 { loop {} }
else if y < 0 { return 0; }
x = y % 42;

Die erste Bedingung und die Schleife können jedoch nicht verworfen werden.

Ralf, ich gebe nicht vor, die Hälfte von dem zu wissen, was du tust, und ich möchte keine neuen Bedeutungen einführen. Ich stimme der Definition der Richtigkeit voll und ganz zu (die Ausführungsreihenfolge muss der Programmreihenfolge entsprechen). Ich dachte nur, dass das "Wann" der Nichtbeendigung beobachtbar ist, wie in: Wenn Sie das Schleifenergebnis nicht beobachten, haben Sie keinen Zeugen für seine Beendigung (können also nicht behaupten, dass es nicht korrekt ist) . Ich muss das Ausführungsmodell erneut überprüfen.

Danke, dass du mit mir zusammen bist

@ Zackw Danke. Das ist ein anderer Code, was natürlich zu einer anderen Optimierung führt.

Meine Prämisse, dass die Schleifen genauso optimiert werden wie die Division, war fehlerhaft (ich kann das Ergebnis der Division nicht sehen == kann nicht sehen, dass die Schleife endet), also spielt der Rest keine Rolle.

@olotenko Ich weiß nicht, was du mit "das Ergebnis der Schleife beobachten" meinst. Eine nicht terminierende Schleife führt dazu, dass das gesamte Programm divergiert, was als beobachtbares Verhalten angesehen wird. Dies bedeutet, dass es außerhalb des Programms beobachtbar ist. Wie in kann der Benutzer das Programm ausführen und sehen, dass es für immer weitergeht. Ein Programm, das für immer weitergeht, wird möglicherweise nicht zu einem Programm kompiliert, das beendet wird, da sich dadurch ändert, was der Benutzer über das Programm beobachten kann.

Es spielt keine Rolle, was diese Schleife berechnet hat oder ob der "Rückgabewert" der Schleife verwendet wird oder nicht. Entscheidend ist, was der Benutzer beim Ausführen des Programms beobachten kann. Der Compiler muss sicherstellen, dass dieses beobachtbare Verhalten gleich bleibt. Die Nichtbeendigung gilt als beobachtbar.

Um ein anderes Beispiel zu geben:

fn main() {
  loop {}
  println!("Hello");
}

Dieses Programm druckt aufgrund der Schleife niemals etwas. Wenn Sie jedoch die Schleife wegoptimieren (oder die Schleife mit dem Druck neu anordnen), druckt das Programm plötzlich "Hallo". Daher ändern diese Optimierungen das beobachtbare Verhalten des Programms und sind nicht zulässig.

@RalfJung es ist ok, ich habe es jetzt. Mein ursprüngliches Problem war, welche Rolle die "Vorwärtsfortschrittsgarantie" hier spielt. Die Optimierung ist vollständig aus Datenabhängigkeit möglich. Mein Fehler war, dass die Datenabhängigkeit nicht Teil der Programmreihenfolge ist: Es sind buchstäblich die Ausdrücke, die gemäß der Sprachsemantik vollständig geordnet sind. Wenn die Programmreihenfolge vollständig ist, können wir ohne Vorwärtsfortschrittsgarantie (die wir als "jeder Unterpfad der Programmreihenfolge ist endlich" wiedergeben können) (in der Ausführungsreihenfolge) nur die Ausdrücke neu anordnen, die wir als beenden (und beweisen können) Beibehalten einiger anderer Eigenschaften, wie z. B. Beobachtbarkeit von Synchronisationsaktionen, Betriebssystemaufrufen, E / A usw.).

Ich muss noch etwas darüber nachdenken, aber ich denke, ich kann den Grund sehen, warum wir in dem Beispiel mit x = y % 42 "so tun" können, als ob es passiert wäre, auch wenn es für einige Eingaben nicht wirklich ausgeführt wird, aber warum das gleiche nicht für beliebige Schleifen gilt. Ich meine, die Feinheiten der Entsprechung der Gesamtreihenfolge (Programmreihenfolge) und der Teilreihenfolge (Ausführungsreihenfolge).

Ich denke, "beobachtbares Verhalten" kann etwas subtiler sein, da eine unendliche Rekursion zu einem Absturz des Stapelüberlaufs führen wird ("beendet" im Sinne eines "Benutzers, der das Ergebnis beobachtet"), aber zu einer Tail-Call-Optimierung verwandelt es in eine nicht terminierende Schleife. Zumindest ist dies eine andere Sache, die Rust / LLVM tun wird. Aber wir müssen diese Frage nicht diskutieren, da dies nicht wirklich mein Problem war (es sei denn, Sie möchten! Ich bin sicher froh zu verstehen, ob dies erwartet wird).

Paketüberfluss

Stapelüberläufe sind in der Tat schwierig zu modellieren, gute Frage. Gleiches gilt für Situationen, in denen nicht genügend Speicher vorhanden ist. In erster Näherung geben wir formal vor, dass sie nicht eintreten. Ein besserer Ansatz ist zu sagen , dass jedes Mal , wenn eine Funktion aufrufen, können Sie aufgrund Stapelüberlauf einen Fehler, oder das Programm fortgesetzt werden kann - dies ist ein nicht-deterministische Auswahl bei jedem Aufruf gemacht. Auf diese Weise können Sie genau abschätzen, was tatsächlich passiert.

Wir können (in der Ausführungsreihenfolge) nur die Ausdrücke neu anordnen, die wir als terminierend beweisen können

Tatsächlich. Außerdem müssen sie "rein" sein, dh ohne Nebenwirkungen - Sie können nicht zwei println! nachbestellen. Aus diesem Grund betrachten wir die Nichtbeendigung normalerweise auch als Effekt, da sich dies alles auf "reine Ausdrücke können neu angeordnet werden" und "nicht terminierende Ausdrücke sind unrein" reduziert (unrein = hat einen Nebeneffekt).

Die Teilung ist ebenfalls potenziell unrein, jedoch nur beim Teilen durch 0 - was eine Panik, dh einen Kontrolleffekt, verursacht. Dies ist nicht direkt, sondern indirekt beobachtbar (z. B. indem der Panik-Handler etwas auf Standard druckt, was dann beobachtbar ist). Daher kann die Division nur neu angeordnet werden, wenn wir sicher sind, dass wir nicht durch 0 teilen.

Ich habe einen Demo-Code, von dem ich denke, dass er dieses Problem sein könnte, bin mir aber nicht ganz sicher. Bei Bedarf kann ich dies in einen neuen Fehlerbericht einfügen.
Ich habe den Code dafür in ein Git-Repo unter https://github.com/uglyoldbob/rust_demo eingefügt

Meine Endlosschleife (mit Nebenwirkungen) wird optimiert und eine Trap-Anweisung generiert.

Ich habe keine Ahnung, ob dies eine Instanz dieses Problems oder etwas anderes ist ... eingebettete Geräte sind überhaupt nicht meine Spezialität, und bei all diesen externen Kistenabhängigkeiten habe ich keine Ahnung, was dieser Code sonst noch tut. ^^ Aber Ihr Programm ist nicht sicher und es hat einen flüchtigen Zugriff in der Schleife, also würde ich sagen, dass es ein separates Problem ist. Wenn ich Ihr Beispiel auf den Spielplatz stelle , denke ich, dass es korrekt kompiliert wurde, also würde ich vermuten, dass das Problem in einer der zusätzlichen Abhängigkeiten liegt.

Es scheint, dass alles in der Schleife eine Referenz auf eine lokale Variable ist (keine ist in einen anderen Thread übergegangen). Unter diesen Umständen ist es einfach, das Fehlen flüchtiger Speicher und das Fehlen beobachtbarer Effekte nachzuweisen (keine Speicher, mit denen sie synchronisieren können). Wenn Rust flüchtigen Stoffen keine besondere Bedeutung verleiht, kann diese Schleife auf eine reine Endlosschleife reduziert werden.

@uglyoldbob Was in Ihrem Beispiel wirklich passiert, wäre klarer, wenn llvm-objdump nicht spektakulär wenig hilfreich (und ungenau) wäre. Das bl #4 (was eigentlich keine gültige Assemblysyntax ist) bedeutet hier Verzweigung auf 4 Bytes nach dem Ende der Anweisung bl , auch bekannt als das Ende der Funktion main , auch bekannt als Start der nächsten Funktion. Die nächste Funktion heißt (wenn ich sie erstelle) _ZN11broken_loop18__cortex_m_rt_main17hbe300c9f0053d54dE , und das ist Ihre eigentliche main -Funktion. Die Funktion mit dem entwirrten Namen main ist nicht Ihre Funktion, sondern eine völlig andere Funktion, die vom Makro #[entry] generiert wird, das von cortex-m-rt bereitgestellt wird. Ihr Code wird nicht wirklich weg optimiert. (Tatsächlich läuft der Optimierer nicht einmal, da Sie im Debug-Modus erstellen.)

War diese Seite hilfreich?
0 / 5 - 0 Bewertungen