Rust: L'optimisation de la boucle LLVM peut faire planter des programmes sûrs

Créé le 29 sept. 2015  ·  97Commentaires  ·  Source: rust-lang/rust

L'extrait de code suivant se bloque lorsqu'il est compilé en mode version sur les versions stable, bêta et nocturne actuelles:

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

Ceci est basé sur l'exemple suivant de LLVM supprimant une boucle dont j'ai été informé: https://github.com/simnalamburt/snippets/blob/12e73f45f3/rust/infinite.rs.
Ce qui semble arriver, c'est que puisque C permet à LLVM de supprimer des boucles sans fin qui n'ont aucun effet secondaire, nous finissons par exécuter un match qui doit s'armer.

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

Commentaire le plus utile

Au cas où quelqu'un voudrait jouer au golf avec code de cas de test:

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

Avec le drapeau -Z insert-sideeffect rustc, ajouté par @sfanxiang dans https://github.com/rust-lang/rust/pull/59546, il continue de tourner :)

avant:

main:
  ud2

après:

main:
.LBB0_1:
  jmp .LBB0_1

Tous les 97 commentaires

Le LLVM IR du code optimisé est

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

Ce type d'optimisation rompt l'hypothèse principale qui devrait normalement s'appliquer aux types inhabités: il devrait être impossible d'avoir une valeur de ce type.
rust-lang / rfcs # 1216 propose de gérer explicitement ces types dans Rust. Il pourrait être efficace pour s'assurer que LLVM n'a jamais à les gérer et pour injecter le code approprié pour assurer la divergence lorsque cela est nécessaire (IIUIC cela pourrait être réalisé avec des attributs appropriés ou des appels intrinsèques).
Ce sujet a également été récemment discuté dans la liste de diffusion LLVM: http://lists.llvm.org/pipermail/llvm-dev/2015-July/088095.html

triage: I-nominé

Ça a l'air mauvais! Si LLVM n'a pas le moyen de dire "oui, cette boucle est vraiment infinie", alors nous devrons peut-être simplement nous asseoir et attendre que la discussion en amont se termine.

Un moyen d'empêcher l'optimisation des boucles infinies est d'ajouter unsafe {asm!("" :::: "volatile")} intérieur de celles-ci. Ceci est similaire à l'intrinsèque llvm.noop.sideeffect qui a été proposé dans la liste de diffusion LLVM, mais cela peut empêcher certaines optimisations.
Afin d'éviter la perte de performances et de garantir toujours que les fonctions / boucles divergentes ne sont pas optimisées, je pense qu'il devrait être suffisant d'insérer une boucle vide non optimisable (c'est- loop { unsafe { asm!("" :::: "volatile") } } dire
Si LLVM optimise le code qui devrait diverger au point qu'il ne diverge plus, de telles boucles garantiront que le flux de contrôle est toujours incapable de continuer.
Dans le cas "chanceux" dans lequel LLVM est incapable d'optimiser le code divergeant, une telle boucle sera supprimée par DCE.

Est-ce lié à # 18785? Il s'agit d'une récursion infinie pour être UB, mais il semble que la cause fondamentale pourrait être similaire: LLVM ne considère pas le fait de ne pas s'arrêter comme un effet secondaire, donc si une fonction n'a pas d'autres effets secondaires que de ne pas s'arrêter, il est heureux d'optimiser loin.

@geofft

C'est le même problème.

Oui, on dirait que c'est la même chose. Plus loin dans ce problème, ils montrent comment obtenir undef , à partir de laquelle je suppose qu'il n'est pas difficile de faire planter un programme (apparemment sûr).

: +1:

Crash, ou, peut-être même pire, heartbleed https://play.rust-lang.org/?gist=15a325a795244192bdce&version=stable

Je me demande donc combien de temps avant que quelqu'un ne le rapporte. :) À mon avis, la meilleure solution serait bien sûr de dire à LLVM de ne pas être aussi agressif sur des boucles potentiellement infinies. Sinon, la seule chose que je pense que nous pouvons faire est de faire une analyse conservatrice dans Rust lui-même qui détermine si:

  1. la boucle se terminera OU
  2. la boucle aura des effets secondaires (opérations d'E / S etc, j'oublie précisément comment cela est défini en C)

L'un ou l'autre de ces éléments devrait suffire à éviter un comportement indéfini.

triage: P-moyen

Nous aimerions voir ce que LLVM fera avant d'investir beaucoup d'efforts de notre côté, et cela semble relativement peu susceptible de causer des problèmes dans la pratique (bien que j'aie personnellement touché cela lors du développement du compilateur). Il n'y a pas de problèmes d'incomatibilité vers l'arrière à craindre.

Citation de la discussion sur la liste de diffusion LLVM:

 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 L'extrait que vous citez provient de la spécification C ++; c'est fondamentalement la réponse à "comment il [avoir des effets secondaires] est défini en C" (également confirmé par le comité de normalisation: http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1528 .htm).

En ce qui concerne le comportement attendu du LLVM IR, il existe une certaine confusion. https://llvm.org/bugs/show_bug.cgi?id=24078 montre qu'il ne semble pas y avoir de spécification précise et explicite de la sémantique des boucles infinies dans LLVM IR. Il s'aligne sur la sémantique du C ++, probablement pour des raisons historiques et pour des raisons de commodité (j'ai seulement réussi à retrouver https://groups.google.com/forum/#!topic/llvm-dev/j2vlIECKkdE qui fait apparemment référence à une heure lorsque des boucles infinies n'étaient pas optimisées, quelque temps avant que les spécifications C / C ++ soient mises à jour pour le permettre).

D'après le thread, il est clair qu'il y a le désir d'optimiser le code C ++ le plus efficacement possible (c'est-à-dire en tenant également compte de la possibilité de supprimer des boucles infinies), mais dans le même thread, plusieurs développeurs (dont certains contribuent activement à LLVM) ont s'est montré intéressé par la possibilité de conserver des boucles infinies, car elles sont nécessaires pour d'autres langues.

@ ranma42 Je suis conscient de cela, je viens de le citer à titre de référence, car une possibilité de contourner ce

Est-ce un problème de solidité? Si tel est le cas, nous devons le marquer comme tel.

Oui, en suivant l'exemple de @ ranma42 , cette façon montre comment il déjoue facilement les vérifications des limites du tableau. lien de l'aire de jeux

@bluss

La politique est que les problèmes de code erroné qui sont également des problèmes de solidité (c'est-à-dire la plupart d'entre eux) doivent être marqués I-wrong .

Donc, juste pour récapituler la discussion précédente, il y a vraiment deux choix ici que je peux voir:

  • Attendez que LLVM fournisse une solution.
  • Introduisez des instructions asm no-op partout où il peut y avoir une boucle infinie ou une récursion infinie (# 18785).

Ce dernier est un peu mauvais car il peut inhiber l'optimisation, nous voudrions donc le faire avec parcimonie - essentiellement partout où nous ne pouvons pas prouver nous-mêmes la résiliation. Vous pouvez également créer une image en le liant un peu plus à la façon dont LLVM optimise - c'est-à-dire en introduisant uniquement si nous pouvons détecter un scénario que LLVM pourrait considérer comme une boucle / récursivité infinie - mais cela nécessiterait (a) de suivre LLVM et (b ) exigent des connaissances plus profondes que celles que je possède au moins.

Attendez que LLVM fournisse une solution.

Quel est le bogue LLVM qui suit ce problème?

remarque: while true {} présente ce comportement . Peut-être que la charpie devrait être mise à niveau en erreur par défaut et obtenir une note indiquant que cela peut actuellement présenter un comportement non défini?

Notez également que cela n'est pas valide pour C. LLVM. Cet argument signifie qu'il y a un bogue dans clang.

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();
}

