2 puntos por GN⁺ 2024-04-20 | 1 comentarios | Compartir por WhatsApp

Este artículo explica en detalle cómo mejorar la convención de llamadas de Rust.

Problemas de la convención de llamadas actual de Rust

  • Actualmente, Rust no tiene una convención de llamadas claramente definida.
  • En la práctica, usa la convención de llamadas C predeterminada de LLVM.
  • Actualmente, Rust intenta generar firmas de funciones LLVM de forma conservadora, similares a las que generaría Clang.
    • Para mantener compatibilidad con los depuradores.
    • Para evitar bugs de LLVM.
  • Pero es tan conservador que genera mal código incluso para funciones simples.
fn extract(arr: [i32; 3]) -> i32 { arr[1] }
  • El código de arriba debería pasar el valor por registros, pero lo pasa por puntero.
  • Rust es incluso más conservador que el ABI de C. Si se especifica como extern "C", sí lo pasa por registros.

Propuesta de nueva convención de llamadas

  • Mantener la convención de llamadas existente para las funciones extern "Rust".
  • Agregar la bandera -Zcallconv para configurar la convención de llamadas de las funciones extern "Rust".
    • -Zcallconv=legacy sería la forma actual.
    • -Zcallconv=fast sería la nueva forma propuesta.
  • ¿Por qué hay que mantener la convención de llamadas actual?
    • Para facilitar la depuración, no se colocan en el orden del ABI de C.
    • Algunos targets, como WASM, podrían no soportarlo.
    • En builds de depuración puede que no tenga sentido.
  • Precauciones relacionadas con punteros a función y bloques extern "Rust" {}.
    • Como es una bandera a nivel de crate, no se puede aplicar a punteros a función.
    • Las llamadas mediante punteros a función son lentas y poco frecuentes, así que usarían -Zcallconv=legacy.
    • Si hace falta, se genera un shim para convertir la convención de llamadas.
    • En casos de llamada directa como extern "Rust" { fn my_func() -> i32; }
      • Solo se pueden llamar símbolos sin name mangling.
      • Las funciones con #[no_mangle] usan la convención de llamadas existente.

Cómo aprovechar LLVM

  • Idealmente sería bueno poder especificar directamente la convención de llamadas en LLVM, pero en la práctica es difícil.
  • Se puede rodear el problema con un procedimiento como este.
    • Verificar para el target dado cuántos valores como máximo se pueden pasar por registros.
    • Decidir cómo pasar el valor de retorno. Si cabe en registros, se devuelve tal cual; si es grande, se pasa por referencia.
    • Seleccionar entre los argumentos pasados por valor cuáles deben pasarse por referencia.
      • Los que son más grandes que el espacio disponible para paso por registros.
      • En x86, alrededor de 176 bytes.
    • Decidir qué argumentos pasar por registros para aprovechar al máximo el espacio disponible.
      • Es un problema NP-hard, así que hace falta una heurística.
      • El resto se pasa por stack.
    • Generar la firma de la función en LLVM IR.
      • Los argumentos que van por registros se representan como no agregados, como i64, ptr, double, <2 x i64>, etc.
      • Los argumentos que van por stack siguen la forma de “entrada por registros”.
    • Generar el prólogo de la función.
      • Decodificar los argumentos a nivel Rust desde las entradas por registros y producir los mismos valores %ssa que con -Zcallconv=legacy.
      • El cuerpo de la función puede generar el mismo código sin importar la convención de llamadas.
      • El código de decodificación innecesario se elimina con DCE.
    • Generar el bloque de retorno de la función.
      • Incluye instrucciones phi para el mismo tipo de retorno que en -Zcallconv=legacy.
      • Codifica al formato de salida necesario y hace ret.
      • En vez de hacer ret directamente, hay que saltar a este bloque.
    • Si hay funciones no polimórficas y no inline que puedan usarse como punteros a función.
      • Si quedan expuestas fuera del crate o se pasan como punteros a función.
      • Se genera un shim con -Zcallconv=legacy y se hace tail call a la implementación real.
      • Esto es necesario para mantener la equivalencia de punteros a función.

Cómo comprobar los límites de paso por registros de LLVM

  • Un programa de LLVM para verificar la cantidad máxima de valores que LLVM permite pasar por registros.
  • En x86, se pueden pasar como entrada 6 enteros y 8 vectores SSE, y como salida 3 enteros y 4 vectores SSE.
  • En aarch64, tanto entrada como salida admiten 8 enteros y 8 vectores.
  • Si se supera eso, se pasan por stack.

