Rust: La optimización del bucle LLVM puede hacer que los programas seguros se bloqueen

Creado en 29 sept. 2015  ·  97Comentarios  ·  Fuente: rust-lang/rust

El siguiente fragmento falla cuando se compila en el modo de lanzamiento en la versión estable actual, beta y nocturna:

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

Esto se basa en el siguiente ejemplo de LLVM eliminando un bucle del que me enteré: https://github.com/simnalamburt/snippets/blob/12e73f45f3/rust/infinite.rs.
Lo que parece suceder es que dado que C permite que LLVM elimine los bucles infinitos que no tienen efectos secundarios, terminamos ejecutando un match que tiene que armarse.

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

Comentario más útil

En caso de que alguien quisiera jugar golf con código de caso de prueba:

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

Con la bandera -Z insert-sideeffect rustc, agregada por @sfanxiang en https://github.com/rust-lang/rust/pull/59546, sigue en bucle :)

antes de:

main:
  ud2

después:

main:
.LBB0_1:
  jmp .LBB0_1

Todos 97 comentarios

El LLVM IR del código optimizado es

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

Este tipo de optimización rompe el supuesto principal que normalmente debería aplicarse a tipos deshabitados: debería ser imposible tener un valor de ese tipo.
rust-lang / rfcs # 1216 propone manejar explícitamente tales tipos en Rust. Podría ser eficaz para garantizar que LLVM nunca tenga que manejarlos y para inyectar el código apropiado para garantizar la divergencia cuando sea necesario (IIUIC, esto podría lograrse con los atributos apropiados o llamadas intrínsecas).
Este tema también se ha discutido recientemente en la lista de correo LLVM: http://lists.llvm.org/pipermail/llvm-dev/2015-July/088095.html

triaje: I-nominado

¡Parece mal! Si LLVM no tiene una forma de decir "sí, este bucle es realmente infinito", es posible que tengamos que sentarnos y esperar a que se resuelva la discusión anterior.

Una forma de evitar que se optimicen los bucles infinitos es agregar unsafe {asm!("" :::: "volatile")} dentro de ellos. Esto es similar al intrínseco llvm.noop.sideeffect que se ha propuesto en la lista de correo LLVM, pero puede evitar algunas optimizaciones.
Para evitar la pérdida de rendimiento y aún así garantizar que las funciones / bucles divergentes no se optimicen, creo que debería ser suficiente insertar un bucle vacío no optimizable (es decir, loop { unsafe { asm!("" :::: "volatile") } } ) si hay valores deshabitados alcance.
Si LLVM optimiza el código que debería divergir hasta el punto de que ya no diverja, tales bucles asegurarán que el flujo de control aún no pueda continuar.
En el caso "afortunado" en el que LLVM no puede optimizar el código divergente, el DCE eliminará dicho bucle.

¿Está relacionado con el número 18785? Se trata de una recursividad infinita para ser UB, pero parece que la causa fundamental podría ser similar: LLVM no considera que no detenerse sea un efecto secundario, por lo que si una función no tiene efectos secundarios aparte de no detenerse, está feliz de optimizar lejos.

@geofft

Es el mismo problema.

Sí, parece que es lo mismo. Más adelante en ese tema, muestran cómo obtener undef , de lo cual supongo que no es difícil hacer que un programa se bloquee (aparentemente seguro).

: +1:

Choque, o posiblemente peor sangrado del corazón https://play.rust-lang.org/?gist=15a325a795244192bdce&version=stable

Así que me he estado preguntando cuánto tiempo hasta que alguien informe esto. :) En mi opinión, la mejor solución sería, por supuesto, si pudiéramos decirle a LLVM que no sea tan agresivo con los bucles potencialmente infinitos. De lo contrario, lo único que creo que podemos hacer es hacer un análisis conservador en el propio Rust que determine si:

  1. el bucle terminará O
  2. el bucle tendrá efectos secundarios (operaciones de E / S, etc., olvido exactamente cómo se define esto en C)

Cualquiera de estos debería ser suficiente para evitar un comportamiento indefinido.

triaje: P-medio

Nos gustaría ver qué hará LLVM antes de invertir mucho esfuerzo de nuestro lado, y parece relativamente poco probable que esto cause problemas en la práctica (aunque personalmente he dado con esto mientras desarrollaba el compilador). No hay problemas de incomatibilidad hacia atrás de los que preocuparse.

Citando de la discusión de la lista de correo 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 El extracto que está citando proviene de la especificación C ++; es básicamente la respuesta a "cómo se define [tener efectos secundarios] en C" (también confirmado por el comité estándar: http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1528 .htm).

En cuanto a cuál es el comportamiento esperado del LLVM IR existe cierta confusión. https://llvm.org/bugs/show_bug.cgi?id=24078 muestra que no parece haber una especificación precisa y explícita de la semántica de bucles infinitos en LLVM IR. Se alinea con la semántica de C ++, probablemente por razones históricas y por conveniencia (solo logré rastrear https://groups.google.com/forum/#!topic/llvm-dev/j2vlIECKkdE que aparentemente se refiere a un tiempo cuando los bucles infinitos no se optimizaron, algún tiempo antes de que se actualizaran las especificaciones de C / C ++ para permitirlo).

Del hilo queda claro que existe el deseo de optimizar el código C ++ de la manera más efectiva posible (es decir, también teniendo en cuenta la oportunidad de eliminar bucles infinitos), pero en el mismo hilo varios desarrolladores (incluidos algunos que contribuyen activamente a LLVM) han mostró interés en la capacidad de conservar bucles infinitos, ya que son necesarios para otros lenguajes.

@ ranma42 Soy consciente de eso, solo lo solucionar esto sería detectar tales bucles en el óxido y agregar uno de los anteriores para evitar que LLVM realice esta optimización.

¿Es este un problema de solidez? Si es así, deberíamos etiquetarlo como tal.

Sí, siguiendo el ejemplo de @ ranma42 , de esta forma se muestra cómo vence fácilmente las comprobaciones de límites de matriz. enlace de patio de recreo

@bluss

La política es que los problemas de código incorrecto que también son problemas de solidez (es decir, la mayoría de ellos) deben etiquetarse como I-wrong .

Entonces, solo para recapitular la discusión anterior, realmente hay dos opciones aquí que puedo ver:

  • Espere a que LLVM proporcione una solución.
  • Introduzca sentencias asm no operativas donde pueda haber un bucle infinito o una recursividad infinita (# 18785).

Esto último es algo malo porque puede inhibir la optimización, por lo que querríamos hacerlo con moderación, básicamente donde no podamos probar la terminación nosotros mismos. También podría crear una imagen vinculándolo un poco más a cómo LLVM optimiza, es decir, introduciendo solo si podemos detectar un escenario que LLVM podría considerar un bucle / recursión infinito, pero eso (a) requeriría el seguimiento de LLVM y (b ) requieren un conocimiento más profundo del que yo, al menos, poseo.

Espere a que LLVM proporcione una solución.

¿Cuál es el error de LLVM que rastrea este problema?

nota al margen: while true {} exhibe este comportamiento . ¿Quizás la pelusa debería actualizarse a error de forma predeterminada y obtener una nota que indique que esto actualmente puede exhibir un comportamiento indefinido?

Además, tenga en cuenta que esto no es válido para C. LLVM, por lo que este argumento significa que hay un error en 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();
}

