2 puntos por GN⁺ 2024-04-20 | 1 comentarios | Compartir por WhatsApp
  • 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, bool y Result pueden 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 en rdi y rsi
    • Es un caso en el que la ruta predeterminada de Rust es incluso más conservadora que la ABI C de Linux

-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
    • -O también podría configurar automáticamente -Zcallconv=fast
  • La convención de llamadas fast no 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=fast podría no estar soportado
  • En builds debug sin optimización, fast podrí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

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 ptr adicional con el atributo sret
    • 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, double y <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=fast tengan la misma cantidad de argumentos by-register
    • 6 argumentos para registros enteros
    • 8 argumentos vectoriales desde xmm0 hasta xmm7
    • Al pasar punteros reales, reemplazar el i64 correspondiente por ptr
    • 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 poison de LLVM en los argumentos no usados se puede evitar el costo adicional
    • LLVM puede considerar poison como el valor que le resulte más conveniente en ese momento
    • Si se pasa poison como 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 en rcx, y el código que carga poison en los otros 13 registros no genera ningún código tras la optimización
  • 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 bits
    • bool tiene 1 bit
    • char técnicamente tiene 21 bits, pero para simplificar se trata como un alias de u32
  • Una estructura con muchos bool puede devolver varios bool empaquetados 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 u128 y f64, 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 bool se 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 recibe Option<usize>, &dyn Context, &str, [char; 6] y una estructura Options, 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, i32
    • xmm0: i32, i32, i32, i32
    • xmm1: 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 }
    • &str es { ptr, i64 }
    • [char; 6] es [6 x i32]
    • Options es { i32, i1, i1, i1 }
  • Si se agrega metadatos !dbg a 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 Result es 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 a Into es complicado, pero implementable

ABI dependiente de la optimización

  • A diferencia de C, en -Zcallconv=fast Rust 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 &T no se conserva ni se convierte a raw pointer, y T es pequeño y T: 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
  • 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 i64 hot 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 -Zcallconv está 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

 
GN⁺ 2024-04-20
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.

    • Estoy muy de acuerdo en que el código que parece rápido no siempre es realmente rápido.
      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.
    • Que pasar por registros sea más rápido también depende del cuerpo de la función.
      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.
    • Esa experiencia no se transfiere por completo.
      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.
    • El rendimiento puede incluir no solo la velocidad de ejecución, sino también el tamaño del binario.
      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.
    • El texto original trata en general sobre x86, e Intel ha hecho durante décadas una ingeniería sorprendente para que el código x86 feo corra rápido sobre el silicio que la gente compra.
      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

    • Me pregunto por qué es tan problemático usar parte de los registros de entrada como registros de salida
      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
    • Si se deja que el desarrollador controle la convención de llamadas, también se bloquea una optimización que, en una cadena donde Function A llama a Function B, Function C, Function D, cambia los argumentos de las funciones intermedias a otra convención para reducir overhead
      Me 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
    • Actualmente DWARF no codifica en absoluto convenciones de llamada personalizadas
    • El bin packing podría más bien volverlo más lento, y en especial en el caso de bool puede crear una cadena de dependencias
      En x64 no parece haber una forma claramente mejor de manejar los bool que primero ponerlos en registros, hacer shifts y aplicar OR al resultado
      El 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 pila
      Si el ABI de Rust ya empaqueta así de densamente los bool dentro de una estructura, entonces habría que hacerlo de todos modos, pero no lo sé bien
      Y 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
    • La mayoría de los procesadores modernos reenvían con facilidad una lectura que sigue inmediatamente a una escritura, y también hay varios trucos para rastrear el estado de la pila
      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 poner Point3D en rax, rdi, rsi, se derrama a la pila
    Otros 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 Result contiene un error

    • “Floja” es una palabra fuerte, pero en cuanto a valores de retorno es correcta
      La 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 mov que reubican los datos a “donde hacen falta”, con frecuencia acaba siendo más rápido
      Si 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 como mystruct.deepinside.point[i] = func(mystruct.deepinside.point[i]), meterlo y sacarlo de registros puede volverse overhead o incluso impedir la vectorización
      El 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 Foo con 8 campos Option que son None o Some(u8), en C se podría representar con 8 bool de 1 bit y 8 uint8_t, para un total de 9 bytes
    En Rust, se repiten 8 veces un discriminante de 1 byte y un uint8_t, así que termina siendo de 16 bytes
    La 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 &Option debe tener la misma forma que cualquier otro &Option en el programa
    Por eso el Option interno debe tener el mismo layout que cualquier otro Option del programa: su propio bit discriminante redondeado a byte, más el u8. La struct paga este costo aunque en la práctica nunca se cree &Foo::some_field
    Si pensamos en Option de tipos más grandes, empeora. En una struct con 8 campos Option, 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 bytes
    Usar Option podría hacer que la struct de Rust mida 128 bytes, mientras que la de C mida 72 bytes
    Por supuesto, se puede implementar una representación como la de C usando un solo u8 para los discriminantes empaquetados y 8 MaybeUninit, y creando manualmente funciones que mapeen desde &Foo a Option<&T> y desde &mut Foo a Option<&mut T>. Pero no se puede llegar a &Option ni a &mut Option
    https://play.rust-lang.org/?version=stable&mode=debug&editio...

    • La versión en C también hay que implementarla a mano, así que no es tan raro que en Rust haya que hacer lo mismo
      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 esos Option por cuenta propia
    • La versión equivalente en C también hay que implementarla a mano
      Que 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 Rust
  • Hay 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=legacy y hace una tail call inmediata a la implementación real; entiendo la intención de preservar la igualdad de punteros a función
    Pero 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?

    • Correcto. La gente tiende a olvidarse de la mitad de retorno de las convenciones de llamada, así que parece un error tipográfico entendible
  • 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

    • Sí, se puede. Con CGO se puede usar FFI extern "C" para llamar funciones de Rust
      Presenté 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
    • Para llamar a Rust desde Go, se declara la función de Rust como extern "C" y luego se la llama desde Go como si se llamara a C
      En la dirección contraria no estoy seguro
    • Mezclar memoria administrada y no administrada normalmente no es una buena idea
      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 cgo permiten mezclar llamadas FFI desde el código administrado de Go hacia memoria no administrada, pero tienen un costo
      Este 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
    • Como tuve que usar bastante Rust y Swift, terminé adoptando la forma rutinaria de pasar arreglos de bytes de protobuf serializados mediante llamadas a funciones convencionales
      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
    • Como ejemplo bastante maldito, hace poco llamé código Go desde Rust poniendo C en el medio
      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?

    • El estilo está en el elemento .post-title: transform: skewY(-2deg) translate(-1rem, -0.4rem);
    • Relacionado con eso, pensé que el minimapa usaba la función CSS 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.
    • A h1, h2, h3, h4, h5, h6 se les aplica transform: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

    • Para ser justos, también hay que señalar que el enfoque de Swift tiene un costo en tiempo de ejecución.
      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.

    • Las funciones en tiempo de ejecución, por ejemplo 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.
    • Probablemente sí valga la pena.
      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?