Cela plante avec les optimisations; il s'agit d'un comportement invalide selon la norme C11:

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. 

Notez que «dont l'expression de contrôle n'est pas une expression constante» - while (1) { } , 1 est une expression constante et ne peut donc

La suppression de la boucle est-elle une passe d'optimisation que nous pourrions simplement supprimer?

@ubsan

Avez-vous trouvé un rapport de bogue pour cela dans le bugzilla de LLVM ou en avez-vous rempli un? Il semble qu'en C ++, les boucles infinies qui ne peuvent jamais se terminer sont des comportements indéfinis, mais en C ce sont des comportements définis (soit ils peuvent être supprimés en toute sécurité dans certains cas, soit ils ne le peuvent pas dans d'autres).

@gnzlbg Je dépose un bug maintenant.

https://llvm.org/bugs/show_bug.cgi?id=31217

Je me répète de # 42009: ce bug peut, dans certaines circonstances, provoquer l'émission d'une fonction appelable en externe ne contenant aucune instruction machine. Cela ne devrait jamais arriver. Si LLVM en déduit qu'un pub fn ne peut jamais être appelé par un code correct, il doit émettre au moins une instruction trap comme corps de cette fonction.

Cela a été évoqué dans ce billet de blog aujourd'hui: https://blog.rom1v.com/2017/09/gnirehtet-rewritten-in-rust/

Avec une reproduction plus simple: https://play.rust-lang.org/?gist=e622f8a672fbc57ecc63eb4450d2fc0a&version=stable

Le bogue LLVM pour cela est https://bugs.llvm.org/show_bug.cgi?id=965 (ouvert en 2006).

@zackw LLVM a un indicateur pour cela: TrapUnreachable . Je n'ai pas testé cela, mais il semble que l'ajout de Options.TrapUnreachable = true; à LLVMRustCreateTargetMachine devrait répondre à votre préoccupation. Il est probable que cela ait un coût suffisamment bas pour que cela puisse être fait par défaut, même si je n'ai fait aucune mesure.

@ oli-obk Ce n'est malheureusement pas seulement une passe de suppression de boucle. Le problème découle d'hypothèses générales, par exemple: (a) les branches n'ont pas d'effets secondaires, (b) les fonctions qui ne contiennent pas d'instructions avec effets secondaires n'ont pas d'effets secondaires, et (c) les appels à des fonctions sans effets secondaires peuvent être déplacés ou supprimé.

On dirait qu'il y a un correctif: https://reviews.llvm.org/D38336

@sunfishcode , on dirait que votre correctif LLVM à https://reviews.llvm.org/D38336 a été "accepté" le 3 octobre, pouvez-vous donner une mise à jour sur ce que cela signifie concernant le processus de publication de LLVM? Quelle est la prochaine étape au-delà de l'acceptation et avez-vous une idée de la future version de LLVM qui contiendra ce correctif?

J'ai parlé avec des personnes hors ligne qui ont suggéré que nous ayons un fil llvmdev. Le fil est ici:

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

Il est maintenant conclu, le résultat étant que je dois apporter des modifications supplémentaires. Je pense que les changements seront bons, même s'ils me prendront un peu plus de temps à faire.

Merci pour la mise à jour et merci beaucoup pour vos efforts!

Notez que https://reviews.llvm.org/rL317729 a atterri dans LLVM. Ce correctif est prévu pour avoir un correctif de suivi qui fait que les boucles infinies présentent un comportement défini par défaut, donc AFAICT tout ce que nous devons faire est d'attendre et finalement cela sera résolu pour nous en amont.

@zackw J'ai maintenant créé # 45920 pour résoudre le problème des fonctions ne contenant pas de code.

@bstrie Oui, la première étape est

@jsgf Encore repro. Avez-vous sélectionné le mode Release?

@kennytm Woops, peu importe.

Notez que https://reviews.llvm.org/rL317729 a atterri dans LLVM. Ce correctif est prévu pour avoir un correctif de suivi qui fait que les boucles infinies présentent un comportement défini par défaut, donc AFAICT tout ce que nous devons faire est d'attendre et finalement cela sera résolu pour nous en amont.

Cela fait plusieurs mois depuis ce commentaire. Quelqu'un sait si le patch de suivi a eu lieu ou se produira toujours?

Alternativement, il semble que l'intrinsèque llvm.sideeffect existe dans la version LLVM que nous utilisons: pourrions-nous résoudre ce problème nous-mêmes en traduisant les boucles infinies Rust en boucles LLVM qui contiennent l'effet secondaire intrinsèque?

Comme on le voit est https://github.com/rust-lang/rust/issues/38136 et https://github.com/rust-lang/rust/issues/54214 , c'est particulièrement mauvais avec le prochain panic_implementation , comme implémentation logique de celui-ci sera loop {} , et cela rendrait toutes les occurrences de panic! UB sans aucun code unsafe . Ce qui… est peut-être le pire qui puisse arriver.

Je viens de rencontrer ce problème sous un autre jour. Voici un exemple:

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);
}

Cet exemple effectue une segmentation fiable des défauts sur stable, beta et nightly, et montre à quel point il est facile de construire des valeurs non initialisées de tout type. Le voici sur la cour de récréation .

@SergioBenitez ce programme ne Exemple de travail minimal .

Dans les versions de version, LLVM peut supposer que vous n'avez pas de récursivité infinie et l'optimise ( mwe ). Cela n'a rien à voir avec les boucles AFAICT, mais plutôt avec https://stackoverflow.com/a/5905171/1422197

@gnzlbg Désolé, mais vous avez

Le programme effectue une segmentation en mode libération. C'est tout le point; qu'une optimisation aboutit à un comportement défectueux - que LLVM et la sémantique de Rust ne sont pas d'accord ici - que je peux écrire et compiler un programme Rust sécurisé avec rustc qui me permet d'utiliser de la mémoire non initialisée, d'inspecter la mémoire arbitraire et de lancer arbitrairement entre les types, violant la sémantique du langage. C'est le même point illustré dans ce fil. Notez que le programme d'origine ne segfault pas non plus en mode débogage.

Vous semblez également proposer qu'il y ait une optimisation _différente_ sans boucle qui se déroule ici. C'est peu probable, bien que largement hors de propos, bien que cela puisse justifier une question distincte si c'est le cas. Je suppose que LLVM remarque la récursivité de la queue, la traite comme une boucle infinie et l'optimise pour, encore une fois, exactement de quoi il s'agit.

@gnzlbg Eh bien, en changeant légèrement votre mwe d'optimisation loin de la récursion infinie ( ici ), cela génère une valeur non initialisée de NonZeroUsize (qui s'avère être… 0, donc une valeur invalide).

Et c'est ce que @SergioBenitez a également fait avec leur exemple, sauf que c'est avec des pointeurs, et génère ainsi un segfault.

Sommes-nous d'accord pour dire que le programme