Esto falla con las optimizaciones; este es un comportamiento no válido según el estándar 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. 

Tenga en cuenta que "cuya expresión de control no es una expresión constante" - while (1) { } , 1 es una expresión constante y, por lo tanto , no puede eliminarse .

¿Es la eliminación del bucle un pase de optimización que simplemente podríamos eliminar?

@ubsan

¿Encontraste un informe de error para eso en el bugzilla de LLVM o llenaste uno? Parece que en C ++ los bucles infinitos que _pueden_ nunca terminar son comportamientos indefinidos, pero en C son comportamientos definidos (o se pueden eliminar de forma segura en algunos casos o no en otros).

@gnzlbg Estoy presentando un error ahora.

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

Repitiéndome de # 42009: este error puede, en algunas circunstancias, causar la emisión de una función externamente invocable que no contenga ninguna instrucción de máquina. Esto nunca debería suceder. Si LLVM deduce que un pub fn nunca puede ser llamado por el código correcto, debería emitir al menos una instrucción trap como el cuerpo de esa función.

El error LLVM para esto es https://bugs.llvm.org/show_bug.cgi?id=965 (inaugurado en 2006).

@zackw LLVM tiene una bandera para eso: TrapUnreachable . No he probado esto, pero parece que agregar Options.TrapUnreachable = true; a LLVMRustCreateTargetMachine debería resolver su inquietud. Es probable que esto tenga un costo lo suficientemente bajo como para poder hacerlo de forma predeterminada, aunque no he tomado ninguna medida.

@ oli-obk Lamentablemente, no es solo un pase de eliminación de bucle. El problema surge de supuestos amplios, por ejemplo: (a) las ramas no tienen efectos secundarios, (b) las funciones que no contienen instrucciones con efectos secundarios no tienen efectos secundarios, y (c) las llamadas a funciones sin efectos secundarios se pueden mover o eliminado.

Parece que hay un parche: https://reviews.llvm.org/D38336

@sunfishcode , parece que su parche LLVM en https://reviews.llvm.org/D38336 fue "aceptado" el 3 de octubre, ¿puede darnos una actualización de lo que eso significa con respecto al proceso de lanzamiento de LLVM? ¿Cuál es el siguiente paso más allá de la aceptación? ¿Tiene una idea de qué versión futura de LLVM contendrá este parche?

Hablé con algunas personas sin conexión que sugirieron que tuviéramos un hilo llvmdev. El hilo está aquí:

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

Ahora está concluido, y el resultado es que necesito hacer cambios adicionales. Creo que los cambios serán buenos, aunque me llevará un poco más de tiempo hacerlos.

¡Gracias por la actualización y muchas gracias por sus esfuerzos!

Tenga en cuenta que https://reviews.llvm.org/rL317729 ha aterrizado en LLVM. Se planea que este parche tenga un parche de seguimiento que hace que los bucles infinitos exhiban un comportamiento definido de forma predeterminada, por lo que AFAICT todo lo que tenemos que hacer es esperar y eventualmente esto se resolverá para nosotros en sentido ascendente.

@zackw He creado # 45920 para solucionar el problema de las funciones que no contienen código.

@bstrie Sí, el primer paso está aterrizado y estoy trabajando en el segundo paso para hacer que LLVM dé un comportamiento definido de bucles infinitos de forma predeterminada. Es un cambio complejo y todavía no sé cuánto tiempo tardará en finalizar, pero publicaré actualizaciones aquí.

@jsgf Aún repro. ¿Ha seleccionado el modo de lanzamiento?

@kennytm Woops, no importa.

Tenga en cuenta que https://reviews.llvm.org/rL317729 ha aterrizado en LLVM. Se planea que este parche tenga un parche de seguimiento que hace que los bucles infinitos exhiban un comportamiento definido de forma predeterminada, por lo que AFAICT todo lo que tenemos que hacer es esperar y eventualmente esto se resolverá para nosotros en sentido ascendente.

Han pasado varios meses desde este comentario. ¿Alguien sabe si el parche de seguimiento sucedió o seguirá sucediendo?

Alternativamente, parece que el llvm.sideeffect intrínseco existe en la versión LLVM que estamos usando: ¿podríamos solucionar esto nosotros mismos traduciendo los bucles infinitos de Rust en bucles LLVM que contienen el efecto secundario intrínseco?

Como se ve en https://github.com/rust-lang/rust/issues/38136 y https://github.com/rust-lang/rust/issues/54214 , esto es especialmente malo con el próximo panic_implementation , como implementación lógica será loop {} , y esto haría que todas las apariciones de panic! UB sin ningún código unsafe . Lo cual ... es quizás lo peor que podría pasar.

Me encontré con este problema bajo otra luz. He aquí un ejemplo:

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

Este ejemplo segfaults de manera confiable en estable, beta y nocturno, y muestra lo fácil que es construir valores no inicializados de cualquier tipo. Aquí está en el patio de recreo .

@SergioBenitez ese programa no segregula, termina con un desbordamiento de pila (debe ejecutarlo en modo de depuración). Este es el comportamiento correcto, ya que su programa simplemente se repite infinitamente requiriendo una cantidad infinita de espacio de pila, que en algún momento excederá el espacio de pila disponible. Ejemplo de trabajo mínimo .

En las versiones de versiones, LLVM puede asumir que no tiene recursividad infinita y lo optimiza ( mwe ). Esto no tiene nada que ver con los bucles AFAICT, sino con https://stackoverflow.com/a/5905171/1422197

@gnzlbg Lo siento, pero no estás en lo correcto.

El programa se segrega en modo de liberación. Ese es todo el punto; que una optimización da como resultado un comportamiento incorrecto - que LLVM y la semántica de Rust no están de acuerdo aquí - que puedo escribir y compilar un programa seguro de Rust con rustc que me permite usar memoria no inicializada, inspeccionar memoria arbitraria y lanzar arbitrariamente entre tipos, violando la semántica del lenguaje. Ese es el mismo punto que se ilustra en este hilo. Tenga en cuenta que el programa original tampoco segmenta en modo de depuración.

También parece estar proponiendo que aquí se está llevando a cabo una _diferente_ optimización sin bucle. Eso es poco probable, aunque en gran medida irrelevante, aunque podría justificar un problema separado si es el caso. Supongo que LLVM está notando la recursividad de la cola, tratándola como un bucle infinito y optimizándola, nuevamente, exactamente de qué se trata este problema.

