1 puntos por GN⁺ 1 시간 전 | 1 comentarios | Compartir por WhatsApp
  • Async Rust permite ejecutar código independiente del executor tanto en servidores como en microcontroladores, pero la máquina de estados que genera el compilador hace que el aumento del tamaño del binario se note especialmente en embebidos
  • Incluso un ejemplo simple como bar(), con 2 puntos de await, crea 360 líneas de MIR y los estados Unresumed, Returned, Panicked, Suspend0, Suspend1; la versión síncrona solo necesita 23 líneas
  • Si al volver a hacer poll de un future ya completado se devuelve Poll::Pending en vez de panic, se puede cumplir el contrato sin comportamiento unsafe, y en pruebas el tamaño del binario se reduce entre 2% y 5% en firmware embebido
  • Incluso async { 5 }, sin await, hoy genera una máquina de estados con 3 estados base, pero si se optimiza para devolver siempre Poll::Ready(5), el tamaño del binario embebido baja 0.2%
  • El Project Goal propuesto busca que el compilador impulse en modo release la eliminación del panic tras completarse, la eliminación de la máquina de estados en bloques async sin await, el inline de futures con un solo await y el plegado de estados idénticos

El problema del bloat a nivel de compilador en Async Rust

  • Async Rust permite ejecutar código independiente del executor tanto en servidores como en microcontroladores, pero en microcontroladores pequeños el aumento del tamaño del binario se nota especialmente
  • El blog de Rust presentó async/await como una abstracción de costo cero, pero en la práctica async sí genera bastante bloat; el mismo problema existe en desktop y servidores, aunque allí se nota menos por tener más memoria y capacidad de cómputo
  • Tras un método alternativo para evitar el bloat al escribir código async, se presentó un Project Goal para resolver el problema desde el compilador
  • Queda fuera del alcance el problema de futures más grandes de lo necesario y con demasiadas copias

Estructura del future generado

  • El código de ejemplo hace que foo() devuelva async { 5 }, y que bar() ejecute foo().await + foo().await
  • bar tiene 2 puntos de await, así que la máquina de estados necesita al menos 2 estados, pero en la práctica se generan más
  • El compilador de Rust puede volcar MIR en varias pasadas, y la pasada coroutine_resume es la última pasada de MIR específica de async
    • async aún existe en MIR, pero ya no en LLVM IR, así que la conversión de async a máquina de estados ocurre en las pasadas de MIR
  • La función bar genera 360 líneas de MIR, mientras que la versión síncrona solo usa 23 líneas
  • El CoroutineLayout que emite el compilador es, en la práctica, un conjunto de estados con forma de enum
    • Unresumed: estado inicial
    • Returned: estado completado
    • Panicked: estado posterior a un pánico
    • Suspend0: primer punto de await, guarda el future de foo
    • Suspend1: segundo punto de await, guarda el primer resultado y el segundo future de foo
  • Future::poll es una función segura, así que no debe provocar UB aunque se vuelva a llamar después de que el future ya terminó
    • Hoy, después de Suspend1, devuelve Ready y cambia el future al estado Returned
    • Si se vuelve a hacer poll en ese estado, ocurre un panic
  • El estado Panicked parece existir para impedir que se vuelva a hacer poll de ese future cuando una función async hizo panic y eso fue capturado con catch_unwind
    • Tras un panic, el future puede quedar en un estado incompleto, así que volver a hacer poll podría llevar a UB
    • Este mecanismo es muy similar al poisoning de un mutex
    • Esta interpretación del estado Panicked no está totalmente documentada; la certeza es de alrededor del 90%

¿De verdad tiene que hacer panic al hacer poll después de completarse?

  • Hoy un future en estado Returned hace panic, pero no es estrictamente necesario que sea así
    • La condición necesaria es solo que no cause UB
  • Un panic es relativamente costoso y añade una ruta con efectos secundarios que cuesta eliminar por optimización
  • Si al volver a hacer poll de un future ya completado se devuelve Poll::Pending, se puede cumplir el contrato del tipo Future sin comportamiento unsafe
  • Al modificar el compilador para probar este enfoque, se observó una reducción de 2% a 5% del tamaño del binario en firmware embebido async
  • Se propone ofrecer este comportamiento como un switch, de forma similar a overflow-checks = false para overflow de enteros
    • En builds de debug seguiría habiendo panic para exponer de inmediato el comportamiento incorrecto
    • En builds release se podrían obtener futures más pequeños
  • Con panic=abort, podría ser posible eliminar por completo el estado Panicked, aunque su impacto requiere más revisión

