La convención de llamadas de Rust que deberíamos tener
(mcyoung.xyz)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
-Zcallconvpara configurar la convención de llamadas de las funcionesextern "Rust".-Zcallconv=legacysería la forma actual.-Zcallconv=fastserí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”.
- Los argumentos que van por registros se representan como no agregados, como
- Generar el prólogo de la función.
- Decodificar los argumentos a nivel Rust desde las entradas por registros y producir los mismos valores
%ssaque 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.
- Decodificar los argumentos a nivel Rust desde las entradas por registros y producir los mismos valores
- Generar el bloque de retorno de la función.
- Incluye instrucciones
phipara el mismo tipo de retorno que en-Zcallconv=legacy. - Codifica al formato de salida necesario y hace
ret. - En vez de hacer
retdirectamente, hay que saltar a este bloque.
- Incluye instrucciones
- 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=legacyy 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
rustcya 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.booles 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
u8o 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"oextern "fastcall"también podría ser una alternativa.
1 comentarios
Opinión de Hacker News
Resumen:
structde Rust deben poder proporcionar referencias a sus campos, por lo que pueden ser más grandes que en C. Unstructcon 8 camposOption<u8>ocupa 16 bytes en Rust y 9 bytes en C.&Option<T>ni con&mut Option<T>.Cargo.toml. Ordenar los campos por tamaño es una optimización fácil, y se puede desactivar conrepr.