@gnzlbg Bueno, cambiando ligeramente su mwe de optimización lejos de la recursividad infinita ( aquí ), genera un valor no inicializado de NonZeroUsize (que resulta ser… 0, por lo tanto, un valor no válido).

Y eso es lo que @SergioBenitez también hizo con su ejemplo, excepto que es con punteros, y por lo tanto genera un segfault.

¿Estamos de acuerdo en que el programa @SergioBenitez tiene un desbordamiento de pila tanto en depuración como en lanzamiento?

Si es así, no puedo encontrar ningún loop s en el ejemplo de @SergioBenitez , por lo que no sé cómo se aplicaría este problema (después de todo, este problema se trata de loop s infinitos). Si me equivoco, indíqueme el loop en su ejemplo.

Como se mencionó, LLVM asume que la recursividad infinita no puede ocurrir (asume que todos los subprocesos eventualmente terminan), pero ese sería un problema diferente de este.

No he inspeccionado las optimizaciones que LLVM hace o el código generado para ninguno de los programas, pero tenga en cuenta que un segfault no es incorrecto, si el segfault es todo lo que sucede. En particular, los desbordamientos de pila que se detectan (al sondear la pila + una página de protección sin asignar después del final de la pila) y no causan ningún problema de seguridad de la memoria también se muestran como segfaults. Por supuesto, los segfaults también pueden indicar corrupción de memoria o escrituras / lecturas salvajes u otros problemas de solidez.

@rkruppe Mi programa segfaults porque se permitió construir una referencia a una ubicación de memoria aleatoria, y la referencia se leyó posteriormente. El programa se puede modificar trivialmente para escribir una ubicación de memoria aleatoria y, sin demasiada dificultad, leer / escribir una ubicación de memoria _particular_.

@gnzlbg El programa _no_ desborda la pila en el modo de lanzamiento. En el modo de liberación, el programa no realiza ninguna llamada de función; la pila se inserta en un número finito de veces, simplemente para asignar locales.

El programa no se desborda en el modo de lanzamiento.

¿Entonces? Lo único que importa es que el programa de ejemplo, que es básicamente fn foo() { foo() } , tiene una recursividad infinita, que LLVM no permite.

Lo único que importa es que el programa de ejemplo, que es básicamente fn foo () {foo ()}, tiene una recursividad infinita, que LLVM no permite.

No sé por qué dices esto como si resolviera algo. LLVM considerando la recursividad infinita y los bucles UB y optimizando en consecuencia, sin embargo, siendo seguro en Rust, ¡es el objetivo de todo este problema!

Autor de https://reviews.llvm.org/rL317729 aquí, confirmando que aún no he implementado el parche de seguimiento.

Puede insertar una llamada @llvm.sideeffect hoy para asegurarse de que los bucles no estén optimizados. Eso podría deshabilitar algunas optimizaciones, pero en teoría no demasiadas, ya que se ha enseñado a entender las optimizaciones principales. Si uno coloca llamadas @llvm.sideeffect en todos los bucles o cosas que podrían convertirse en bucles (recursividad, desenrollado, ensamblaje en línea , ¿otros?), Eso es teóricamente suficiente para solucionar el problema aquí.

Obviamente, sería mejor tener el segundo parche en su lugar, de modo que no sea necesario hacer esto. No sé cuándo volveré a implementar eso.

Hay una pequeña diferencia, pero no estoy seguro de si es material o no.

Recursividad

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

Lazo

#[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, compilando en modo de lanzamiento, viendo el LLVM IR.

No creo que podamos, en general, detectar la recursividad (objetos de rasgo, C FFI, etc.), por lo que tendríamos que usar llvm.sideeffect en casi todos los sitios de llamadas a menos que podamos probar que la llamada el sitio no se repetirá. Probar que no hay recursiones para los casos en los que se puede probar requiere un análisis interprocedimiento, excepto para los programas más triviales como fn main() { main() } . Puede ser bueno saber cuál es el impacto de implementar esta solución y si existen soluciones alternativas a este problema.

@gnzlbg Eso es cierto, aunque podría poner los efectos @ llvm.sideeffects en las entradas de funciones, en lugar de en los sitios de llamada.

Curiosamente, no puedo reproducir el SEGFAULT en el caso de prueba de @SergioBenitez 'localmente.

Además, para un desbordamiento de pila, ¿no debería haber un mensaje de error diferente? Pensé que teníamos algún código para imprimir "La pila se desbordó" o algo así.

@RalfJung, ¿lo intentaste en modo de depuración? (Puedo reproducir de manera confiable el desbordamiento de la pila en modo de depuración en mis máquinas y en el patio de juegos, por lo que tal vez necesite corregir un error si este no es el caso para usted). En --release no obtendrá un desbordamiento de pila porque todo ese código está mal optimizado.


@sunfishcode

Eso es cierto, aunque podría poner @ llvm.sideeffects en las entradas de funciones, en lugar de en los sitios de llamada.

Es difícil saber cuál sería la mejor manera de avanzar sin saber exactamente qué optimizaciones evita llvm.sideeffects . ¿Vale la pena intentar generar la menor cantidad posible de @llvm.sideeffects ? De lo contrario, ponerlo en cada llamada de función podría ser lo más sencillo de hacer. De lo contrario, IIUC, si se necesita @llvm.sideeffect depende de lo que haga el sitio de la llamada:

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, tenemos que poner @llvm.sideeffect dentro de las funciones para evitar que se eliminen, así que incluso si estas "optimizaciones" valieran la pena, no creo que sean sencillas de hacer con el modelo actual. Incluso si lo fueran, estas optimizaciones se basarían en poder demostrar que no hay recursividad.

¿Intentaste en modo de depuración? (Puedo reproducir de manera confiable el desbordamiento de la pila en modo de depuración en mis máquinas y en el patio de juegos, por lo que tal vez necesite corregir un error si este no es el caso para usted)

Claro, pero en el modo de depuración LLVM no realiza las optimizaciones de bucle, por lo que no hay problema.

Si la pila del programa se desborda en modo de depuración, eso no debería otorgarle a LLVM una licencia para crear UB. El problema es averiguar si el programa final tiene UB, y al mirar el IR no puedo decirlo. Segfaults, pero no sé por qué. Pero me parece un error "optimizar" un programa que desborda la pila en uno que segfaults.

Si la pila del programa se desborda en modo de depuración, eso no debería otorgarle a LLVM una licencia para crear UB.

Pero me parece un error "optimizar" un programa que desborda la pila en uno que segfaults.

En C, se supone que un hilo de ejecución termina, realiza accesos a memoria volátil, E / S o una operación atómica de sincronización. Me sorprendería que LLVM-IR no evolucionara para tener la misma semántica, ya sea por accidente o por diseño.