Si tel est le cas, je ne trouve aucun loop s dans l'exemple @SergioBenitez , donc je ne sais pas comment ce problème s'y appliquerait (ce problème concerne l'infini loop s après tout). Si je me trompe, indiquez-moi le loop dans votre exemple.

Comme mentionné, LLVM suppose que la récursivité infinie ne peut pas se produire (elle suppose que tous les threads finissent par se terminer), mais ce serait un problème différent de celui-ci.

Je n'ai pas inspecté les optimisations effectuées par LLVM ou le code généré pour l'un ou l'autre des programmes, mais notez qu'un segfault n'est pas défectueux, si le segfault est tout ce qui se produit. En particulier, les débordements de pile qui sont interceptés (par sonde de pile + une page de garde non mappée après la fin de la pile) et qui ne causent aucun problème de sécurité de la mémoire apparaissent également comme des défauts de segmentation. Bien sûr, les erreurs de segmentation peuvent également indiquer une corruption de la mémoire ou des écritures / lectures sauvages ou d'autres problèmes de son.

@rkruppe Mon programme segfaults parce qu'une référence à un emplacement de mémoire aléatoire a été autorisée à être construite, et la référence a été lue par la suite. Le programme peut être modifié de manière simple pour écrire à la place un emplacement mémoire aléatoire, et sans trop de difficulté, lire / écrire un emplacement mémoire _particulier_.

@gnzlbg Le programme _ne_ne pas_ débordement de pile en mode version. En mode libération, le programme effectue zéro appel de fonction; la pile est poussée sur un nombre fini de fois, uniquement pour allouer des locaux.

Le programme n'empile pas le débordement en mode version.

Alors? La seule chose qui compte est que le programme d'exemple, qui est fondamentalement fn foo() { foo() } , a une récursion infinie, ce qui n'est pas autorisé par LLVM.

La seule chose qui compte est que l'exemple de programme, qui est fondamentalement fn foo () {foo ()}, a une récursion infinie, ce qui n'est pas autorisé par LLVM.

Je ne sais pas pourquoi vous dites ça comme ça résout quoi que ce soit. LLVM considérant la récursivité infinie et les boucles UB et l'optimisation en conséquence, tout en étant sûr dans Rust, est le point entier de tout ce problème!

Auteur de https://reviews.llvm.org/rL317729 ici, confirmant que je n'ai pas encore implémenté le correctif de suivi.

Vous pouvez insérer un appel @llvm.sideeffect aujourd'hui pour vous assurer que les boucles ne sont pas optimisées. Cela pourrait désactiver certaines optimisations, mais en théorie pas trop, car les principales optimisations ont appris à les comprendre. Si l'on met des appels @llvm.sideeffect dans toutes les boucles ou choses qui pourraient se transformer en boucles (récursion, déroulement, asm en ligne , autres?), C'est théoriquement suffisant pour résoudre le problème ici.

De toute évidence, il serait plus agréable d'avoir le deuxième patch en place, de sorte qu'il ne soit pas nécessaire de le faire. Je ne sais pas quand je reviendrai sur la mise en œuvre de cela.

Il y a une petite différence, mais je ne sais pas si c'est important ou non.

Récursion

#[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
}

Boucle

#[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, compilation en mode version, affichage du LLVM IR.

Je ne pense pas que nous puissions, en général, détecter la récursivité (objets de trait, C FFI, etc.), donc nous devrions utiliser llvm.sideeffect sur à peu près tous les sites d'appel à moins que nous puissions prouver que l'appel le site ne se répétera pas. Prouver l'absence de récursions dans les cas où cela peut être prouvé nécessite une analyse interprocédurale, sauf pour les programmes les plus triviaux comme fn main() { main() } . Il peut être utile de savoir quel est l'impact de la mise en œuvre de ce correctif et s'il existe des solutions alternatives à ce problème.

@gnzlbg C'est vrai, bien que vous puissiez placer les @ llvm.sideeffects aux entrées des fonctions, plutôt qu'aux sites d'appels.

Étrangement, je ne peux pas reproduire localement le cas de test SEGFAULT dans le cas de test @SergioBenitez .

De plus, pour un débordement de pile, ne devrait-il pas y avoir un message d'erreur différent? Je pensais que nous avions du code à imprimer "La pile débordait" ou alors?

