- La convención de llamadas actual
extern "Rust"de Rust se apoya en la ruta de convención de llamadas C de LLVM y, en el paso de valores complejos, es conservadora en el uso de registros, por lo que se pierde una mejor generación de código - La idea central es separar el modo actual,
legacy, de un nuevo modo centrado en registros,fast, mediante la bandera por crate-Zcallconv, y usar una ABI más agresiva en builds optimizados - Incluso sin agregar directamente una nueva convención de llamadas a LLVM, se puede controlar la disposición de los argumentos con una firma de función LLVM fija y valores
poison, dejando sin costo los argumentos de registros que no se usan - Tipos de Rust como estructuras, enum, union,
boolyResultpueden pasarse de forma más compacta mediante tamaño efectivo sin padding, aplanamiento, empaquetado de bits y heurísticas de división entre stack/registros - Incorporar el cuerpo de la función, información del borrow checker e información de perfil en las decisiones de ABI permitiría optimizaciones más fuertes, pero la complejidad de generar código ABI en rustc y la falta de experiencia en LLVM siguen siendo barreras reales
Optimizaciones de convención de llamadas que Rust hoy se está perdiendo
- Una convención de llamadas (calling convention) es parte de la ABI que define cómo se pasan los argumentos y valores de retorno de una función, qué registros se usan y cómo se manejan el prólogo/epílogo y el unwinding
- Rust define su propia convención de llamadas unspecified, pero en la práctica se baja a la convención de llamadas C incorporada de LLVM y depende de la generación de código de prólogo/epílogo de LLVM
- rustc actúa de forma conservadora intentando generar una firma de función LLVM como la que produciría Clang
- Puede reducir la posibilidad de romper depuradores
- Puede disminuir la probabilidad de disparar bugs de LLVM por rutas de generación de código ABI que Clang casi no usa
- En sistemas basados en ELF, DWARF no fija la ABI C de Linux, así que, dentro del alcance del texto, la posibilidad de depuración no se considera el problema central
- Como ejemplo simple, en
fn extract(arr: [i32; 3]) -> i32, el arreglo de 12 bytes se pasa por puntero y no por registros- Si se agrega
extern "C", el mismo[i32; 3]se pasa empaquetado enrdiyrsi - Es un caso en el que la ruta predeterminada de Rust es incluso más conservadora que la ABI C de Linux
- Si se agrega
-Zcallconv: separar legacy y fast
- Se mantiene la convención de llamadas actual de
extern "Rust", pero se elige cuál usar con la bandera de compilación por crate-Zcallconv-Zcallconv=legacy: el modo actual-Zcallconv=fast: el nuevo modo centrado en registros-Otambién podría configurar automáticamente-Zcallconv=fast
- La convención de llamadas
fastno ubica los argumentos siguiendo el orden de la ABI C, por lo que puede resultar confusa para quien espere el orden idiomático de registros en x86 - En targets como WASM, donde no existen los conceptos de registros y spilling,
-Zcallconv=fastpodría no estar soportado - En builds debug sin optimización,
fastpodría generar peor código, por lo que quizá no convenga activarlo - Se necesitan restricciones separadas para punteros a función y bloques
extern "Rust" {}- La bandera es por crate, pero con punteros a función es difícil expresar qué versión de
extern "Rust"se usa - Las llamadas mediante puntero a función pueden considerarse rutas lentas y poco frecuentes, y forzar
-Zcallconv=legacy - Si es necesario, se genera un shim que convierte la convención de llamadas
- Debido a las rutas que pueden llamar símbolos unmangled, también se puede hacer que los símbolos
#[no_mangle]usen la convención de llamadas legacy
- La bandera es por crate, pero con punteros a función es difícil expresar qué versión de
Manipular LLVM de forma indirecta
- Idealmente, se querría especificar directamente en LLVM una convención de llamadas del estilo “este argumento va en este registro, este retorno en aquel registro”, pero agregar una convención de llamadas a LLVM requiere escribir mucho código C++
- En su lugar, se puede lograr un efecto cercano a una convención de llamadas propia con el siguiente procedimiento
- Determinar, por cada target triple, la cantidad máxima de valores que pueden pasarse por registros
- Decidir si el valor de retorno cabe en los registros de salida o si debe devolverse por referencia como un argumento
ptradicional con el atributosret - Bajar los argumentos by-value demasiado grandes a by-reference
- Decidir qué argumentos enviar por registros para maximizar la utilización del espacio de registros
- Dejar los demás argumentos en el stack
- Componer la firma de función en LLVM IR con argumentos non-aggregate como
i64,ptr,doubley<2 x i64> - En el prólogo de la función, decodificar las entradas en registros hacia argumentos a nivel de Rust
- En el bloque de salida de la función, codificar el valor de retorno al formato de salida necesario y luego hacer
ret - Para funciones non-polymorphic, non-inline cuya dirección pueda tomarse, crear un shim legacy para preservar la igualdad de punteros a función
- Decidir qué valores poner en registros es equivalente al problema de la mochila (knapsack problem), que es NP-hard; en una implementación real se necesitan heurísticas
- Para no calcular esta información demasiado tarde ni recalcularla, puede colocarse en
rmeta - Como Rust rompe la ABI en cada release, la condición de impedir enlazar código generado por distintos compiladores de Rust ya encaja con la situación actual
Límites del paso por registros que permite LLVM
- Cuando LLVM pasa argumentos aggregate by-value a una función, intenta “explotarlos” en registros tanto como sea posible
- En x86, las entradas que LLVM puede pasar por registros son aproximadamente las siguientes
- 6 enteros
- 8 vectores SSE
- Para retornos, la mitad: 3 enteros y 4 vectores
- En
aarch64-unknown-linux, tanto para entradas como para salidas se pueden usar 8 enteros y 8 vectores - Se puede diseñar que todas las funciones x86 con
-Zcallconv=fasttengan la misma cantidad de argumentos by-register- 6 argumentos para registros enteros
- 8 argumentos vectoriales desde
xmm0hastaxmm7 - Al pasar punteros reales, reemplazar el
i64correspondiente porptr - Al pasar
double, reemplazar el lugar de<2 x i64>
- Aunque la mayoría de las funciones no pasen 176 bytes, si se pasan valores
poisonde LLVM en los argumentos no usados se puede evitar el costo adicional- LLVM puede considerar
poisoncomo el valor que le resulte más conveniente en ese momento - Si se pasa
poisoncomo argumento en un registro, puede tratarse como “el valor que ya estaba en ese registro”, sin necesidad de tocarlo - En el ejemplo,
load_rcx()recibe un puntero enrcx, y el código que cargapoisonen los otros 13 registros no genera ningún código tras la optimización
- LLVM puede considerar
- Este enfoque permite controlar casi por completo el paso de argumentos, pero la situación ideal de usar los mismos registros para entradas y salidas varía por arquitectura
- ARM y RISC-V se acercan a una estructura que usa los mismos registros para entrada y salida
- x86 no, pero se pueden reducir movimientos de registros innecesarios asumiendo un orden distinto de asignación de registros
Encajar mejor los tipos de Rust en registros
- Al manejar estructuras y union de Rust, se asume que rustc ya trató los tipos de usuario como aggregate y union básicos, y luego se decide qué partes colocar en registros
- En valores de retorno, más que el tamaño total de una estructura importa el tamaño efectivo sin padding
[(u64, u32); 2]ocupa 32 bytes en total, pero 8 bytes son padding- Si se aplana como
(u64, u32, u64, u32)y luego se ordena por tamaño como(u64, u64, u32, u32), queda en 24 bytes - Puede caber en los 3 registros enteros de retorno de x86
- El tamaño efectivo se define como la cantidad de bits non-
undef[(u64, u32); 2]tiene 192 bitsbooltiene 1 bitchartécnicamente tiene 21 bits, pero para simplificar se trata como un alias deu32
- Una estructura con muchos
boolpuede devolver variosboolempaquetados en bits dentro de un registro - Del lado de los argumentos es más difícil, y se pueden aplicar heurísticas como estas
- Los argumentos cuyo tamaño efectivo sea mayor que todo el espacio de entrada by-register se bajan a by-reference
- En x86, el espacio total de entrada es de 176 bytes, 1408 bits
- Los enum se convierten en un par de discriminant y union
Option<i32>puede verse internamente como(union { i32, () }, i1)Option<Option<i32>>puede verse como(union { i32, (), () }, i2)
- Como una union puede tocar arbitrariamente bits no inicializados, normalmente se pasa como un arreglo de
u8 - Una union con una sola variante non-empty se reemplaza por esa variante
- Los argumentos transformados se aplanan a primitives como punteros, enteros, float y bool
- Los campos más grandes que un registro de argumento pequeño, como
u128yf64, pueden dividirse - Se ordena la lista de primitives por tamaño efectivo y se elige el prefijo más grande que quepa en registros
- El resto se deja en el stack
- Si la parte que va al stack es mayor que un pequeño múltiplo del tamaño de puntero, se baja a pointer-on-the-stack para reducir el tráfico de memoria
- Los valores pasados por registros se ubican de mayor a menor, y los
boolse empaquetan en bits hasta 64 por registro
Ejemplo de función Rust compleja y límites actuales de rustc
- En el ejemplo
do_thing, que recibeOption<usize>,&dyn Context,&str,[char; 6]y una estructuraOptions, tras aplanar y ordenar todos los argumentos LLVM primitivos pueden entrar en registros - Los tipos LLVM de los argumentos raw del ejemplo quedan de esta forma
gprs: i64, ptr, ptr, ptr, i64, i32, i32xmm0: i32, i32, i32, i32xmm1: i32, i1, i1, i1, i1
- El prólogo de la función extrae los primitives y luego los recompone como valores a nivel de Rust
Option<usize>es{ i64, i1 }- El trait object es
{ ptr, ptr } &stres{ ptr, i64 }[char; 6]es[6 x i32]Optionses{ i32, i1, i1, i1 }
- Si se agrega metadatos
!dbga las instrucciones que materializan realmente los valores de argumentos, gdb puede dar mejores resultados al imprimir esos argumentos - Actualmente, para la misma función, rustc pasa a LLVM 8 parámetros del tamaño de puntero; como resultado, usa los 6 registros enteros y pasa 2 valores por stack
Valores de retorno y margen de optimización para Result
- Este diseño no cubre todas las optimizaciones posibles de convención de llamadas
- En algunos casos se podrían usar registros adicionales, como los registros AVX de x86
- También se puede considerar dividir estructuras entre registros y stack
- Hay margen de optimización específico para retornos
Result- Si se atraviesan varias capas de funciones con
?, puede haber muchos movimientos de registros redundantes - Si
Resultes tan grande que no cabe en registros, en el stack de llamadas de cada?hay que cargar desde memoria el bit ok para verificarlo - Como alternativa, error puede quedar como un puntero out-parameter, y el payload de la variante ok junto con el bit is-ok puede devolverse como
Option<T> - El detalle de que
?vaya acompañado de una llamada aIntoes complicado, pero implementable
- Si se atraviesan varias capas de funciones con
ABI dependiente de la optimización
- A diferencia de C, en
-Zcallconv=fastRust puede ver el cuerpo de la función al construir la ABI que observará el caller - Un crate puede anunciar una ABI exacta por función desde la perspectiva del paso por registros
- La optimización más simple es eliminar de la ABI los argumentos no usados
- Si una función no usa ningún parámetro, no se gasta ningún registro en ese argumento
- Si un argumento
&Tno se conserva ni se convierte a raw pointer, yTes pequeño yT: Freeze, se puede pasar by-value el pointee en lugar de la referencia - APIs como
HashMap::get()son candidatas- Si la key es de un tipo como
i32, actualmente hay que hacer spill del entero al stack y pasar su puntero - Ese tráfico de memoria puede evitarse
- Si la key es de un tipo como
- Una ABI basada en perfiles es una forma más agresiva
- Se puede priorizar los argumentos más hot en el orden de asignación de registros
- Aunque una estructura grande se reciba por referencia, el caller puede precargar 3 campos
i64hot y pasarlos tanto por puntero como por registros - El callee de todos modos tendría que hacer esa carga, así que no incurre en costo adicional
- Un instrumentation profile también podría justificar clonar funciones que solo difieren en la ABI
Por qué aún no se ha hecho
- Rust tiene menos restricciones de ABI que C++, por lo que puede generar mejor código, y esta idea se conecta con el enfoque que realmente usa la ABI de registros de Go
- El primer obstáculo es la complejidad de generación de código ABI
- LLVM ofrece muy pocas perillas de control útiles
- Tampoco es un área amigable dentro de rustc
- Si se implementa mal, puede producir malos resultados de usabilidad
- Otro obstáculo es la falta de experiencia
- Entre quienes contribuyen a rustc, son pocas las personas que entienden lo suficiente la semántica de LLVM y sus características de generación de código como para producir buen código y no hacer crashear LLVM
- El tiempo de compilación también puede ser una carga
- Mientras más complejas sean las firmas de función, más código de prólogo/epílogo debe procesar LLVM
- Sin embargo, como
-Zcallconvestá pensado para usarse solo con optimizaciones activadas, no se considera una desventaja decisiva
- El código ABI de Rust es un área con bajo bus factor, y el conocimiento de LLVM puede aprovecharse directamente para ayudar al equipo del compilador de Rust a generar código más optimizado
1 comentarios
Opiniones de Hacker News
Al optimizar una convención de llamada, lo clave no es razonar mentalmente sobre la forma que parece buena, sino medir el rendimiento.
El código es bueno si es rápido, no si parece rápido.
A veces lo que el autor llama código malo resulta ser lo más rápido por razones nada intuitivas, y eso solo se puede saber midiéndolo en benchmarks grandes.
Una de las razones por las que una convención de llamada que se ve mal funciona bien es que ahorra registros de argumentos y le facilita un poco más la vida al asignador de registros.
Además, las CPU actuales están optimizadas para los flujos de instrucciones que producen los compiladores de C, así que si generas código al estilo de un compilador de C que pasa cosas por la pila con una frecuencia inesperada, especialmente como MSVC, puedes terminar alineándote con el punto óptimo de la CPU.
Como el inlining funciona tan bien, las llamadas en las rutas calientes se vuelven fronteras poco frecuentes, y si esa frontera queda algo sucia pero simplifica otras cosas, está bien.
No digo que el cambio aquí sea malo, pero es raro debatirlo sin mediciones solo mirando código que parece extraño.
Trabajé optimizando convenciones de llamada en JavaScriptCore, y en código grande real ocurría con sorprendente frecuencia que ganara código con paso por pila que se veía mal.
Sin embargo, creo que los resultados de medición de rendimiento no deberían ser el único criterio.
En la frase de que las CPU de “hoy” están optimizadas, la palabra importante es hoy; las CPU siguen cambiando, por lo que una convención de llamada debe ser un diseño de largo plazo.
Por eso, lamentablemente, conviene no alejarse demasiado de la forma en que lo hace C++. Es probable que las optimizaciones de los procesadores futuros también apunten en esa dirección.
Al mismo tiempo, sería bueno considerar principios generales que no cambian fácilmente, como ahorrar registros de argumentos, para hacer que la convención de llamada sea robusta y orientada al futuro.
Me resulta un poco raro decir esto, porque parece que Rust se ha vuelto demasiado conservador en los últimos años en cuanto a su tolerancia a las rarezas (https://steveklabnik.com/writing/the-language-strangeness-bu...). Al final, no se puede mejorar sin ser diferente.
Si la función apenas empieza toma la dirección de un parámetro y se la pasa a una función desconocida, de todos modos habrá que derramarlo a la pila.
Sería interesante ver optimizaciones de convenciones de llamada basadas en el cuerpo de la función. Para funciones estáticas en C, parecería seguro siempre que no se tome su dirección.
Un JIT tiene ventaja en este problema porque, antes incluso de generar una sola línea de ensamblador, ya reunió mucha información sobre la CPU en la que realmente se está ejecutando.
En código puramente compilado de forma estática no se puede conocer el conjunto de capacidades de la arquitectura en tiempo de ejecución, así que en el código que más querrías optimizar terminas encontrándote con frecuencia con barreras de inlining.
Actualmente Rust parece flojo en ese aspecto en plataformas pequeñas, y la convención de llamada podría ayudar en relación con los retornos de
Result.Aun así, me da curiosidad si la ventaja empírica del paso por pila sigue aplicando al pasar a CPU ARMV8 con muchos registros o a RISC-V.
Es un borrador razonable, pero le falta la distinción entre registros guardados por el llamador y guardados por el llamado, y comete el error común de asignar parte de los registros de entrada a la salida
También es optimista esperar que los depuradores entiendan una convención de llamadas distinta de la de C. Sin importar qué pueda codificar DWARF, en la práctica es muy probable que falle de forma desastrosa
Cambiar el ABI según la configuración de optimización interactúa muy mal con la compilación separada
Reubicar los argumentos como si se tratara de bin packing funcionaría, pero aumenta mucho la complejidad del compilador, y no sé si vale la pena frente a una asignación de primer ajuste de izquierda a derecha. También hace más difícil que el desarrollador prediga adónde irá cada argumento
La dirección general de tener convenciones de llamada distintas para funciones cuya dirección se escapa y para las que no es sensata. Separar un prólogo que haga el matching de impedancia también funciona bien
Rust debería estar dispuesto a tener una convención de llamadas distinta de la de C, pero no sé si debería ser una única convención hardcodeada que usen todas las funciones. Parece natural ponerla en el sistema de tipos, y si se permite que el desarrollador controle la convención de llamadas, desaparece una de las ventajas de rendimiento del ensamblador
Desde el punto de vista del llamador, de todos modos tiene que liberar los registros de salida entre dos llamadas a funciones, y en las convenciones de llamadas del sistema se usa bastante
Supongo que se busca facilitar que el llamado prepare los valores de salida mientras conserva intactos los valores de entrada. Si es así, entiendo poner los registros de salida al final del orden de entrada para evitar solapamientos, pero no veo bien por qué habría que prohibir por completo cualquier solapamiento
Function Allama aFunction B,Function C,Function D, cambia los argumentos de las funciones intermedias a otra convención para reducir overheadMe pregunto qué semántica permitiría conservar esa optimización y a la vez permitir el control, o si en la práctica no es más que una ilusión
En realidad, el ensamblador no suele ser objetivo de la mayoría de las optimizaciones del compilador, por lo que tiene desventajas de rendimiento. Muchas veces tampoco recibe optimizaciones como “examinar el comportamiento, determinar que es totalmente redundante y eliminarlo por completo”; ya no estamos en los años 90
Dicho eso, si ni siquiera se pueden considerar esas optimizaciones, creo que donde el ensamblador inline claramente quedaría atrás es más que nada en la optimización basada en perfiles. Porque el desarrollador de la aplicación conoce perfectamente el comportamiento del código y el desarrollador del compilador no
El overhead de llamada puede eliminarse escribiendo más ensamblador hasta cubrir las fronteras calientes relevantes
boolpuede crear una cadena de dependenciasEn x64 no parece haber una forma claramente mejor de manejar los
boolque primero ponerlos en registros, hacer shifts y aplicar OR al resultadoEl enfoque simple crea una cadena de dependencias de longitud 64 y podría tener una penalización de 64 ciclos, aunque quizá se pueda reducir a 6 ciclos con una buena implementación, o de forma más realista a unos 12 ciclos
Pero también está el problema de de dónde vienen esos 64
bool. No hay tantos registros, así que al final habría que volver a leerlos desde la pilaSi el ABI de Rust ya empaqueta así de densamente los
booldentro de una estructura, entonces habría que hacerlo de todos modos, pero no lo sé bienY luego el llamador tiene que desempaquetarlos todos de nuevo
Probablemente sea más fácil enseñarle al compilador a derramar los valores al espacio de resultados en la pila, y es posible que también tenga mejor rendimiento
Entonces me pregunto cuánto ayuda realmente poner los valores en registros
La convención de llamadas de C es bastante floja
Es cierto que no se puede cambiar la convención de llamadas de C, pero eso no la hace menos lamentable
Habría que usar todos los registros disponibles guardados por el llamador para argumentos y valores de retorno, pero en el ABI SysV tradicional solo se usa un registro para el valor de retorno, a veces dos
Si se devuelve
struct Point3D { long x, y, z }, aunque se podría ponerPoint3Denrax,rdi,rsi, se derrama a la pilaOtros sistemas tienen otros trucos. Si no recuerdo mal, en SBCL cuando una función devuelve varios valores, al terminar activa la bandera de acarreo. Por ejemplo, pienso que podría ser útil usar la bandera de acarreo para indicar si un
Resultcontiene un errorLa convención de llamadas de C en la práctica soporta lo que soporta C: devolver un solo argumento. Ni siquiera la devolución de estructuras está bien resuelta
En C es más bien “¿qué esperabas?”, y en C++ la actitud es “simplemente haz inline y ya”
En cambio, los spills a memoria sí ocurren de verdad. Por ejemplo, el amplio espacio de registros y las ventanas de SPARC dejaban muchos registros sin usar en funciones simples, y derramar el anillo de registros terminaba en un gran uso de pila que rompía la caché
Aunque en x86 haya muchos
movque reubican los datos a “donde hacen falta”, con frecuencia acaba siendo más rápidoSi uno mira solo el código del llamado, dan ganas de decir “este argumento aquí y aquel valor de retorno allá sería siempre más rápido”, pero no se conoce al llamador
No se puede garantizar que la preparación de argumentos pase intacta ni que el valor de retorno se consuma en caliente. Por ejemplo, si se usa
struct Point { x: i32, y: i32, z: i32 }como argumento/retorno y el llamador, dentro de un bucle, hace algo comomystruct.deepinside.point[i] = func(mystruct.deepinside.point[i]), meterlo y sacarlo de registros puede volverse overhead o incluso impedir la vectorizaciónEl llamado no puede saber eso, salvo cuando el compilador puede ver ambos lados e inlining
El fruto más al alcance en lo relacionado con llamadas parece ser eliminar la premisa, incrustada en casi todos los ABI de C, de que una función devuelve un único valor primitivo. Para lo demás harían falta muchos benchmarks y estadísticas de generación de código
Rust tiene otro detalle lamentable que hace que las structs crezcan más de lo deseado
Si pensamos en una struct
Foocon 8 camposOptionque sonNoneoSome(u8), en C se podría representar con 8boolde 1 bit y 8uint8_t, para un total de 9 bytesEn Rust, se repiten 8 veces un discriminante de 1 byte y un
uint8_t, así que termina siendo de 16 bytesLa razón es que la struct debe poder ofrecer préstamos de sus campos. Si se tiene
&Foo, el compilador debe poder crear&Foo::some_field, es decir, un&Option, y ese&Optiondebe tener la misma forma que cualquier otro&Optionen el programaPor eso el
Optioninterno debe tener el mismo layout que cualquier otroOptiondel programa: su propio bit discriminante redondeado a byte, más elu8. La struct paga este costo aunque en la práctica nunca se cree&Foo::some_fieldSi pensamos en
Optionde tipos más grandes, empeora. En una struct con 8 camposOption, cada discriminante se redondea a 2 bytes, dando un total de 32 bytes; una cuarta parte, y casi la mitad si contamos los bits no usados de los discriminantes, se desperdicia en padding intermedio. La estructura equivalente en C necesitaría solo 18 bytesUsar
Optionpodría hacer que la struct de Rust mida 128 bytes, mientras que la de C mida 72 bytesPor supuesto, se puede implementar una representación como la de C usando un solo
u8para los discriminantes empaquetados y 8MaybeUninit, y creando manualmente funciones que mapeen desde&FooaOption<&T>y desde&mut FooaOption<&mut T>. Pero no se puede llegar a&Optionni a&mut Optionhttps://play.rust-lang.org/?version=stable&mode=debug&editio...
En la práctica, lo que se describió es un tipo definido por el usuario que contiene 8
Option, y si se empieza a cuidar el rendimiento, hay que manejar internamente esosOptionpor cuenta propiaQue Rust ofrezca una funcionalidad cómoda que se puede elegir usar cuando encaja con el objetivo difícilmente puede considerarse una desventaja
El caso de uso descrito es relativamente raro y, si realmente fuera un cuello de botella de rendimiento, dedicar un poco más de tiempo a implementarlo en Rust no sería un gran problema
Como en el uso común las ventajas del tipo
Option<_>son muy grandes, cuesta verlo como un “detalle lamentable” de RustHay una parte que dice que, si la dirección de una función no polimórfica y no inline puede tomarse como puntero a función, se crea un shim que usa
-Zcallconv=legacyy hace una tail call inmediata a la implementación real; entiendo la intención de preservar la igualdad de punteros a funciónPero si el shim legacy hace una tail call a una función con la convención de llamada de Rust, ¿no queda sin corregir la diferencia en el valor de retorno de la convención de llamada?
Es un tema un poco distinto, pero me pregunto si actualmente es posible la interoperabilidad entre Go y Rust
Recuerdo haber visto antes un caso en el que lo lograron usando Zig en el medio, pero no lo encuentro. Tengo código Rust legacy y quisiera migrarlo poco a poco a Go
extern "C"para llamar funciones de RustPresenté en RustConf 2023 cómo se usa esto en GitHub Code Search (https://www.youtube.com/watch?v=KYdlqhb267c), y después escuché que lugares como 1Password hacen algo parecido
Mover tipos a través del límite de interoperabilidad con C es engorroso y no muy divertido, pero es posible y también permite reutilizar código
extern "C"y luego se la llama desde Go como si se llamara a CEn la dirección contraria no estoy seguro
El código administrado debe poder ser dueño de la memoria que va a liberar o mover, y el código no administrado debe inferir cuándo se libera o se mueve la memoria
Cosas como
cgopermiten mezclar llamadas FFI desde el código administrado de Go hacia memoria no administrada, pero tienen un costoEste problema aparece siempre en implementaciones donde los lenguajes que se llaman entre sí no comparten el recolector de basura
Mezclar código administrado y no administrado es una idea antigua y, aun así, sigue siendo un tema de investigación activo
Salvo que el runtime integrado esté diseñado para eso, llamar desde código no administrado a código administrado casi siempre es una mala idea, y normalmente se coloca una capa de serialización en el medio
Si esto fuera mi trabajo principal quizá me parecería malo, pero me cansé de volver al código cada varias semanas y no recordar qué se hacía ni cómo
Pasé un closure de Rust con estado al código Go como callback para meterlo en una función de la biblioteca estándar de Go, incluyendo el unwinding de un panic dentro del closure de Rust
https://github.com/Voultapher/sort-research-rs/commit/df6c91...
Pasé un buen rato usando Inspeccionar elemento para averiguar cómo habían puesto inclinados los títulos de las secciones, pero con las herramientas de Safari no pude avanzar. ¿Cómo diablos lo hicieron?
.post-title:transform: skewY(-2deg) translate(-1rem, -0.4rem);element()(https://developer.mozilla.org/en-US/docs/Web/CSS/element), pero en realidad era una copia del cuerpo del artículo reducida a un tamaño diminuto.h1, h2, h3, h4, h5, h6se les aplicatransform:skewY(-2deg) translate(-1rem,0rem);,transform-origin:top;,font-style:italic;,text-decoration-line:underline;,text-decoration-color:goldenrod;,text-underline-offset:4%;,text-decoration-thickness:.25ex.En contraste, está el artículo de 2019 “How Swift Achieved Dynamic Linking Where Rust Couldn't”
https://faultlore.com/blah/swift-abi/
Es una lástima que Rust todavía no tenga una convención de llamadas para semántica al nivel de Rust, pero al mismo tiempo ese artículo muestra que la cantidad de trabajo necesaria para llegar ahí es enorme.
Apple estaba profundamente motivada para convertir Swift en un lenguaje de sistemas práctico del que pudieran depender las aplicaciones, pero Rust no cuenta con ese tipo de patrocinio.
Discusión en HN: https://news.ycombinator.com/item?id=21488415
Sería bueno que Rust tuviera más opciones de soporte para este compromiso, y no necesariamente limitadas a cosas como https://github.com/rust-lang/rfcs/pull/3470.
Si el compilador actual de Rust primero hace inline de forma agresiva y luego optimiza, me pregunto si esto realmente vale la pena.
Si la función llamada es pequeña, se hará inline; y si es grande, se pasará bastante tiempo dentro de la función, así que el overhead de la llamada será pequeño.
dyn Trait, no pueden hacerse inline, así que este cambio ayudaría.Si se pueden abaratar las llamadas, no habría que hacer inline de forma tan agresiva, lo que también podría ayudar con el tamaño del código y los tiempos de compilación.
Una función compleja que no sea buena candidata para inline probablemente acceda a memoria varias veces, y es muy posible que esos accesos sean el cuello de botella.
Pasar por la pila aprieta más ese cuello de botella, porque aumenta la presión sobre la caché y las cargas/almacenamientos.
Si Rust puede pasar argumentos de manera óptima en una proporción considerable de las llamadas a funciones, no solo podría evitar unos cuantos ciclos de acceso a L1, sino también hacer que la CPU llegue antes al cuello de botella de memoria esencial.
Quizá haya una ganancia de unos cuantos puntos porcentuales, pero ahora mismo estoy tomando vino y no estoy haciendo los cálculos.
¿Alguien puede explicar qué significa la mnemotecnia “Diana’s silk dress cost $89” que aparece en el material de referencia de x86?