El código de Rust contiene un hilo de ejecución que nunca termina y no realiza ninguna de las operaciones necesarias para que esto no sea UB en C. Sospecho que generamos el mismo LLVM-IR que generaría un programa C con comportamiento indefinido. , por lo que no creo que sea sorprendente que LLVM esté optimizando mal este programa de Rust.

Segfaults, pero no sé por qué.

LLVM elimina la recursividad infinita, por lo que @SergioBenitez mencionó anteriormente, el programa luego procede a:

se permitió construir una referencia a una ubicación de memoria aleatoria y, posteriormente, se leyó la referencia.

La parte del programa que lo hace es esta:

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

donde Container::from comienza una recursividad infinita, que LLVM concluye que nunca puede suceder, y reemplaza con algún valor aleatorio, que luego se desreferencia. Puede ver una de las muchas formas en que esto se optimiza aquí: https://rust.godbolt.org/z/P7Snex En el patio de recreo (https://play.rust-lang.org/?gist=f00d41cc189f9f6897d429350f3781ec&version=stable&mode = release & edition = 2015) uno tiene un pánico diferente en el lanzamiento de las compilaciones de depuración debido a esta optimización, pero UB es UB es UB.

El código de Rust contiene un hilo de ejecución que nunca termina y no realiza ninguna de las operaciones necesarias para que esto no sea UB en C. Sospecho que generamos el mismo LLVM-IR que generaría un programa C con comportamiento indefinido. , por lo que no creo que sea sorprendente que LLVM esté optimizando mal este programa de Rust.

Tenía la impresión de que usted argumentó anteriormente que este no es el mismo error que el problema del bucle infinito. Entonces parece que leí mal tus mensajes. Perdón por la confusion.

Entonces, parece que un buen próximo paso sería agregar algo de llvm.sideffect en el IR que generamos y hacer algunos puntos de referencia.

En C, se supone que un hilo de ejecución termina, realiza accesos a memoria volátil, E / S o una operación atómica de sincronización.

Por cierto, esto no es del todo correcto: un bucle con un condicional constante (como while (true) { /* ... */ } ) está explícitamente permitido por el estándar, incluso si no contiene ningún efecto secundario. Esto es diferente en C ++. LLVM no implementa el estándar C correctamente aquí.

Tenía la impresión de que usted argumentó anteriormente que este no es el mismo error que el problema del bucle infinito.

El comportamiento de los programas Rust que no terminan siempre se define, mientras que el comportamiento de los programas LLVM-IR que no terminan solo se define si se cumplen ciertas condiciones.

Pensé que este problema se trata de corregir la implementación de Rust para bucles infinitos de modo que el comportamiento del LLVM-IR generado se defina, y para esto, @llvm.sideeffect , sonaba como una solución bastante buena.

@SergioBenitez mencionó que también se pueden crear programas de Rust que no terminen usando la recursividad, y @rkruppe argumentó que la recursión infinita y los bucles infinitos son equivalentes, por lo que ambos son el mismo error.

No estoy en desacuerdo con que estos dos problemas estén relacionados, o incluso que sean el mismo error, pero para mí, estos dos problemas se ven ligeramente diferentes:

  • En cuanto a la solución, pasamos de aplicar una barrera de optimización ( @llvm.sideeffect ) exclusivamente a bucles que no terminan, para aplicarla a cada función de Rust.

  • En términos de valor, los loop s infinitos son útiles porque el programa nunca termina. Para la recursividad infinita, si el programa termina depende del nivel de optimización (por ejemplo, si LLVM transforma la recursividad en un bucle o no), y cuándo y cómo termina el programa depende de la plataforma (tamaño de pila, página de guarda protegida, etc.). Se requiere corregir ambos para hacer que la implementación de Rust suene, pero para el caso de la recursividad infinita, si el usuario pretendiera que su programa se repitiera para siempre, una implementación sólida aún sería "incorrecta" en el sentido de que no siempre se repite para siempre.

En cuanto a la solución, pasamos de aplicar una barrera de optimización (@ llvm.sideeffect) exclusivamente a bucles que no terminan, para aplicarla a cada función de Rust.

El análisis requerido para mostrar que un cuerpo de bucle realmente tiene efectos secundarios (no solo potencialmente , como con las llamadas a funciones externas) y, por lo tanto, no necesita una inserción llvm.sideeffect es bastante complicado, probablemente en aproximadamente el mismo orden de magnitud que muestra lo mismo para una función que puede ser parte de la recursividad infinita. Demostrar que un ciclo está terminando también es difícil sin hacer muchas optimizaciones primero, ya que la mayoría de los ciclos de Rust involucran iteradores. Así que creo que acabaríamos poniendo llvm.sideeffect en la gran mayoría de los bucles independientemente. Es cierto que hay bastantes funciones que no contienen bucles, pero todavía no me parece una diferencia cualitativa.

Si entiendo el problema correctamente, para arreglar el caso del bucle infinito, debería ser suficiente insertar llvm.sideeffect en loop { ... } y while <compile-time constant true> { ... } donde el cuerpo del bucle no contiene break expresiones. Esto captura la diferencia entre la semántica de C ++ y la semántica de Rust para bucles infinitos: en Rust, a diferencia de C ++, el compilador no puede asumir que un bucle termina cuando se puede conocer en tiempo de compilación que _no_. (No estoy seguro de cuánto debamos preocuparnos por la corrección frente a bucles en los que el cuerpo podría entrar en pánico, pero eso siempre se puede mejorar más adelante).

No sé qué hacer con la recursividad infinita, pero estoy de acuerdo con RalfJung en que optimizar una recursión infinita en una falla segmentada no relacionada no es un comportamiento deseable.

@zackw

Si entiendo el problema correctamente, para arreglar el caso de bucle infinito, debería ser suficiente insertar llvm.sideeffect en el bucle {...} y while{...} donde el cuerpo del bucle no contiene expresiones de ruptura.

No creo que sea tan simple, por ejemplo, loop { if false { break; } } es un bucle infinito que contiene una expresión break , pero debemos insertar @llvm.sideeffect para evitar que llvm lo elimine. AFAICT tenemos que insertar @llvm.sideeffect menos que podamos probar que el ciclo siempre termina.

@gnzlbg

loop { if false { break; } } es un bucle infinito que contiene una expresión de ruptura, pero necesitamos insertar @llvm.sideeffect para evitar que llvm lo elimine.

Hm, sí, eso es problemático. Pero no tenemos que ser _perfectos_, solo conservadoramente correctos. Un bucle como

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

(de la documentación de std::sync::atomic ) se vería fácilmente que no necesita un @llvm.sideeffect , ya que la condición de control no es constante (y es mejor que una operación de carga atómica cuente como un efecto secundario para propósitos de LLVM , o tenemos problemas mayores). El tipo de bucle finito que podría emitir un generador de programas,

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

tampoco debería ser problemático. De hecho, ¿hay algún caso en el que la regla "sin expresiones de ruptura en el cuerpo del bucle" se equivoque _además_

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