Siempre se genera una máquina de estados, incluso sin await

  • foo() solo devuelve async { 5 }, así que la forma óptima manual sería un future sin estado que siempre devuelva Poll::Ready(5)
  • Sin embargo, en el MIR que genera el compilador siguen existiendo los 3 estados base: Unresumed, Returned, Panicked
    • Al hacer poll, se revisa el discriminante del estado actual y se bifurca en consecuencia
    • Si se vuelve a hacer poll tras completarse, hay un panic con el assert `async fn` resumed after completion
  • En este caso se puede optimizar para no crear ninguna máquina de estados y devolver siempre Poll::Ready(5)
  • Al aplicar esto experimentalmente en el compilador, el tamaño del binario embebido se redujo 0.2%
    • El ahorro no es grande, pero la optimización es simple y podría valer la pena
  • Esta optimización cambia un poco el comportamiento, pero solo afecta a executors que no respetan el contrato
    • Hoy el compilador hace panic en polls posteriores
    • Tras la optimización, el future siempre devolvería Ready

Solo con LLVM no alcanza

  • Aunque la salida MIR sea ineficiente, a veces LLVM puede limpiar todo, pero bajo condiciones limitadas
    • El future debe ser lo suficientemente simple
    • Hay que usar opt-level=3
  • Si el future se vuelve más complejo, LLVM ya no logra eliminarlo, y en código async idiomático de Rust los futures tienden a anidarse profundamente, así que la complejidad crece rápido
  • En entornos donde a menudo se optimiza por tamaño, como embebidos o wasm, LLVM no logra optimizar todo esto por sí solo
  • Ejemplo en Godbolt: https://godbolt.org/z/58ahb3nne
    • En el ensamblador generado, LLVM sabe que foo devuelve 5, pero no logra optimizar la respuesta de bar a 10
    • La llamada a la función poll de foo sigue presente
    • La razón son rutas potenciales de panic que el compilador no puede descartar completamente
    • LLVM no sabe que foo en realidad solo se llama una vez y que no va a hacer panic
  • Si se comenta la rama de panic en el IR, la optimización mejora: https://godbolt.org/z/38KqjsY8E
  • En vez de esperar una optimización posterior de LLVM, el compilador debería darle una mejor entrada a LLVM

El inline de futures no está funcionando bien

  • El inline es importante porque habilita pasadas de optimización posteriores, pero los futures generados por Rust hoy no se inlinean en etapas tempranas
  • Una vez que cada future obtiene su implementación, LLVM y el linker sí tienen oportunidades de inline, pero por los problemas anteriores ese momento ya llega demasiado tarde
  • La oportunidad de inline más directa es una forma donde bar() solo hace foo(blah).await
    • Es un patrón que aparece seguido al construir abstracciones con traits
    • Hoy el compilador crea una máquina de estados para bar y dentro de ella llama a la máquina de estados de foo
    • De manera más eficiente, bar podría ser directamente el future de foo
  • Cuando hay preamble y postamble, la cosa es más compleja
    • Ejemplo: bar(input) crea blah con input > 10, luego hace foo(blah).await y finalmente aplica * 2 al resultado
    • Es algo común al transformar funciones async a otras firmas, especialmente en implementaciones de traits
  • Incluso en esta forma, bar no necesita tener estado async propio
    • No hay datos preservados más allá del único punto de await excepto el valor capturado por foo
    • Aun así, bar no puede convertirse simplemente en foo, sino que la mayor parte del estado puede delegarse a foo
  • En una implementación manual, BarFut podría tener los estados Unresumed { input } e Inlined { foo: FooFut }
    • En el primer poll ejecutaría el preamble para crear foo(blah) y cambiar al estado Inlined
    • Después aplicaría el postamble al resultado de foo.poll(cx)
  • Si el código pudiera ejecutarse por adelantado hasta el primer punto de await, también podría eliminarse el estado Unresumed, pero no puede hacerse porque está garantizado que un future no hace nada antes de recibir poll
  • Si se pudieran consultar propiedades del future mientras está siendo polleado, sería posible aplicar optimizaciones de inline adicionales
    • Por ejemplo, si se supiera que un future siempre devuelve ready en su primer poll, el future llamador no necesitaría crear un estado para ese punto de await
    • Si este tipo de optimización se aplicara recursivamente, muchos futures podrían plegarse en máquinas de estados mucho más simples
  • En la estructura actual de rustc, cada bloque async parece transformarse por separado y luego no se conserva la información relacionada, así que este tipo de consulta no parece posible
  • El inline de futures aún no se ha probado, pero se espera que ayude bastante tanto en tamaño de binario como en rendimiento