@RalfJung avez-vous essayé en mode débogage? (Je peux reproduire de manière fiable le débordement de pile en mode débogage dans mes machines et le terrain de jeu, alors peut-être que vous devez remplir un bogue si pour vous ce n'est pas le cas). Dans --release vous n'obtiendrez pas de débordement de pile car tout ce code est mal optimisé.


@sunfishcode

C'est vrai, bien que vous puissiez placer les @ llvm.sideeffects aux entrées des fonctions, plutôt qu'aux sites d'appels.

Il est difficile de dire quelle serait la meilleure voie à suivre sans savoir exactement quelles optimisations llvm.sideeffects empêche. Vaut-il la peine d'essayer de générer le moins de @llvm.sideeffects possible? Sinon, le mettre dans chaque appel de fonction pourrait être la chose la plus simple à faire. Sinon, IIUC, si @llvm.sideeffect est nécessaire dépend de ce que fait le site d'appel:

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, nous devons mettre @llvm.sideeffect à l'intérieur des fonctions pour éviter qu'elles ne soient supprimées, donc même si ces "optimisations" en valaient la peine, je ne pense pas qu'elles soient simples à voir avec le modèle actuel. Même si c'était le cas, ces optimisations reposeraient sur la capacité de prouver qu'il n'y a pas de récursivité.

avez-vous essayé en mode débogage? (Je peux reproduire de manière fiable le débordement de pile en mode débogage dans mes machines et le terrain de jeu, alors peut-être que vous devez remplir un bogue si pour vous ce n'est pas le cas)

Bien sûr, mais en mode débogage, LLVM ne fait pas les optimisations de boucle donc il n'y a pas de problème.

Si la pile du programme déborde en mode débogage, cela ne devrait pas donner la licence LLVM pour créer UB. Le problème est de savoir si le programme final a UB, et en regardant l'IR, je ne peux pas le dire. Il se sépare, mais je ne sais pas pourquoi. Mais cela me semble être un bogue "d'optimiser" un programme à débordement de pile en un programme qui sépare les défauts.

Si la pile du programme déborde en mode débogage, cela ne devrait pas donner la licence LLVM pour créer UB.

Mais cela me semble être un bogue "d'optimiser" un programme à débordement de pile en un programme qui sépare les défauts.

En C, un thread d'exécution est supposé se terminer, effectuer des accès mémoire volatile, des E / S ou une opération atomique de synchronisation. Cela me surprendrait si LLVM-IR n'évolue pas pour avoir la même sémantique soit par accident, soit par conception.

Le code Rust contient un thread d'exécution qui ne se termine jamais et n'effectue aucune des opérations requises pour que cela ne soit pas UB en C.Je soupçonne que nous générons le même LLVM-IR qu'un programme C avec un comportement non défini , donc je ne pense pas qu'il soit surprenant que LLVM n'optimise pas ce programme Rust.

Il se sépare, mais je ne sais pas pourquoi.

LLVM supprime la récursivité infinie, donc comme @SergioBenitez mentionné ci-dessus, le programme procède alors à:

une référence à un emplacement de mémoire aléatoire a pu être construite, et la référence a été lue par la suite.

La partie du programme qui le fait est celle-ci:

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

Container::from commence une récursion infinie, que LLVM conclut ne peut jamais arriver, et remplace par une valeur aléatoire, qui est ensuite déréférencée. Vous pouvez voir l'une des nombreuses façons dont cela est mal optimisé ici: https://rust.godbolt.org/z/P7Snex Sur le terrain de jeu (https://play.rust-lang.org/?gist=f00d41cc189f9f6897d429350f3781ec&version=stable&mode = release & edition = 2015) on obtient une panique différente dans la version des versions de débogage en raison de cette optimisation, mais UB est UB est UB.

Le code Rust contient un thread d'exécution qui ne se termine jamais et n'effectue aucune des opérations requises pour que cela ne soit pas UB en C.Je soupçonne que nous générons le même LLVM-IR qu'un programme C avec un comportement non défini , donc je ne pense pas qu'il soit surprenant que LLVM n'optimise pas ce programme Rust.

J'avais l'impression que vous aviez dit plus haut qu'il ne s'agissait

Donc, il semble qu'une bonne prochaine étape consisterait à saupoudrer de llvm.sideffect dans l'IR que nous générons, et à faire des repères?

En C, un thread d'exécution est supposé se terminer, effectuer des accès mémoire volatile, des E / S ou une opération atomique de synchronisation.

Btw, ce n'est pas tout à fait correct - une boucle avec une constante conditionnelle (telle que while (true) { /* ... */ } ) est explicitement autorisée par la norme, même si elle ne contient aucun effet secondaire. C'est différent en C ++. LLVM n'implémente pas correctement le standard C ici.

J'avais l'impression que vous aviez dit plus haut qu'il ne s'agissait pas du même bug que le problème de la boucle infinie.

Le comportement des programmes Rust sans terminaison est toujours défini, tandis que le comportement des programmes LLVM-IR sans terminaison n'est défini que si certaines conditions sont remplies.

Je pensais que ce problème concernait la résolution de l'implémentation de Rust pour des boucles infinies de sorte que le comportement du LLVM-IR généré devienne défini, et pour cela, @llvm.sideeffect , sonnait comme une très bonne solution.

@SergioBenitez a mentionné que l'on peut également créer des programmes Rust sans terminaison en utilisant la récursivité, et @rkruppe a fait valoir que la récursivité infinie et les boucles infinies sont équivalentes, de sorte que ce sont les deux le même bogue.

Je ne conteste pas que ces deux problèmes sont liés, ou même qu'ils sont le même bug, mais pour moi, ces deux problèmes sont légèrement différents:

  • En termes de solution, nous passons de l'application d'une barrière d'optimisation ( @llvm.sideeffect ) exclusivement aux boucles sans terminaison, pour l'appliquer à chaque fonction Rust.

  • en termes de valeur, les loop s sont utiles car le programme ne se termine jamais. Pour une récursion infinie, la fin du programme dépend du niveau d'optimisation (par exemple, si LLVM transforme la récursion en boucle ou non), et quand et comment le programme se termine dépend de la plate-forme (taille de la pile, page de garde protégée, etc.). Corriger les deux est nécessaire pour que l'implémentation de Rust sonne, mais dans le cas d'une récursion infinie, si l'utilisateur voulait que son programme se répète pour toujours, une implémentation saine serait toujours «fausse» dans le sens où elle ne se répéterait pas toujours pour toujours.

En termes de solution, nous passons de l'application d'une barrière d'optimisation (@ llvm.sideeffect) exclusivement à des boucles sans terminaison, pour l'appliquer à chaque fonction Rust.

L'analyse requise pour montrer qu'un corps de boucle a en fait des effets secondaires (pas seulement potentiellement , comme avec les appels à des fonctions externes) et n'a donc pas besoin d'une insertion llvm.sideeffect est assez délicate, probablement dans à peu près le même ordre de magnitude comme montrant la même chose pour une fonction qui peut faire partie d'une récursion infinie. Prouver qu'une boucle se termine est également difficile sans faire d'abord beaucoup d'optimisations, car la plupart des boucles Rust impliquent des itérateurs. Je pense donc que nous finirions par mettre llvm.sideeffect dans la grande majorité des boucles malgré tout. Certes, il y a pas mal de fonctions qui ne contiennent pas de boucles, mais cela ne me semble toujours pas une différence qualitative.

Si je comprends bien le problème, pour résoudre le cas de la boucle infinie, il devrait suffire d'insérer llvm.sideeffect dans loop { ... } et while <compile-time constant true> { ... } où le corps de la boucle ne contient pas de break expressions. Cela capture la différence entre la sémantique C ++ et la sémantique Rust pour des boucles infinies: dans Rust, contrairement au C ++, le compilateur n'est pas autorisé à supposer qu'une boucle se termine quand elle est connaissable au moment de la compilation et qu'elle _ne pas_. (Je ne sais pas à quel point nous devons nous soucier de l'exactitude face aux boucles où le corps pourrait paniquer, mais cela peut toujours être amélioré plus tard.)

Je ne sais pas quoi faire à propos de la récursivité infinie, mais je suis d'accord avec RalfJung que l'optimisation d'une récursivité infinie dans un segfault non lié n'est pas un comportement souhaitable.

@zackw

Si je comprends bien le problème, pour résoudre le cas de la boucle infinie, il devrait être suffisant d'insérer llvm.sideeffect dans loop {...} et while{...} où le corps de la boucle ne contient aucune expression de rupture.

Je ne pense pas que ce soit aussi simple, par exemple, loop { if false { break; } } est une boucle infinie qui contient une expression break , mais nous devons insérer @llvm.sideeffect pour empêcher llvm de la supprimer. AFAICT nous devons insérer @llvm.sideeffect sauf si nous pouvons prouver que la boucle se termine toujours.

@gnzlbg

loop { if false { break; } } est une boucle infinie qui contient une expression de rupture, mais nous devons insérer @llvm.sideeffect pour empêcher llvm de la supprimer.

Hm, oui, c'est gênant. Mais nous n'avons pas à être parfaits, juste prudemment corrects. Une boucle comme

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

(d'après la documentation std::sync::atomic ) on verrait facilement ne pas avoir besoin d'un @llvm.sideeffect , car la condition de contrôle n'est pas constante (et une opération de chargement atomique ferait mieux de compter comme un effet secondaire à des fins de LLVM , ou nous avons de plus gros problèmes). Le type de boucle finie qui pourrait être émise par un générateur de programme,

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

ne devrait pas non plus être gênant. En fait, y a-t-il un cas où la règle "pas d'expressions de rupture dans le corps de la boucle" se trompe _besides_

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

?

Je pensais que ce problème concernait la résolution de l'implémentation de Rust pour des boucles infinies de sorte que le comportement du LLVM-IR généré devienne défini, et pour cela, @ llvm.sideeffect, semblait être une très bonne solution.

C'est suffisant. Cependant, comme vous l'avez dit, le problème (l'inadéquation entre la sémantique Rust et la sémantique LLVM) concerne en fait la non-terminaison, pas les boucles. Je pense donc que c'est ce que nous devrions suivre ici.

@zackw

Si je comprends bien le problème, pour résoudre le cas de la boucle infinie, il devrait être suffisant d'insérer llvm.sideeffect dans loop {...} et while{...} où le corps de la boucle ne contient aucune expression de rupture. Cela capture la différence entre la sémantique C ++ et la sémantique Rust pour des boucles infinies: dans Rust, contrairement au C ++, le compilateur n'est pas autorisé à supposer qu'une boucle se termine quand elle est connaissable au moment de la compilation. (Je ne sais pas à quel point nous devons nous soucier de l'exactitude face aux boucles où le corps pourrait paniquer, mais cela peut toujours être amélioré plus tard.)

Ce que vous décrivez est valable pour C. Dans Rust, toute boucle est autorisée à diverger. Tout le reste ne serait tout simplement pas judicieux.

Donc, par exemple

while test_fermats_last_theorem_on_some_random_number() { }

est un programme correct en Rust (mais ni en C ni en C ++), et il bouclera pour toujours sans provoquer d'effet secondaire. Donc, il doit s'agir de toutes les boucles, à l'exception de celles dont nous pouvons prouver qu'elles se termineront.

@zackw

y a-t-il un cas où la règle "pas d'expressions de rupture dans le corps de la boucle" se trompe en plus

Ce n'est pas seulement if /*compile-time condition */ . Tout le flux de contrôle est affecté ( while , match , for , ...) et les conditions d'exécution sont également affectées.

Mais nous n'avons pas besoin d'être parfaits, juste prudemment corrects.

Considérer:

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

x est une condition d'exécution. Si nous n'émettons pas @llvm.sideeffect ici, alors si l'utilisateur écrit foo(false) quelque part, foo pourrait être inséré et avec une propagation constante et une élimination de code mort, la boucle optimisée en un boucle infinie sans effets secondaires, entraînant une mauvaise optimisation.

Si cela a du sens, une transformation que LLVM serait autorisée à faire est de remplacer foo par foo_opt :

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

où les deux branches sont optimisées indépendamment, et la deuxième branche serait mal optimisée si nous n'utilisons pas @llvm.sideeffect .

Autrement dit, pour pouvoir omettre @llvm.sideeffect , nous aurions besoin de prouver que LLVM ne peut en aucun cas mal optimiser cette boucle. La seule façon de le prouver est soit de prouver que la boucle se termine toujours, soit de prouver que si elle ne se termine pas, elle fait inconditionnellement l'une des choses qui empêchent les mauvaises optimisations. Même dans ce cas, des optimisations comme le fractionnement / épluchage de boucles pourraient transformer une boucle en une série de boucles, et il suffirait que l'une d'elles n'ait pas @llvm.sideeffect pour qu'une mauvaise optimisation se produise.

Tout ce qui concerne ce bogue me semble que ce serait beaucoup plus facile à résoudre à partir de LLVM qu'à partir de rustc . (avertissement: je ne connais pas vraiment la base de code de l'un ou l'autre de ces projets)

Si je comprends bien, le correctif de LLVM changerait les optimisations de l'exécution (prouver la non-terminaison || ne peut pas prouver non plus) à l'exécution uniquement lorsque la non-résiliation peut être prouvée (ou l'inverse). Je ne dis pas que c'est facile (en aucun cas), mais LLVM inclut déjà (je suppose) du code pour essayer de prouver la (non-) terminaison des boucles.

D'un autre côté, rustc ne peut le faire qu'en ajoutant @llvm.sideeffect , ce qui aura potentiellement plus d'impact sur l'optimisation que de "simplement" désactiver les optimisations qui utilisent de manière inappropriée la non-résiliation. Et rustc devrait incorporer un nouveau code pour essayer de détecter la (non-) terminaison des boucles.

Je pense donc que la voie à suivre serait:

  1. Ajoutez @llvm.sideeffect sur chaque boucle et appel de fonction pour résoudre le problème
  2. Correction de LLVM pour ne pas effectuer de mauvaises optimisations sur les boucles sans terminaison et supprimez le @llvm.sideeffects

Que penses-tu de cela? J'espère que l'impact sur les performances de l'étape 1 ne serait pas trop horrible, même s'il est censé disparaître une fois que 2 est implémenté ...

@Ekleog, c'est ce que pourrait être le deuxième patch de https://lists.llvm.org/pipermail/llvm-dev/2017-October/118595.html

une partie de la proposition d'attribut de fonction consiste à
changer la sémantique par défaut de LLVM IR pour avoir un comportement défini sur
boucles infinies, puis ajoutez un attribut optant pour potentiel-UB. Alors
si nous faisons cela, alors le rôle de @ llvm.sideeffect devient un peu
subtile - ce serait un moyen pour une interface pour un langage comme C d'opter
en potentiel-UB pour une fonction, mais alors optez pour l'individu
boucles dans cette fonction.

Pour être juste envers LLVM, les rédacteurs de compilateurs n'abordent pas ce sujet du point de vue de "Je vais écrire une optimisation qui prouve que les boucles ne se terminent pas, afin que je puisse les optimiser pédantiquement!" Au lieu de cela, l'hypothèse selon laquelle les boucles se termineront ou auront des effets secondaires surgit naturellement dans certains algorithmes de compilation courants. Résoudre ce problème n'est pas seulement une modification du code existant; cela nécessitera une nouvelle complexité considérable.

Considérez l'algorithme suivant pour tester si un corps de fonction "n'a pas d'effets secondaires": si une instruction dans le corps a des effets secondaires potentiels, alors le corps de fonction peut avoir des effets secondaires. Sympa et simple. Puis plus tard, les appels aux fonctions «sans effets secondaires» sont supprimés. Cool. Sauf que les instructions de branchement sont considérées comme n'ayant aucun effet secondaire, donc une fonction contenant uniquement des branches semblera n'avoir aucun effet secondaire, même si elle peut contenir une boucle infinie. Oops.

C'est réparable. Si quelqu'un d'autre est intéressé à examiner cela, mon idée de base est de diviser le concept de "a des effets secondaires" en concepts indépendants de "a des effets secondaires réels" et "peut être sans fin". Ensuite, parcourez l'ensemble de l'optimiseur et trouvez tous les endroits qui se soucient des «effets secondaires» et déterminez de quel (s) concept (s) ils ont réellement besoin. Ensuite, apprenez les passes de boucle pour ajouter des métadonnées aux branches qui ne font pas partie d'une boucle, ou les boucles dans lesquelles elles se trouvent sont prouvées finies, afin d'éviter les pessimisations.


Un compromis possible pourrait être d'avoir rustc insert @ llvm.sideeffect quand un utilisateur écrit littéralement une récursion vide loop { } (ou similaire) ou inconditionnelle (qui a déjà une charpie). Ce compromis permettrait aux personnes qui souhaitent réellement une boucle de rotation infinie sans effet de l'obtenir, tout en évitant toute surcharge pour tout le monde. Bien sûr, ce compromis ne rendrait pas impossible le blocage du code sécurisé, mais il réduirait probablement les chances que cela se produise accidentellement, et il semble que cela devrait être facile à implémenter.

Au lieu de cela, l'hypothèse selon laquelle les boucles se termineront ou auront des effets secondaires surgit naturellement dans certains algorithmes de compilation courants.

Cependant, ce n'est tout à fait pas naturel si vous commencez même à penser à l'exactitude de ces transformations. Pour être franc, je pense toujours que c'était une énorme erreur de C de permettre cette hypothèse, mais bon.

si une instruction quelconque dans le corps a des effets secondaires potentiels, alors le corps fonctionnel peut avoir des effets secondaires.

Il y a une bonne raison pour laquelle la "non-résiliation" est généralement considérée comme un effet lorsque vous commencez à regarder les choses formellement. (Haskell n'est pas pur, il a deux effets: la non-résiliation et les exceptions.)

Un compromis possible pourrait être d'avoir rustc insert @ llvm.sideeffect lorsqu'un utilisateur écrit littéralement une boucle vide {} (ou similaire) ou une récursion inconditionnelle (qui a déjà une charpie). Ce compromis permettrait aux personnes qui souhaitent réellement une boucle de rotation infinie sans effet de l'obtenir, tout en évitant toute surcharge pour tout le monde. Bien sûr, ce compromis ne rendrait pas impossible le blocage du code sécurisé, mais il réduirait probablement les chances que cela se produise accidentellement, et il semble que cela devrait être facile à implémenter.

Comme vous l'avez noté vous-même, c'est toujours incorrect. Je ne pense pas que nous devrions accepter une «solution» que nous savons incorrecte. Les compilateurs font tellement partie intégrante de notre infrastructure que nous ne devons pas simplement espérer que rien ne va pas. Ce n'est pas une façon de construire une base solide.


Ce qui s'est passé ici, c'est que la notion d'exactitude a été construite autour de ce que les compilateurs ont fait, au lieu de commencer par "Que voulons- nous de nos compilateurs" et d'en faire ensuite leur spécification. Un compilateur correct ne transforme pas un programme qui diverge toujours en un programme qui se termine, point final. Je trouve cela plutôt évident, mais avec Rust ayant un système de type raisonnable, cela se voit même clairement dans les types, c'est pourquoi le problème refait surface régulièrement.

Compte tenu des contraintes avec lesquelles nous travaillons (à savoir, LLVM), ce que nous devrions faire est de commencer par ajouter llvm.sideeffect à suffisamment d'endroits pour que chaque exécution divergente soit assurée d'en "exécuter" une infinité. Ensuite, nous avons atteint une base de référence raisonnable (comme dans, saine et correcte) et pouvons parler d'améliorations en supprimant ces annotations lorsque nous pouvons garantir qu'elles ne sont pas nécessaires.

Pour rendre mon propos plus précis, je pense que ce qui suit est une caisse Rust saine, avec pick_a_number_greater_2 renvoyant (de manière non déterministe) une sorte de big-int:

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(()) }
}

Si nous compilons cette boucle divergente, c'est un bogue et il devrait être corrigé.

Nous n'avons même pas encore de chiffres sur les performances qu'il en coûterait pour résoudre ce problème naïvement. Tant que nous ne le faisons pas, je ne vois aucune raison d'interrompre délibérément des programmes comme celui-ci.

En pratique, fn foo() { foo() } se terminera toujours en raison de l'épuisement des ressources, mais comme la machine abstraite Rust a un cadre de pile infiniment grand (AFAIK), il est valide de transformer ce code en fn foo() { loop {} } qui ne le sera jamais se terminer (ou bien plus tard, lorsque l'univers se fige). Cette transformation doit-elle être valide? Je dirais oui, car sinon, nous ne pouvons pas effectuer d'optimisations de fin d'appel à moins de pouvoir prouver la résiliation, ce qui serait malheureux.

Serait-il sensé d'avoir un unsafe intrinsèque qui déclare qu'une boucle, une récursion, ... se termine toujours? N1528 donne un exemple où, si les boucles ne peuvent pas être supposées se terminer, la fusion de boucles ne peut pas être appliquée au code de pointeur traversant des listes liées, car les listes liées pourraient être circulaires, et prouver qu'une liste liée n'est pas circulaire n'est pas quelque chose que les compilateurs modernes peuvent faire.

Je suis tout à fait d'accord que nous devons résoudre ce problème de solidité pour de bon. Cependant, la façon dont nous procédons devrait être conscient de la possibilité que "ajouter llvm.sideeffect partout où nous ne pouvons pas prouver que c'est inutile" puisse régresser la qualité du code des programmes qui sont compilés correctement aujourd'hui. Bien que ces préoccupations soient en fin de compte supplantées par la nécessité d'avoir un compilateur sonore, il pourrait être prudent de procéder de manière à retarder un peu la correction appropriée en échange d'éviter les régressions de performances et d'améliorer la qualité de vie du programmeur Rust moyen. temps. Je propose:

  • Comme avec d'autres correctifs potentiellement réducteurs de performances pour les bogues de solidité de longue date (# 10184), nous devrions implémenter le correctif derrière un indicateur -Z pour pouvoir évaluer l'impact sur les performances des bases de code dans la nature.
  • Si l'impact s'avère négligeable, c'est parfait, nous pouvons simplement activer le correctif par défaut.
  • Mais s'il y a de vraies régressions, nous pouvons apporter ces données aux personnes de LLVM et essayer d'abord d'améliorer LLVM (ou nous pourrions choisir de manger la régression et de la corriger plus tard, mais dans tous les cas, nous prendrions une décision éclairée)
  • Si nous décidons de ne pas activer le correctif par défaut en raison de régressions, nous pouvons au moins ajouter llvm.sideeffect à des boucles syntaxiquement vides: elles sont plutôt courantes et leur mauvaise compilation a conduit plusieurs personnes à dépenser des sommes misérables. heures de débogage de problèmes étranges (# 38136, # 47537, # 54214, et il y en a sûrement plus), donc même si cette atténuation n'a aucune incidence sur le bogue de solidité, elle aurait un avantage tangible pour les développeurs pendant que nous résolvons les problèmes dans le bon correction d'un bug.

Certes, cette perspective est éclairée par le fait que cette question existe depuis des années. S'il s'agissait d'une nouvelle régression, je serais plus disposé à la corriger plus rapidement ou à annuler le PR qui l'a introduite.

En attendant, cela devrait-il être mentionné dans https://doc.rust-lang.org/beta/reference/behavior-consemed-undefined.html tant que ce problème est ouvert?

Serait-il logique d'avoir un unsafe intrinsèque qui déclare qu'une boucle, une récursion, ... se termine toujours?

std::hint::reachable_unchecked ?

Incidemment, je suis tombé sur cette écriture de code réel pour un système de messagerie TCP. J'avais une boucle infinie en guise d'arrêt jusqu'à ce que je mette en place un véritable mécanisme d'arrêt mais le fil est sorti immédiatement.

Au cas où quelqu'un voudrait jouer au golf avec code de cas de test:

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

''
$ cargo run - release
Instruction illégale (core dumped)

Au cas où quelqu'un voudrait jouer au golf avec code de cas de test:

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

Avec le drapeau -Z insert-sideeffect rustc, ajouté par @sfanxiang dans https://github.com/rust-lang/rust/pull/59546, il continue de tourner :)

avant:

main:
  ud2

après:

main:
.LBB0_1:
  jmp .LBB0_1

Au fait, le suivi des bogues LLVM est https://bugs.llvm.org/show_bug.cgi?id=965 , que je n'ai pas encore vu publié dans ce fil.

@RalfJung Pouvez-vous mettre à jour le lien hypertexte https://github.com/simnalamburt/snippets/blob/master/rust/src/bin/infinite.rs dans la description du problème dans https://github.com/simnalamburt/snippets/blob /12e73f45f3/rust/infinite.rs ceci? L'ancien lien hypertexte a été interrompu pendant longtemps car in n'était pas un lien permanent. Merci! 😛

@simnalamburt fait, merci!

L'augmentation du niveau de l'optimisation MIR semble éviter une mauvaise optimisation dans le cas suivant:

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()
}

Cela a aidé avec ce cas particulier, mais ne résout pas le problème en général. https://godbolt.org/z/5hv87d

En général, ce problème ne peut être résolu que lorsque rustc ou LLVM peuvent prouver qu'une fonction pure est totale avant d'utiliser les optimisations pertinentes.

En effet, je n'affirmais pas que cela résolvait le problème. L'effet subtil était suffisamment intéressant pour les autres qu'il semblait utile de le mentionner ici aussi. -Z insert-sideeffect continue de corriger les deux cas.

Quelque chose bouge du côté LLVM: il y a une proposition d'ajouter un attribut au niveau de la fonction pour contrôler les garanties de progression. https://reviews.llvm.org/D85393

Je ne sais pas pourquoi tout le monde (ici et sur les fils de LLVM) semble insister sur la clause sur les progrès en avant.

L'élimination de la boucle semble être une conséquence directe d'un modèle de mémoire: les calculs de valeurs sont autorisés à être déplacés, tant qu'ils se produisent - avant l'utilisation de la valeur. Maintenant, s'il y a une preuve qu'il ne peut y avoir aucune utilisation de la valeur, c'est la preuve qu'il n'y a pas d'avance, et le code peut être déplacé infiniment loin dans le futur, tout en satisfaisant le modèle de mémoire.

Ou, si vous n'êtes pas familier avec les modèles de mémoire, considérez que la boucle entière est abstraite dans une fonction calculant une valeur. Remplacez maintenant toutes les lectures de la valeur en dehors de la boucle par un appel de cette fonction. Cette transformation est certainement valable. Maintenant, s'il n'y a aucune utilisation de la valeur, il n'y a pas d'invocations de la fonction qui fait la boucle infinie.

les calculs de valeurs sont autorisés à être déplacés, tant qu'ils se produisent-avant l'utilisation de la valeur. Maintenant, s'il y a une preuve qu'il ne peut y avoir aucune utilisation de la valeur, c'est la preuve qu'il n'y a pas d'avance, et le code peut être déplacé infiniment loin dans le futur, tout en satisfaisant le modèle de mémoire.

Cette déclaration n'est correcte que si ce calcul est assuré de se terminer. La non-terminaison est un effet secondaire, et tout comme vous ne pouvez pas supprimer un calcul qui s'imprime sur stdout (il n'est "pas pur"), vous ne pouvez pas supprimer un calcul qui ne se termine pas.

Il n'est pas acceptable de supprimer l'appel de fonction suivant, même si le résultat n'est pas utilisé:

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

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

Ceci est vrai pour tout type d'effet secondaire, et cela reste vrai lorsque vous remplacez l'impression par un loop {} .

L'affirmation concernant la non-résiliation en tant qu'effet secondaire nécessite non seulement un accord sur le fait que c'est (qui n'est pas controversé), mais aussi un accord sur le moment où il doit être observé.

Une non-terminaison sûre est observée, si la boucle calcule la valeur. La non-terminaison n'est pas observée si vous êtes autorisé à réorganiser les calculs qui ne dépendent pas du résultat de la boucle.

Comme l'exemple sur le thread LLVM.

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

Les propriétés de terminaison de la division n'ont rien à voir avec la réorganisation. Les processeurs modernes tenteront d'exécuter la division, la comparaison, la prédiction de branche et la prélecture de la branche réussie en parallèle. Ainsi, vous n'êtes pas assuré d'observer la division terminée au moment où vous observez 0 retourné, si y est négatif. (Par "observer", j'entends vraiment mesurer avec un oscillomètre où se trouve le processeur, pas par le programme)

Si vous ne pouvez pas observer la division terminée, vous ne pouvez pas observer la division commencée. Ainsi, la division dans l'exemple ci-dessus permettrait généralement d'être réorganisée, ce qu'un compilateur pourrait faire:

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

Je dis «habituellement», car il y a peut-être des langues où cela n'est pas autorisé. Je ne sais pas si Rust est une telle langue.

Les boucles pures ne sont pas différentes.


Je ne dis pas que ce n'est pas un problème. Je dis seulement que la garantie de progrès n'est pas ce qui permet que cela se produise.

L'affirmation concernant la non-résiliation en tant qu'effet secondaire nécessite non seulement un accord sur le fait qu'elle est (qui n'est pas controversée), mais également un accord sur le moment où elle doit être observée.

Ce que j'exprime, c'est le consensus de tout le champ de recherche des langages de programmation et des compilateurs. Bien sûr, vous êtes libre de ne pas être d'accord, mais alors vous pourriez tout aussi bien redéfinir des termes tels que «correction du compilateur» - ce n'est pas utile pour une discussion avec d'autres.

Les observations autorisées sont toujours définies au niveau de la source. La spécification du langage définit une "machine abstraite", qui décrit (idéalement dans des détails mathématiques minutieux) quels sont les comportements observables permis d'un programme. Ce document ne parle d'aucune optimisation.

L'exactitude d'un compilateur est ensuite mesurée par le fait que les programmes qu'il produit ne présentent que des comportements observables que la spécification indique que le programme source peut avoir. C'est ainsi que fonctionne chaque langage de programmation qui prend la correction au sérieux, et c'est la seule façon dont nous savons comment capturer de manière précise quand un compilateur est correct.

Il appartient à chaque langage de définir ce qui est exactement considéré comme observable au niveau source, et quels comportements source sont considérés comme "indéfinis" et peuvent donc être supposés par le compilateur comme ne se produisant jamais. Ce problème survient parce que C ++ dit qu'une boucle infinie sans autres effets secondaires («divergence silencieuse») est un comportement indéfini, mais Rust ne dit pas une telle chose. Cela signifie que la non-terminaison dans Rust est toujours observable et doit être préservée par le compilateur. La plupart des langages de programmation font ce choix, car le choix C ++ peut rendre très facile l'introduction accidentelle d'un comportement non défini (et donc de bogues critiques) dans un programme. Rust promet qu'aucun comportement indéfini ne peut provenir du code sécurisé, et comme le code sécurisé peut contenir des boucles infinies, il s'ensuit que des boucles infinies dans Rust doivent être définies (et donc préservées).

Si ces choses prêtent à confusion, je suggère de faire une lecture de fond. Je peux recommander "Types et langages de programmation" de Benjamin Pierce. Vous trouverez probablement également de nombreux articles de blog, même s'il peut être difficile de juger à quel point l'auteur est vraiment informé.

Pour plus de précision, si votre exemple de division a été changé en

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

alors j'espère que vous conviendrez que le conditionnel _ ne peut pas être hissé au-dessus de la division, car cela changerait le comportement observable lorsque y est égal à zéro (de crash à zéro).

De la même manière, dans

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

la machine abstraite Rust permet de réécrire ceci comme

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

mais la première condition et la boucle ne peuvent pas être ignorées.

Ralf, je ne prétends pas connaître la moitié de ce que vous faites, et je ne veux pas introduire de nouvelles significations. Je suis totalement d'accord avec la définition de ce qu'est l'exactitude (l'ordre d'exécution doit correspondre à l'ordre du programme). Je pensais seulement que le "quand" la non-résiliation est observable en faisait partie, comme dans: si vous ne regardez pas le résultat de la boucle, vous n'avez pas de témoin de sa fin (vous ne pouvez donc pas prétendre à son inexactitude) . J'ai besoin de revoir le modèle d'exécution.

Je vous remercie de m'avoir accompagné

@zackw Merci. C'est un code différent, ce qui entraînera bien sûr une optimisation différente.

Mon hypothèse sur l'optimisation des boucles de la même manière que la division était erronée (je ne peux pas voir le résultat de la division == je ne peux pas voir la boucle se terminer), donc le reste n'a pas d'importance.

@olotenko Je ne sais pas ce que vous entendez par "regarder le résultat de la boucle". Une boucle sans terminaison fait diverger l'ensemble du programme, ce qui est considéré comme un comportement observable - cela signifie qu'il est observable en dehors du programme. Comme dans, l'utilisateur peut exécuter le programme et voir qu'il dure indéfiniment. Un programme qui dure indéfiniment peut ne pas être compilé dans un programme qui se termine, car cela change ce que l'utilisateur peut observer à propos du programme.

Peu importe ce que cette boucle calculait ou si la "valeur de retour" de la boucle est utilisée ou non. Ce qui compte, c'est ce que l'utilisateur peut observer lors de l'exécution du programme. Le compilateur doit s'assurer que ce comportement observable reste le même. La non-résiliation est considérée comme observable.

Pour donner un autre exemple:

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

Ce programme n'imprimera jamais rien, à cause de la boucle. Mais si vous optimisiez la boucle (ou réorganisiez la boucle avec l'impression), tout à coup, le programme imprimait "Hello". Ainsi, ces optimisations modifient le comportement observable du programme et sont interdites.

@RalfJung c'est bon, je l'ai maintenant. Mon problème initial était de savoir quel rôle joue ici la "garantie de progrès". L'optimisation est entièrement possible à partir de la dépendance des données. Mon erreur a été qu'en fait la dépendance des données ne fait pas partie de l'ordre du programme: ce sont littéralement les expressions totalement ordonnées selon la sémantique du langage. Si l'ordre du programme est total, alors sans garantie d'avancement (que nous pouvons reformuler comme "tout sous-chemin de l'ordre du programme est fini"), nous pouvons réorganiser (dans l'ordre d'exécution) uniquement les expressions que nous pouvons _prouver_ comme terminantes (et en préservant quelques autres propriétés, comme l'observabilité des actions de synchronisation, les appels du système d'exploitation, les E / S, etc.).

Je dois y réfléchir un peu plus, mais je pense que je peux voir la raison pour laquelle nous pouvons "prétendre" que la division s'est produite dans l'exemple avec x = y % 42 , même si elle n'est pas vraiment exécutée pour certaines entrées, mais pourquoi la même chose ne s'applique pas aux boucles arbitraires. Je veux dire, les subtilités de la correspondance de l'ordre total (programme) et de l'ordre partiel (exécution).

Je pense que le "comportement observable" peut être un peu plus subtil que cela, car une récursion infinie se terminera par un crash de débordement de pile ("se termine" dans le sens d'un "utilisateur observant le résultat"), mais une optimisation des appels de fin le transformera en une boucle sans terminaison. Au moins, c'est une autre chose que Rust / LLVM fera. Mais nous n'avons pas à discuter de cette question car ce n'est pas vraiment le sujet de mon problème (à moins que vous ne le vouliez! Je suis bien sûr heureux de comprendre si cela est attendu).

débordement de pile

Les débordements de pile sont en effet difficiles à modéliser, bonne question. Idem pour les situations de manque de mémoire. En guise de première approximation, nous prétendons formellement qu'elles ne se produisent pas. Une meilleure approche consiste à dire que chaque fois que vous appelez une fonction, vous pouvez obtenir une erreur en raison d'un débordement de pile, ou le programme peut continuer - il s'agit d'un choix non déterministe effectué à chaque appel. De cette façon, vous pouvez approximer ce qui se passe réellement.

nous ne pouvons réordonner (dans l'ordre d'exécution) que les expressions que nous pouvons prouver

En effet. De plus, ils doivent être "purs", c'est-à-dire sans effets secondaires - vous ne pouvez pas réorganiser deux println! . C'est pourquoi nous considérons généralement la non-terminaison comme un effet aussi, car alors tout se réduit à "les expressions pures peuvent être réordonnées", et "les expressions non terminales sont impures" (impure = a un effet secondaire).

La division est également potentiellement impure, mais seulement lors de la division par 0 - ce qui provoque une panique, c'est-à-dire un effet de contrôle. Ce n'est pas directement observable mais indirectement (par exemple en demandant au gestionnaire de panique d'afficher quelque chose sur stdout, qui est alors observable). Ainsi, la division ne peut être réorganisée que si nous sommes sûrs de ne pas diviser par 0.

J'ai un code de démonstration qui, je pense, pourrait être ce problème, mais je ne suis pas entièrement sûr. Si nécessaire, je peux mettre cela dans un nouveau rapport de bogue.
J'ai mis le code pour cela dans un repo git à https://github.com/uglyoldbob/rust_demo

Ma boucle infinie (avec effets secondaires) est optimisée et une instruction trap est générée.

Je n'ai aucune idée si c'est une instance de ce problème ou autre chose ... les périphériques embarqués ne sont pas du tout ma spécialité et avec toutes ces dépendances de crate externes, je n'ai aucune idée de ce que fait ce code. ^^ Mais votre programme est pas sûr et il a un accès volatile dans la boucle, donc je dirais que c'est un problème distinct. Quand je mets votre exemple sur le terrain de jeu , je pense qu'il est compilé correctement, donc je soupçonne que le problème vient de l'une des dépendances supplémentaires.

Il semble que tout dans la boucle est une référence à une variable locale (aucune n'est échappée à un autre thread). Dans ces circonstances, il est facile de prouver l'absence de magasins volatils et l'absence d'effets observables (pas de magasins avec lesquels ils peuvent se synchroniser). Si Rust n'ajoute pas de signification particulière aux volatiles, alors cette boucle peut être réduite à une boucle infinie pure.

@uglyoldbob Ce qui se passe vraiment dans votre exemple serait plus clair si llvm-objdump n'était pas spectaculairement inutile (et inexact). Cette bl #4 (qui n'est pas réellement une syntaxe d'assembly valide) signifie ici une branche à 4 octets après la fin de l'instruction bl , c'est-à-dire la fin de la fonction main , alias le début de la fonction suivante. La fonction suivante est appelée (quand je la construis) _ZN11broken_loop18__cortex_m_rt_main17hbe300c9f0053d54dE , et c'est votre fonction main réelle. La fonction avec le nom démêlé main n'est pas votre fonction, mais une fonction complètement différente générée par la macro #[entry] fournie par cortex-m-rt . Votre code n'est pas réellement optimisé. (En fait, l'optimiseur ne fonctionne même pas puisque vous créez en mode débogage.)

Cette page vous a été utile?
0 / 5 - 0 notes