?

Pensé que este problema se trata de arreglar la implementación de Rust para bucles infinitos de modo que el comportamiento del LLVM-IR generado se defina, y para esto, @ llvm.sideeffect, sonaba como una solución bastante buena.

Lo suficientemente justo. Sin embargo, como dijiste, el problema (la falta de coincidencia entre la semántica de Rust y la semántica de LLVM) se trata en realidad de no terminación, no de bucles. Así que creo que eso es lo que deberíamos estar rastreando aquí.

@zackw

Si entiendo el problema correctamente, para arreglar el caso de bucle infinito, debería ser suficiente insertar llvm.sideeffect en el bucle {...} y while{...} donde el cuerpo del bucle no contiene expresiones de ruptura. Esto captura la diferencia entre la semántica de C ++ y la semántica de Rust para bucles infinitos: en Rust, a diferencia de C ++, el compilador no puede asumir que un bucle termina cuando se puede conocer en tiempo de compilación que no lo hace. (No estoy seguro de cuánto debamos preocuparnos por la corrección frente a bucles en los que el cuerpo podría entrar en pánico, pero eso siempre se puede mejorar más adelante).

Lo que usted describe es válido para C. En Rust, cualquier bucle puede divergir. Todo lo demás sería incorrecto.

Así por ejemplo

while test_fermats_last_theorem_on_some_random_number() { }

es un programa aceptable en Rust (pero ni en C ni en C ++), y se repetirá para siempre sin causar efectos secundarios. Entonces, tiene que ser todos los bucles, excepto aquellos que podamos probar que terminarán.

@zackw

¿Hay algún caso de que la regla "no hay expresiones de ruptura en el cuerpo del bucle" se equivoque además

No es solo if /*compile-time condition */ . Todo el flujo de control se ve afectado ( while , match , for , ...) y las condiciones de tiempo de ejecución también se ven afectadas.

Pero no tenemos que ser perfectos, solo conservadoramente correctos.

Considerar:

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

donde x es una condición de tiempo de ejecución. Si no emitimos @llvm.sideeffect aquí, entonces si el usuario escribe foo(false) algún lugar, foo podría estar en línea y con propagación constante y eliminación de código muerto, el ciclo optimizado en un bucle infinito sin efectos secundarios, lo que resulta en una mala optimización.

Si eso tiene sentido, una transformación que LLVM podría hacer es reemplazar foo con foo_opt :

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

donde ambas ramas se optimizan de forma independiente, y la segunda rama estaría mal optimizada si no usamos @llvm.sideeffect .

Es decir, para poder omitir @llvm.sideeffect , tendríamos que demostrar que LLVM no puede optimizar mal ese ciclo bajo ninguna circunstancia. La única forma de probar esto es probar que el ciclo siempre termina, o probar que si no termina, hace incondicionalmente una de las cosas que previenen las optimizaciones incorrectas. Incluso entonces, optimizaciones como la división / pelado de bucles podrían transformar un bucle en una serie de bucles, y sería suficiente que uno de ellos no tuviera @llvm.sideeffect para que ocurriera una mala optimización.

Todo acerca de este error me parece que sería mucho más fácil de resolver desde LLVM que desde rustc . (descargo de responsabilidad: realmente no conozco el código base de ninguno de estos proyectos)

Según tengo entendido, la solución de LLVM sería cambiar las optimizaciones de ejecutarse (probar la no terminación || tampoco puede probar) a ejecutarse solo cuando se pueda probar la no terminación (o lo contrario). No estoy diciendo que esto sea fácil (de ninguna manera), pero LLVM ya (supongo) incluye código para intentar probar la (no) terminación de los bucles.

Por otro lado, rustc solo puede hacer esto agregando @llvm.sideeffect , lo que potencialmente tendrá más impacto en la optimización que "solo" deshabilitar las optimizaciones que hacen un uso inapropiado de la no terminación. Y rustc tendría que incrustar un nuevo código para intentar detectar (no) la terminación de los bucles.

Entonces creo que el camino a seguir sería:

  1. Agregue @llvm.sideeffect en cada bucle y llamada de función para solucionar el problema
  2. Arregle LLVM para no realizar optimizaciones incorrectas en bucles no terminados y elimine el @llvm.sideeffects

¿Qué piensas sobre esto? Sin embargo, espero que el impacto en el rendimiento del paso 1 no sea demasiado horrible, incluso si está destinado a desaparecer una vez que se implemente 2 ...

@Ekleog De eso se trata el segundo parche de https://lists.llvm.org/pipermail/llvm-dev/2017-October/118595.html

parte de la propuesta de atributo de función es
cambiar la semántica predeterminada de LLVM IR para tener un comportamiento definido en
bucles infinitos, y luego agregue un atributo optando por potencial-UB. Entonces
si hacemos eso, entonces el rol de @ llvm.sideeffect se vuelve un poco
sutil: sería una forma de que una interfaz para un lenguaje como C optara
en potencial-UB para una función, pero luego optar por no participar
bucles en esa función.

Para ser justos con LLVM, los escritores de compiladores no abordan este tema desde la perspectiva de "¡Voy a escribir una optimización que demuestre que los bucles no terminan, de modo que pueda optimizarlos pedante!" En cambio, la suposición de que los bucles terminarán o tendrán efectos secundarios surge naturalmente en algunos algoritmos de compilación comunes. Arreglar esto no es solo una modificación del código existente; requerirá una cantidad significativa de nueva complejidad.

Considere el siguiente algoritmo para probar si un cuerpo funcional "no tiene efectos secundarios": si alguna instrucción en el cuerpo tiene efectos secundarios potenciales, entonces el cuerpo funcional puede tener efectos secundarios. Agradable y sencillo. Luego, más tarde, se eliminan las llamadas a funciones "sin efectos secundarios". Frio. Excepto que se considera que las instrucciones de bifurcación no tienen efectos secundarios, por lo que una función que contiene solo bifurcaciones parecerá no tener efectos secundarios, aunque pueda contener un bucle infinito. ¡Ups!

Es reparable. Si alguien más está interesado en investigar esto, mi idea básica es dividir el concepto de "tiene efectos secundarios" en conceptos independientes de "tiene efectos secundarios reales" y "puede no terminar". Y luego revise todo el optimizador y encuentre todos los lugares que se preocupan por "tiene efectos secundarios" y descubra qué concepto (s) realmente necesitan. Y luego enséñele a los pases de bucle para agregar metadatos a las ramas que no forman parte de un bucle, o los bucles en los que se encuentran son probablemente finitos, para evitar pesimizaciones.