Manejo de structs y enums en Rust

  • Se asume que rustc ya los trató como agregados básicos y uniones.
  • Manejo del valor de retorno.
    • Lo importante no es el tamaño del struct, sino el tamaño real de los datos excluyendo padding.
    • [(u64, u32); 2] mide 32 bytes, pero si se excluyen 8 bytes de padding, quedan 24 bytes.
    • Se define el tamaño efectivo del tipo.
      • Es la cantidad de bits definidos, excluyendo padding.
      • [(u64, u32); 2] son 192 bits.
      • bool es 1 bit.
    • Si el tamaño efectivo es menor que el espacio de registros de salida, se devuelve por valor.
    • En x86, 3 enteros + 4 SSE = 88 bytes = 704 bits.
  • Manejo de registros para argumentos.
    • Es un problema tipo knapsack, por lo tanto NP-hard.
    • Heurística simple.
      • Si el tamaño efectivo es mayor que el espacio total de registros de entrada, se pasa por referencia.
      • Los enums se sustituyen por pares discriminante-unión.
      • Las uniones pueden tocar bits no inicializados, así que se pasan como arreglos de u8 o como una sola variante no vacía.
      • Se aplana a los elementos más básicos posibles, como punteros, enteros, flotantes, booleanos, etc.
      • Se ordena en forma ascendente según el tamaño efectivo.
      • Se asigna a registros el prefijo más grande posible y el resto va por stack.
      • Si parte de una entrada que iría por stack es mayor que un múltiplo pequeño del tamaño de puntero, se pasa por el puntero del stack.
      • El resto se pasa directamente por stack en el orden previo al ordenamiento.
      • Lo que se pasa por registros se asigna en orden descendente de tamaño.
      • Los booleanos se empaquetan en bits de 64 en 64.

Opinión de GN+

  • Personalmente, la convención de llamadas actual de Rust me deja muy insatisfecho. Podría dar un rendimiento mucho mejor que C++, pero todavía no lo logra.
  • Es una técnica que Go ya implementó hace bastante tiempo.
  • Razones por las que Rust no ha podido aplicarlo.
    • La generación de código ABI es compleja y LLVM ayuda poco.
    • No hay mucha gente en el equipo del compilador que conozca bien LLVM.
    • Hay preocupación por el tiempo de compilación, pero como solo se usaría en builds optimizados, no sería un gran problema.
  • El autor no tiene tiempo para arreglarlo personalmente, pero está dispuesto a ayudar al equipo del compilador de Rust con base en su experiencia en LLVM.
  • O simplemente cambiar a extern "C" o extern "fastcall" también podría ser una alternativa.

1 comentarios

 
GN⁺ 2024-04-20
Opinión de Hacker News

Resumen:

  • Al crear una convención de llamadas optimizada, es importante medir directamente el rendimiento. Un código que parece extraño puede ser en realidad el más rápido.
  • Los CPU actuales optimizan el rastro de instrucciones generado por los compiladores de C, por lo que puede ayudar pasar cosas a la pila con frecuencia, como hace un compilador de C.
  • El inlining suele funcionar bien, así que las llamadas se vuelven un límite poco frecuente; por eso se puede permitir un poco de irregularidad en ese límite para simplificar otras cosas.
  • Los struct de Rust deben poder proporcionar referencias a sus campos, por lo que pueden ser más grandes que en C. Un struct con 8 campos Option<u8> ocupa 16 bytes en Rust y 9 bytes en C.
  • En Rust se puede implementar manualmente algo equivalente a C, pero no se puede mapear con &Option<T> ni con &mut Option<T>.
  • Rust todavía no tiene una convención de llamadas para semánticas a nivel de Rust. Apple tenía motivación para construirla, pero Rust no cuenta con ese soporte.
  • La interoperabilidad entre Go y Rust actualmente se puede lograr usando Zig como intermediario.
  • El compilador actual de Rust hace inlining y optimización agresivos, así que queda la duda de si vale la pena resolver este problema.
  • Para depuración, se pueden evitar estas preocupaciones usando flags en Cargo.toml. Ordenar los campos por tamaño es una optimización fácil, y se puede desactivar con repr.