Plegado de estados idénticos

  • Cada punto de await dentro de un bloque async añade un estado extra a la máquina de estados
  • Un código como el siguiente es natural, pero como ambas ramas hacen await de la misma función async, se generan 2 estados idénticos
    • CommandId::A => send_response(123).await
    • CommandId::B => send_response(456).await
  • En este caso, el CoroutineLayout crea _s0 y _s1, ambos almacenando el mismo tipo de coroutine de send_response, además de los estados Suspend0 y Suspend1
  • El MIR de esta función tiene 456 líneas, y muchos bloques básicos son en la práctica duplicados
  • Si antes se refactoriza manualmente el código para calcular primero solo el valor de respuesta y luego hacer una sola vez send_response(response).await, desaparecen los estados duplicados
    • CommandId::A produce 123
    • CommandId::B produce 456
    • Después se hace send_response(response).await
  • Tras el refactor, en CoroutineLayout queda almacenado un solo future y solo queda el estado Suspend0
  • La longitud total del MIR baja a 302 líneas y desaparece la duplicación
  • Por eso parece útil una pasada de optimización que detecte rutas de código y estados equivalentes para plegarlos en uno solo
    • Esta optimización probablemente combine bien con una pasada de inline de futures

Enlaces de experimentos y benchmarks adicionales

Solicitud de apoyo para el Project Goal

1 comentarios

 
GN⁺ 1 시간 전
Comentarios de Lobste.rs
  • Fue un texto mucho más constructivo de lo que esperaba viendo solo el título

    • Creo que es básicamente cierto. Ya pasaron 7 años desde el lanzamiento del MVP, pero casi no hubo avances ni en el diseño del lenguaje ni en la implementación del compilador, y como las personas que impulsaron principalmente el MVP redujeron su participación en el proyecto más o menos en esa misma época, el relevo después de eso quedó estancado.
      Ojalá quien quiera trabajar en esto reciba el apoyo que necesita
  • I want to work on this in the compiler and as such have submitted it as a Project Goal

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

    Me alegra ver que se esté abordando este problema. Ya había leído varias veces que ahora mismo rustc le está pasando demasiado código a LLVM y espera que el optimizador resuelva todo, y este texto en particular además está pidiendo financiamiento para ese trabajo

  • Dios mío, yo era un idiota
    Siempre pensé que async era intrínsecamente “inflado”, porque de una forma u otra necesita un runtime, seguimiento de tareas y polling para verificar la finalización. Ese overhead no es cero, al fin y al cabo
    Lo que se quería decir aquí con “abstracción de costo cero” era sobre la funcionalidad del lenguaje, y yo lo veía como algo aparte del runtime agregado
    Ni siquiera se me había ocurrido mirar qué está emitiendo rustc antes de pasárselo a LLVM

  • Para quienes no están familiarizados con async Rust:

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

    Esto es totalmente cierto. Incluso un árbol anidado de llamadas async, después de una optimización máxima, se solidifica internamente en una sola estructura con una máquina de estados dentro. Es una forma realmente ingeniosa de hacerlo

  • ¿En un build de release, llegar a este caso genera una especie de deadlock? ¿O también podría producir fugas porque hay tareas esperando trabajo que siempre queda en Pending?

    • Sí. Esos futures quedan en un estado atascado y nunca terminan. Pero ese estado solo puede alcanzarse en código async de bajo nivel que ya tiene bugs, y es muy probable que el código que no logra rastrear correctamente un future completado ya esté produciendo fugas y deadlocks
      No puedes hacer polling incorrecto con .await
  • Se me ocurren algunas cosas:

    1. Este texto parece argumentar que habría que sacar más lógica de optimización fuera de LLVM y moverla a la capa MIR. Por ejemplo, entiendo por qué hacer inline de funciones async sería más fácil en MIR que en LLVM. Si eso se logró en MIR para async, me pregunto si esa lógica podría generalizarse también para funciones síncronas y eliminar algunos de los passes de optimización de LLVM. Sé que sería un trabajo grande, y no es tanto una pregunta práctica como de dirección. Cuando un compilador de frontend/middle-end alcanza cierto nivel de complejidad, quizá sea mejor trasladar bastante de la optimización genérica de LLVM a otro lugar
    2. Sigo sin estar convencido con panic=unwind. Fuera de algunos test harnesses, casi nunca he visto ventajas frente a panic=abort que compensen su costo. Incluso en un test harness, en Linux parece que podría aplicarse una elección parecida usando clone de una forma rebuscada para hacer wait al hilo de ejecución en vez de pthread_join. Puede que yo esté equivocado en esto
  • ¿El enlace también se le cayó a alguien más hace un momento?
    Edit: la entrada del blog se ve como medio segundo y luego te manda a una página 404
    Edit 2: entré a la lista de posts del blog y estuve picándole a varias cosas, y hasta abriendo ese post desde la lista igual me manda a una página 404. ¿Cómo se puede arruinar así un blog que es una página estática, o al menos debería serlo?

    • El tono se siente un poco innecesariamente grosero y agresivo. Los sitios web también pueden tener bugs; reportarlo es útil, pero este comentario suena algo mezquino
      Como referencia, creo que seguí los mismos pasos de reproducción y a mí no me salió ningún 404. Lo probé en celular y escritorio, con JavaScript activado y desactivado. Así que da la impresión de que lo que te pasó pudo haber sido más complejo de lo que parecía