Un posible compromiso podría ser que rustc insert @ llvm.sideeffect cuando un usuario escribe literalmente un loop { } vacío (o similar) o una recursividad incondicional (que ya tiene un lint). Este compromiso permitiría a las personas que realmente tienen la intención de un bucle giratorio infinito sin efecto obtenerlo, al tiempo que evitaría gastos generales para todos los demás. Por supuesto, este compromiso no haría imposible bloquear el código seguro, pero probablemente reduciría las posibilidades de que suceda accidentalmente, y parece que debería ser fácil de implementar.

En cambio, la suposición de que los bucles terminarán o tendrán efectos secundarios surge naturalmente en algunos algoritmos de compilación comunes.

Sin embargo, es completamente antinatural si empiezas a pensar en la corrección de esas transformaciones. Para ser franco, sigo pensando que fue un gran error de C permitir alguna vez esta suposición, pero bueno.

si alguna instrucción en el cuerpo tiene efectos secundarios potenciales, entonces el cuerpo funcional puede tener efectos secundarios.

Hay una buena razón por la que la "no rescisión" se suele considerar un efecto cuando comienzas a mirar las cosas de manera formal. (Haskell no es puro, tiene dos efectos: no terminación y excepciones).

Un posible compromiso podría ser que rustc insert @ llvm.sideeffect cuando un usuario escribe literalmente un bucle vacío {} (o similar) o una recursividad incondicional (que ya tiene una pelusa). Este compromiso permitiría a las personas que realmente tienen la intención de un bucle giratorio infinito sin efecto obtenerlo, al tiempo que evitaría gastos generales para todos los demás. Por supuesto, este compromiso no haría imposible bloquear el código seguro, pero probablemente reduciría las posibilidades de que suceda accidentalmente, y parece que debería ser fácil de implementar.

Como usted mismo señaló, esto sigue siendo incorrecto. No creo que debamos aceptar una "solución" que sabemos que es incorrecta. Los compiladores son una parte tan integral de nuestra infraestructura que no deberíamos esperar que nada salga mal. Esta no es una forma de construir una base sólida.


Lo que sucedió aquí es que la noción de corrección se construyó alrededor de lo que hicieron los compiladores, en lugar de comenzar con "¿Qué queremos de nuestros compiladores?" Y luego convertir eso en su especificación. Un compilador correcto no convierte un programa que siempre diverge en uno que termina, punto. Encuentro esto bastante evidente, pero dado que Rust tiene un sistema de tipos razonable, esto incluso se ve claramente en los tipos, por lo que el problema surge con regularidad.

Dadas las restricciones con las que estamos trabajando (es decir, LLVM), lo que debemos hacer es comenzar agregando llvm.sideeffect en suficientes lugares para que cada ejecución divergente esté garantizada para "ejecutar" infinitamente muchas de ellas. Luego, hemos alcanzado una línea de base razonable (como en, sólida y correcta) y podemos hablar de mejoras eliminando estas anotaciones cuando podamos garantizar que no son necesarias.

Para hacer mi punto más preciso, creo que lo siguiente es una caja de Rust sólida, con pick_a_number_greater_2 devolviendo (de forma no determinista) algún tipo 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 compilamos ese bucle divergente, es un error y debería corregirse.

Ni siquiera tenemos cifras hasta ahora de cuánto rendimiento costaría arreglar esto ingenuamente. Hasta que lo hagamos, no veo ninguna razón para romper deliberadamente programas como el anterior.

En la práctica, fn foo() { foo() } siempre terminará debido al agotamiento de los recursos, pero dado que la máquina abstracta de Rust tiene un marco de pila infinitamente grande (AFAIK), es válido transformar ese código en fn foo() { loop {} } que nunca terminar (o mucho más tarde, cuando el universo se congela). ¿Debería ser válida esta transformación? Yo diría que sí, ya que de lo contrario no podemos realizar optimizaciones finales a menos que podamos probar la terminación, lo cual sería desafortunado.

¿Tendría sentido tener un unsafe intrínseco que establezca que un bucle, recursión, ... siempre termina? N1528 da un ejemplo en el que, si no se puede suponer que los bucles terminen, la fusión de bucles no se puede aplicar al código de puntero que atraviesa listas vinculadas, porque las listas vinculadas podrían ser circulares, y demostrar que una lista vinculada no es circular no es algo que los compiladores modernos puedan hacer.

Estoy absolutamente de acuerdo en que debemos solucionar este problema de solidez para siempre. Sin embargo, la forma en que lo hagamos debería tener en cuenta la posibilidad de que "agregar llvm.sideeffect todas partes donde no podamos probar que es innecesario" puede hacer retroceder la calidad del código de los programas que se compilan correctamente hoy. Si bien estas preocupaciones son en última instancia anuladas por la necesidad de tener un compilador sólido, podría ser prudente proceder de una manera que retrase un poco la solución adecuada a cambio de evitar regresiones de rendimiento y mejorar la calidad de vida del programador promedio de Rust. hora. Propongo:

  • Al igual que con otras correcciones de retroceso potencial del rendimiento para errores de solidez de larga data (# 10184), deberíamos implementar la corrección detrás de una bandera -Z para poder evaluar el impacto del rendimiento en las bases de código en la naturaleza.
  • Si el impacto resulta ser insignificante, genial, podemos activar la solución por defecto.
  • Pero si hay regresiones reales a partir de él, podemos llevar esos datos a las personas de LLVM e intentar mejorar LLVM primero (o podríamos elegir comer la regresión y corregirla más tarde, pero en cualquier caso, tomaríamos una decisión informada).
  • Si decidimos no activar la solución de forma predeterminada debido a las regresiones, al menos podemos continuar agregando llvm.sideeffect a los bucles sintácticamente vacíos: son bastante comunes y su compilación incorrecta ha llevado a que varias personas gasten miserablemente horas depurando problemas extraños (# 38136, # 47537, # 54214, y seguramente hay más), así que aunque esta mitigación no tiene nada que ver con el error de solidez, tendría un beneficio tangible para los desarrolladores mientras solucionamos los problemas de la manera adecuada. arreglo del fallo.

Es cierto que esta perspectiva se basa en el hecho de que este problema se ha mantenido durante años. Si fuera una nueva regresión, estaría más abierto a arreglarlo más rápidamente o revertir el PR que lo introdujo.

Mientras tanto, ¿debería mencionarse esto en https://doc.rust-lang.org/beta/reference/behavior-considered-undefined.html mientras este problema esté abierto?

¿Tendría sentido tener un unsafe intrínseco que establezca que un bucle, una recursión ... siempre termina?

std::hint::reachable_unchecked ?

Por cierto, me encontré con este código real de escritura para un sistema de mensajes TCP. Tuve un bucle infinito como solución provisional hasta que puse un mecanismo real para detener, pero el hilo salió de inmediato.

En caso de que alguien quisiera jugar golf con código de caso de prueba:

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

''
$ cargo run --release
Instrucción ilegal (núcleo abandonado)

En caso de que alguien quisiera jugar golf con código de caso de prueba:

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

Con la bandera -Z insert-sideeffect rustc, agregada por @sfanxiang en https://github.com/rust-lang/rust/pull/59546, sigue en bucle :)

antes de:

main:
  ud2

después:

main:
.LBB0_1:
  jmp .LBB0_1

Por cierto, el seguimiento de errores de LLVM es https://bugs.llvm.org/show_bug.cgi?id=965 , que aún no he visto publicado en este hilo.

@RalfJung ¿Puede actualizar el hipervínculo https://github.com/simnalamburt/snippets/blob/master/rust/src/bin/infinite.rs en la descripción del problema en https://github.com/simnalamburt/snippets/blob /12e73f45f3/rust/infinite.rs esto? El hipervínculo anterior se rompió durante mucho tiempo ya que no era un enlace permanente. ¡Gracias! 😛

@simnalamburt hecho, ¡gracias!

El aumento del nivel de opción MIR parece evitar la mala optimización en el siguiente caso:

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

Ayudó con ese caso especial, pero no resuelve el problema en general. https://godbolt.org/z/5hv87d

En general, este problema solo se puede resolver cuando rustc o LLVM pueden demostrar que una función pura es total antes de utilizar las optimizaciones relevantes.

De hecho, no estaba afirmando que resolviera el problema. El efecto sutil fue lo suficientemente interesante para los demás como para que mereciera la pena mencionarlo aquí también. -Z insert-sideeffect continúa corrigiendo ambos casos.

Algo se está moviendo en el lado de LLVM: hay una propuesta para agregar un atributo de nivel de función para controlar las garantías de progreso. https://reviews.llvm.org/D85393

No estoy seguro de por qué todos (aquí y en los hilos de LLVM) parecen estar enfatizando la cláusula sobre el progreso hacia adelante.

La eliminación del bucle parece ser una consecuencia directa de un modelo de memoria: se permite mover los cálculos de valores, siempre que ocurran, antes del uso del valor. Ahora bien, si hay una prueba de que no se puede utilizar el valor, es la prueba de que no hay ningún suceso antes, y el código se puede mover infinitamente hacia el futuro y aún así satisfacer el modelo de memoria.

O, si no está familiarizado con los modelos de memoria, considere que el ciclo completo se abstrae en una función que calcula un valor. Ahora reemplace todas las lecturas del valor fuera del ciclo por una llamada de esa función. Esta transformación ciertamente es válida. Ahora, si no hay usos del valor, no hay invocaciones de la función que hace el bucle infinito.

los cálculos de valores pueden moverse, siempre que sucedan, antes del uso del valor. Ahora bien, si hay una prueba de que no se puede utilizar el valor, es la prueba de que no hay ningún suceso antes, y el código se puede mover infinitamente hacia el futuro y aún así satisfacer el modelo de memoria.

Esta declaración es correcta solo si se garantiza que ese cálculo terminará. La no terminación es un efecto secundario y, al igual que no puede eliminar un cálculo que se imprime en stdout (no es "puro"), no puede eliminar un cálculo que no termina.

No está bien eliminar la siguiente llamada de función, incluso si el resultado no se utiliza:

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

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

Esto es cierto para cualquier tipo de efecto secundario y sigue siendo cierto cuando reemplaza la impresión por un loop {} .

La afirmación sobre la no terminación como efecto secundario requiere no solo un acuerdo de que lo es (que no es controvertido), sino también un acuerdo sobre _cuando_ debe observarse.

Seguro que se observa no terminación, si el bucle calcula el valor. No se observa la no terminación si se le permite reordenar los cálculos que no dependen del resultado del ciclo.

Como el ejemplo del hilo LLVM.

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

Las propiedades de terminación de la división no tienen nada que ver con el reordenamiento. Las CPU modernas intentarán ejecutar la división, la comparación, la predicción de la rama y la captación previa de la rama correcta en paralelo. Por lo tanto, no se garantiza que observará la división completada en el momento en que observe 0 devuelto, si y es negativo. (Por "observar" aquí me refiero a medir realmente con un oscilómetro donde está la CPU, no por el programa)

Si no puede observar la división completada, no puede observar que comenzó la división. Por lo tanto, la división en el ejemplo anterior generalmente se permitiría reordenar, que es lo que podría hacer un compilador:

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

Digo "normalmente", porque quizás hay idiomas en los que esto no está permitido. No sé si Rust es ese lenguaje.

Los bucles puros no son diferentes.


No digo que no sea un problema. Solo digo que la garantía de progreso hacia adelante no es lo que permite que suceda.

La afirmación sobre la no rescisión como efecto secundario requiere no solo un acuerdo de que lo es (que no es controvertido), sino también un acuerdo sobre cuándo debe cumplirse.

Lo que estoy expresando es el consenso de todo el campo de investigación de lenguajes de programación y compiladores. Seguro que tienes la libertad de no estar de acuerdo, pero también podrías redefinir términos como "corrección del compilador"; no es útil para una discusión con otros.

Las observaciones permitidas siempre se definen a nivel de fuente. La especificación del lenguaje define una "máquina abstracta", que describe (idealmente con minucioso detalle matemático) cuáles son los comportamientos observables permisibles de un programa. Este documento no habla de optimizaciones.

La exactitud de un compilador se mide en función de si los programas que produce solo exhiben comportamientos observables que la especificación dice que puede tener el programa fuente. Así es como funcionan todos los lenguajes de programación que se toman en serio la corrección, y es la única forma que conocemos de cómo capturar de forma precisa cuándo un compilador es correcto.

Lo que depende de cada lenguaje es definir qué se considera exactamente observable en el nivel de fuente, y qué comportamientos de fuente se consideran "indefinidos" y, por lo tanto, el compilador puede asumir que nunca ocurren. Este problema surge porque C ++ dice que un bucle infinito sin otros efectos secundarios ("divergencia silenciosa") es un comportamiento indefinido, pero Rust no dice tal cosa. Esto significa que la no terminación en Rust siempre es observable y debe ser preservada por el compilador. La mayoría de los lenguajes de programación hacen esta elección, porque la elección de C ++ puede hacer que sea muy fácil introducir accidentalmente un comportamiento indefinido (y por lo tanto errores críticos) en un programa. Rust promete que ningún comportamiento indefinido puede surgir de un código seguro, y dado que el código seguro puede contener bucles infinitos, se deduce que los bucles infinitos en Rust deben ser un comportamiento definido (y por lo tanto preservado).

Si estas cosas son confusas, sugiero hacer una lectura de antecedentes. Puedo recomendar "Tipos y lenguajes de programación" de Benjamin Pierce. Probablemente también encontrará muchas publicaciones de blog, aunque puede ser difícil juzgar qué tan bien informado está realmente el autor.

Para concretar, si su ejemplo de división se cambia a

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

entonces espero que esté de acuerdo en que el condicional _no se puede elevar por encima de la división, porque eso cambiaría el comportamiento observable cuando y es cero (de estrellarse a devolver cero).

De la misma manera, en

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

la máquina abstracta de Rust permite que esto se reescriba como

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

pero la primera condición y el bucle no se pueden descartar.

Ralf, no pretendo saber la mitad de lo que haces, y no quiero introducir nuevos significados. Estoy totalmente de acuerdo con la definición de lo que es la corrección (el orden de ejecución debe corresponder al orden del programa). Solo pensé que el "cuándo" la no terminación es observable era parte de ella, como en: si no está viendo el resultado del ciclo, no tiene un testigo de su terminación (por lo que no puede reclamar su incorrección) . Necesito volver a visitar el modelo de ejecución.

Gracias por su paciencia conmigo

@zackw Gracias. Ese es un código diferente, que por supuesto resultará en una optimización diferente.

Mi premisa acerca de que los bucles se optimizan de la misma manera que la división era defectuosa (no puedo ver el resultado de la división == no puedo ver que el bucle termine), por lo que el resto no importa.

@olotenko No sé a qué te refieres con "ver el resultado del bucle". Un bucle sin terminación hace que todo el programa diverja, lo que se considera un comportamiento observable ; esto significa que es observable fuera del programa. Al igual que en, el usuario puede ejecutar el programa y ver que continúa para siempre. Un programa que dura para siempre no puede compilarse en un programa que termina, porque eso cambia lo que el usuario puede observar sobre el programa.

No importa qué estaba calculando ese bucle o si se usa o no el "valor de retorno" del bucle. Lo que importa es lo que el usuario puede observar al ejecutar el programa. El compilador debe asegurarse de que este comportamiento observable sea el mismo. La no terminación se considera observable.

Para dar otro ejemplo:

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

Este programa nunca imprimirá nada debido al bucle. Pero si optimizaba el bucle (o reordenaba el bucle con la impresión), de repente el programa imprimiría "Hola". Por tanto, estas optimizaciones cambian el comportamiento observable del programa y no se permiten.

@RalfJung está bien, lo tengo ahora. Mi problema original era qué papel juega aquí la "garantía de progreso hacia adelante". La optimización es posible completamente a partir de la dependencia de datos. Mi error fue que en realidad la dependencia de datos no es parte del orden del programa: son literalmente las expresiones totalmente ordenadas según la semántica del lenguaje. Si el orden del programa es total, entonces sin garantía de progreso hacia adelante (que podemos reformular como "cualquier subruta del orden del programa es finito") podemos reordenar (en el orden de ejecución) solo las expresiones que podamos _probar_ como terminantes (y conservando algunas otras propiedades, como la observabilidad de las acciones de sincronización, llamadas al sistema operativo, IO, etc.).

Necesito pensar un poco más al respecto, pero creo que puedo ver la razón por la que podemos "fingir" que la división ocurrió en el ejemplo con x = y % 42 , incluso si realmente no se ejecuta para algunas entradas, pero por qué no se aplica lo mismo a los bucles arbitrarios. Quiero decir, las sutilezas de la correspondencia del orden total (programa) y el orden parcial (ejecución).

Creo que el "comportamiento observable" puede ser un poco más sutil que eso, ya que una recursividad infinita terminará con un bloqueo de desbordamiento de pila ("termina" en el sentido de un "usuario que observa el resultado"), pero una optimización de llamada final lo convertirá en un bucle sin terminación. Al menos, esta es otra cosa que hará Rust / LLVM. Pero no tenemos que discutir esa pregunta ya que ese no es realmente mi problema (¡a menos que tú quieras! Estoy seguro de que me alegra entender si eso es lo que se espera).

desbordamiento de pila

Los desbordamientos de pila son un desafío para modelar de hecho, buena pregunta. Lo mismo para situaciones de falta de memoria. Como primera aproximación, fingimos formalmente que no suceden. Un mejor enfoque es decir que cada vez que llama a una función, puede obtener un error debido a un desbordamiento de la pila, o el programa puede continuar; esta es una elección no determinista hecha en cada llamada. De esta manera puede aproximarse de manera sólida a lo que realmente sucede.

podemos reordenar (en el orden de ejecución) solo las expresiones que podemos probar que terminan

En efecto. Además, tienen que ser "puros", es decir, libres de efectos secundarios; no se pueden reordenar dos println! . Es por eso que generalmente consideramos la no terminación como un efecto también, porque entonces todo esto se reduce a "las expresiones puras se pueden reordenar", y "las expresiones que no terminan son impuras" (impura = tiene un efecto secundario).

La división también es potencialmente impura, pero solo cuando se divide por 0, lo que provoca pánico, es decir, un efecto de control. Esto no es directamente observable sino indirectamente (por ejemplo, al hacer que el controlador de pánico imprima algo en la salida estándar, que luego es observable). Por lo tanto, la división solo se puede reordenar si estamos seguros de que no dividimos entre 0.

Tengo un código de demostración que creo que podría ser este problema, pero no estoy del todo seguro. Si es necesario, puedo incluir esto en un nuevo informe de error.
Puse el código en un repositorio de git en https://github.com/uglyoldbob/rust_demo

Mi bucle infinito (con efectos secundarios) se optimiza y se genera una instrucción de trampa.

No tengo idea si esa es una instancia de este problema o algo más ... los dispositivos integrados no son mi especialidad en absoluto y con todas estas dependencias de cajas externas no tengo idea de qué más está haciendo ese código. ^^ Pero su programa es no es seguro y tiene un acceso volátil en el circuito, así que diría que se trata de un problema aparte. Cuando pongo su ejemplo en el patio de recreo , creo que está compilado correctamente, por lo que sospecho que el problema está en una de las dependencias adicionales.

Parece que todo en el bucle es una referencia a una variable local (ninguno escapó a ningún otro hilo). En estas circunstancias, es fácil probar la ausencia de tiendas volátiles y la ausencia de efectos observables (sin tiendas con las que puedan sincronizarse). Si Rust no agrega un significado especial a los volátiles, entonces este bucle se puede reducir a un bucle infinito puro.

@uglyoldbob Lo que realmente está sucediendo en su ejemplo sería más claro si llvm-objdump no fueran espectacularmente inútiles (e inexactos). Ese bl #4 (que en realidad no es una sintaxis de ensamblaje válida) aquí significa una bifurcación a 4 bytes después del final de la instrucción bl , también conocida como el final de la función main , también conocida como inicio de la siguiente función. La siguiente función se llama (cuando la construyo) _ZN11broken_loop18__cortex_m_rt_main17hbe300c9f0053d54dE , y esa es su función main real. La función con el nombre no mutilado main no es su función, sino una función completamente diferente generada por la macro #[entry] proporcionada por cortex-m-rt . Su código en realidad no se está optimizando. (De hecho, el optimizador ni siquiera se está ejecutando ya que está compilando en modo de depuración).

¿Fue útil esta página
0 / 5 - 0 calificaciones