2 puntos por GN⁺ 2025-02-14 | 1 comentarios | Compartir por WhatsApp
  • En Ruby, FFI es práctico para llamar funciones nativas, pero en el benchmark de strlen su overhead de llamada es mayor que el de una extensión en C, lo que lo convierte en un cuello de botella de rendimiento
  • En el benchmark base, la llamada directa a String#bytesize logró 39.879M i/s, la extensión en C 30.661M i/s, la llamada indirecta en Ruby 28.697M i/s, y FFI fue la más lenta con 15.682M i/s
  • La idea central es generar en tiempo de ejecución código máquina para invocar funciones externas usando el nombre de la función, los tipos de argumentos y el tipo de retorno que ya se conocen al momento de attach_function
  • La prueba de concepto FJIT elevó la llamada a strlen hasta 32.508M i/s en Ruby 3.5.0dev sobre ARM64, más de 2 veces más rápido que FFI y ligeramente más rápido que una extensión en C
  • Por ahora, está limitado a ARM64, un solo argumento y un solo retorno, tipos restringidos, --rjit --rjit-disable, y dependencia de un commit específico de Ruby head, así que todavía hace falta ampliar la implementación antes de usarlo en la práctica

Dónde se vuelve lento FFI en Ruby

  • La dirección general es escribir la mayor cantidad posible de código Ruby y llamar código nativo solo cuando sea estrictamente necesario
  • YJIT puede optimizar código Ruby, pero no puede optimizar código C
  • Si se necesita una librería nativa, lo ideal es poner un wrapper delgado de extensión en C alrededor de la función real y manejar la mayor parte del trabajo en Ruby
  • Este tipo de API simple encaja bien con FFI, pero el FFI existente no logra el mismo rendimiento que una extensión en C

Benchmark base con strlen

  • Se compararon cuatro opciones
    • Llamar strlen de C mediante FFI
    • Llamada indirecta a x.bytesize desde el método Ruby B.strlen
    • Llamar una extensión en C creada con el gem Ruby strlen
    • Llamada directa a str.bytesize
  • Resultados en Ruby 3.5.0dev sobre ARM64:
    • ruby-direct: 39.879M i/s, 25.08 ns/i
    • strlen-cext: 30.661M i/s, 32.61 ns/i, 1.30 veces más lento que la llamada directa
    • strlen-ruby: 28.697M i/s, 34.85 ns/i, 1.39 veces más lento que la llamada directa
    • strlen-ffi: 15.682M i/s, 63.77 ns/i, 2.54 veces más lento que la llamada directa
  • La llamada directa a String#bytesize fue la más rápida, y cualquier llamada indirecta agrega más overhead
  • La diferencia entre ruby-direct y strlen-ruby muestra el costo de hacer push/pop de stack frames, y eliminar ese overhead es precisamente un área en la que un compilador JIT como YJIT funciona bien
  • La brecha entre strlen-cext y strlen-ffi deja ver que el costo adicional al invocar funciones nativas mediante FFI es considerable

Reemplazar llamadas FFI con JIT

  • En el momento en que se llama attach_function :strlen, [:string], :int, la información necesaria ya existe
    • Nombre de la función a invocar: strlen
    • Tipo de argumentos: string
    • Tipo de retorno: int
  • Con esa información se puede generar código máquina que desempaquete valores Ruby en tipos nativos, llame la función externa y luego vuelva a envolver el valor de retorno como un objeto Ruby
  • Se necesitan tres componentes
    • AArch64 gem: generación de código máquina para ARM64
    • Fisk gem: generación de código máquina para x86_64
    • JITBuffer gem: asignación de memoria ejecutable
  • Generar código máquina por sí solo no basta; Ruby también debe poder saltar a ese código máquina para poder omitir el overhead de FFI

La ruta usando RJIT

  • RJIT es un compilador JIT para Ruby escrito en Ruby y distribuido junto con Ruby
  • Su estructura interna es parecida a la de YJIT, pero como no está pensado para uso en producción, no es tan conocido como YJIT
  • Kokubun presentó una solicitud de funcionalidad para extraer RJIT como gem
  • Esta propuesta aporta dos bases para facilitar la creación de compiladores JIT de Ruby de terceros
    • Separar RJIT como un gem
    • Exponer los tipos internos de Ruby como estructuras de datos Ruby, para que un JIT de terceros pueda obtener la información necesaria para envolver y desempaquetar tipos de datos Ruby
  • Otro cambio es que, si existe un puntero a función de entrada JIT, siempre se ejecuta
    • Si un JIT de terceros registra código máquina, Ruby puede saltar automáticamente a ese código
  • Con estos dos elementos se puede construir un compilador JIT pequeño y de propósito único que funcione como interfaz para FFI

Prueba de concepto FJIT

  • La prueba de concepto de FJIT, abreviatura de “FFI JIT”, genera en tiempo de ejecución código máquina para llamar funciones externas
  • El ejemplo adjunta strlen con una interfaz similar a FFI
    • module C
    • extend FJIT
    • attach_function :strlen, [:string], :int
  • Cuando se llama attach_function, FJIT genera código máquina que desempaqueta una cadena Ruby, llama strlen de C y devuelve la longitud de la cadena como un objeto Ruby

Resultados del benchmark de FJIT

  • Resultados en Ruby 3.5.0dev, +RJIT +PRISM, sobre ARM64:
    • ruby-direct: 41.907M i/s, 23.86 ns/i
    • strlen-fjit: 32.508M i/s, 30.76 ns/i, 1.29 veces más lento que la llamada directa
    • strlen-cext: 29.778M i/s, 33.58 ns/i, 1.41 veces más lento que la llamada directa
    • strlen-ruby: 28.851M i/s, 34.66 ns/i, 1.45 veces más lento que la llamada directa
    • strlen-ffi: 15.629M i/s, 63.98 ns/i, 2.68 veces más lento que la llamada directa
  • La llamada directa a String#bytesize sigue siendo la más rápida
  • El código máquina generado por FJIT fue el segundo más rápido y obtuvo un resultado ligeramente mejor que la extensión en C para strlen
  • FJIT fue más de 2 veces más rápido que una llamada FFI, e incluso más rápido que una llamada indirecta en Ruby
  • Esto sugiere que es posible igualar o superar la velocidad de una extensión en C manteniendo al mismo tiempo el enfoque de “escribir la mayor cantidad posible en Ruby”

Restricciones pendientes antes de aplicarlo en la práctica

  • El compilador JIT de prueba de concepto está limitado actualmente a la plataforma ARM64
    • Para extenderlo a una implementación real, habría que agregar un backend para x86_64
  • Todavía no maneja todos los tipos de argumentos ni todos los tipos de retorno
    • Se considera factible soportar todos los tipos de argumentos, y no parece ser una carga de trabajo excesiva
  • Por ahora solo maneja funciones que reciben un solo argumento y devuelven un solo valor
  • Actualmente hay que ejecutar Ruby con las banderas --rjit --rjit-disable
    • Se espera que este requisito desaparezca cuando entre la funcionalidad propuesta por Kokubun
  • La prueba de concepto solo funcionaba con el Ruby head actual de ese momento
  • A la fecha de la actualización, RJIT fue eliminado de Ruby head, así que para ejecutar el script hay que hacer checkout de Ruby en el commit f32d5071b7b01f258eb45cf533496d82d5c0f6a1

1 comentarios

 
GN⁺ 2025-02-14
Comentarios de Hacker News
  • Timefold, el solucionador de restricciones en Java, trabajó bastante con FFI para poder llamar desde CPython a funciones definidas en Java, y la mayoría de los problemas de rendimiento aparecían al ir y venir mediante proxies entre el lenguaje host y el lenguaje externo
    Si haces llamadas FFI directas con JNI o con la nueva interfaz externa, pueden ser casi tan rápidas como llamar directamente a un método de Java, pero como los recolectores de basura de CPython y Java no encajan bien entre sí, hace falta magia negra para sincronizarlos
    En cambio, proxies como JPype o GraalPy necesitan convertir parámetros y valores de retorno, y además pueden provocar llamadas FFI extra en la dirección opuesta. Si pasas un objeto de CPython a Java, Java crea un proxy de ese objeto, y si luego vuelves a pasar ese proxy a CPython, en vez de deshacerlo termina creando un proxy del proxy
    Como resultado, el proxy de JPype fue 1402% más lento que llamar directamente a CPython por FFI, y el proxy de GraalPy fue 453% más lento
    Al final, tradujeron el bytecode de CPython a bytecode de Java y generaron estructuras de datos de Java correspondientes a las clases de CPython usadas, con lo que lograron ser 100 veces más rápidos que usando proxies. Como nota adicional, el bytecode de CPython es muy inestable, está poco documentado y además las características de su VM son complicadas, así que es mejor no intentar traducirlo ni leerlo directamente
    Los detalles están resumidos en el artículo: https://timefold.ai/blog/java-vs-python-speed

    • Hablando sin experiencia, el FFI de Python a C y el de Java a C parecen bastante mejores por separado, así que quizá la solución general sea conectarlos con un pequeño puente en C
    • También vale la pena leer cgo is not Go
      El código Go y el código C tienen que ponerse de acuerdo sobre cómo compartir recursos como el espacio de direcciones, manejadores de señales y slots TLS de los hilos, pero en la práctica esto se parece más a que Go tiene que esquivar los supuestos del código C. El código C puede asumir que corre en un solo hilo, o incluso no estar preparado en absoluto para un entorno multihilo
      Como C no conoce la convención de llamadas de Go ni sus stacks crecientes, para llamar código C hay que registrar los detalles de la stack de la goroutine y cambiar a la stack de C antes de ejecutar código C, que no sabe cómo fue llamado ni conoce el runtime completo de Go
      Ya sea que hagas bindings o envuelvas código C desde Python, desde Java con JNI, desde lenguajes que usan libffi o desde Go con cgo, al final terminas viviendo en el mundo de C
      https://dave.cheney.net/2016/01/18/cgo-is-not-go / https://archive.vn/GZoMK
    • Me pregunto en qué punto encajarían enfoques de comunicación entre procesos como colas, archivos o HTTP en casos como este
  • Gracias a Rails At Scale y a los artículos de byroot, este es un muy buen momento para leer textos que profundizan de verdad en la estructura interna y el rendimiento de Ruby. Viendo además las mejoras recientes en Ruby y Rails, también es una época bastante buena para ser desarrollador Ruby

    • No sé si realmente sea así. Ruby parece ir en bajada https://www.tiobe.com/tiobe-index/ruby/
      Sigue siendo popular para cierto tipo de aplicaciones, pero su mejor época parece haber quedado bastante atrás, y aunque las mejoras recientes son buenas, no sé si un JIT sea técnicamente tan interesante en 2025
  • El enfoque de “¿y si en vez de llamar una biblioteca de terceros simplemente se hace JIT del código necesario para invocar funciones externas?” se parece mucho a la base del FFI de LuaJIT: https://luajit.org/ext_ffi.html
    Por eso da la impresión de que el FFI de LuaJIT es muy rápido

  • No se entiende bien eso de “escribe tanto como sea posible en Ruby. En particular, YJIT puede optimizar código Ruby, pero no código C”.
    ¿Ruby no es un lenguaje bastante lento? Si al final va a bajar a nativo, parecería que uno querría hacer la mayor cantidad posible en código nativo.

    • Hubo un pequeño drama de este tipo en la época en que Java consiguió un JIT decente.
      En una versión importante se descubrió que el cuello de botella era código Java que manejaba el comportamiento de cierto elemento de UI, así que en la siguiente gran versión se reescribió en C.
      Después, cuando el JIT pasó a ser realmente útil, el overhead del FFI terminó siendo mayor que la diferencia entre el código C ajustado a mano y el código generado por el JIT, y en la siguiente gran versión volvió a una implementación pura en Java.
      El FFI de Java era bastante rápido para un lenguaje de esa generación, pero unas cuantas versiones después lo cambiaron por un enfoque mejor, y para entonces ya no seguía tanto el código UI en Java. Como al mismo tiempo también estaban ordenando la interfaz entre el código específico por plataforma y el código UI general en Java, no me queda claro cómo terminó exactamente.
      En este tipo de trabajo hay que vigilar constantemente este efecto sube y baja. Hay que evaluar si conviene esperar unos cuantos hitos para reducir trabajo de ajuste manual, o si por razones políticas o técnicas hace falta hacerlo ya mismo.
    • Aquí es donde entra el JIT. Idealmente, el JIT puede volver a optimizar el código hacia un estado mejor.
      Puede ser ineficiente para trabajos que se ejecutan una sola vez y terminan, pero en cargas de escritorio o servidor de larga vida hay una recompensa a nivel de la aplicación completa.
      Por ejemplo, el JIT de Dalvik era bastante débil, así que convenía más llamar funciones matemáticas en C, pero después de ART eso dejó de ser necesario y el JIT pudo superar el costo de llamar a C.
      https://developer.android.com/reference/android/util/FloatMa...
    • En lenguajes administrados con compilador JIT o AOT, muchas veces lo ideal es escribir la mayor parte del código en ese lenguaje. Así se habilitan inlining y otras optimizaciones que son imposibles al cruzar la frontera de llamadas a C.
      A veces a esta tendencia se le llama “self-hosting”, y los navegadores también la aprovechan mucho al mover a JavaScript privilegiado partes que originalmente uno habría escrito en C/C++. Una porción sorprendentemente grande de la biblioteca estándar no es código nativo.
    • El FFI crea una frontera opaca e imposible de optimizar entre códigos. El código que va y viene con frecuencia de esta forma resulta costoso.
      Incluso en lenguajes mucho más rápidos y con costo de interoperabilidad cercano a cero, como C#, sigue habiendo costo de llamada, y a veces además pagas por cambios de flags de estado de la VM o por transiciones del garbage collector.
      Si Ruby YJIT ya empezó a ser un factor medible, esta regla de arriba también va a volverse cada vez más importante.
    • Hay un excelente artículo de JPCamara que explica por qué el método each de Ruby fue reescrito de nuevo en Ruby: https://jpcamara.com/2024/12/01/speeding-up-ruby.html / https://bugs.ruby-lang.org/issues/20182
      También hay un artículo extra de tender love: https://railsatscale.com/2023-08-29-ruby-outperforms-c/
      En resumen, el JIT gana.
  • FFI significa Foreign Function Interface, es decir, la forma de llamar C desde Ruby.

    • El enfoque completamente seguro y sensato sería escribir código C que en tiempo de ejecución reciba datos por línea de comandos y escupa resultados por la línea de comandos o en páginas de memoria.
      Luego, desde Ruby, ejecutas ese programa C en la terminal con flags o datos, y así Ruby puede ejecutar código C.
  • No entiendo por qué esto habría que compilarlo con JIT. Si se puede escribir en C, ¿no se podría simplemente compilar al momento de cargarlo?

    • No me queda claro qué quieres decir con que se puede escribir en C. Las bibliotecas FFI permiten enlazar dinámicamente y ejecutar métodos de bibliotecas desde Ruby sin escribir extensiones nativas.
      Eso aumenta mucho la productividad y permite compartir el mismo código entre CRuby, JRuby y TruffleRuby.
      Si pudieras conocer estáticamente todos los bindings al arrancar, podrías escribir stubs y meterlos en la tabla de métodos, pero aun así seguiría siendo algo que ocurre en runtime, así que contaría como JIT. Y como no puede adaptarse a los tipos que circulan por el sistema, no te queda otra que ser conservador con los valores aceptados y las optimizaciones, que es bastante parecido a lo que hace hoy libffi.
      El enfoque AOT es escribir extensiones nativas.
  • Como detalle tangencial, normalmente termino evitando no tanto FFI en sí, sino las gems que usan FFI. Compilar suele ser demasiado engorroso, al punto de que muchas veces era más fácil saltarse la etapa intermedia de Rubygems/bundler y compilar directo.

  • Algo relacionado: esta biblioteca usa JVMCI para generar al vuelo código arm64/amd64 que llama bibliotecas nativas sin JNI: https://github.com/apangin/nalim

  • ¿No es esto exactamente lo que hace libffi?

    • libffi es lento y, hasta donde sé, no hace JIT
      En libffi se crea un objeto descriptor para la función, y esa estructura de datos en tiempo de ejecución representa los tipos de los argumentos y del valor de retorno.
      Al hacer una llamada FFI, hay que pasar un descriptor y un arreglo de punteros a los valores que se quieren enviar. Internamente, probablemente recorre juntos el arreglo de valores y el descriptor para colocar los valores en la pila según su tipo y, cuando termina la función, sacar el valor según el tipo de retorno. Es muy probable que en todo este proceso haya bifurcaciones por tipo en varios puntos.
      Incluso si el mecanismo de llamada de libffi se hiciera con JIT, preparar el arreglo de argumentos seguiría siendo lento. Es menos directo que un FFI JIT que accede directamente a los argumentos sin pasar por un arreglo intermedio.
      El código JIT de FFI recibe directamente los valores de los argumentos, los convierte de tipos de Ruby a tipos de C, coloca cada valor en la posición correcta de la pila o de los registros con código inline y luego llama a la función y convierte el valor de retorno a un tipo de Ruby. En la práctica, es parecido a escribir a mano código de extensión.
      Si hay inferencia de tipos, se pueden omitir las comprobaciones de tipo en el código de conversión. Por ejemplo, si se garantiza que arg1 es una cadena de Ruby, se puede usar una versión unsafe más rápida de la función de conversión.
      En el peor de los casos, el código JIT solo necesita reflejar algo como los tipos de Ruby, y no requiere arreglos ni listas relacionadas con los argumentos. Como a qué tipo de C se convierte está hardcodeado en el código, no hace falta recorrer en tiempo de ejecución una estructura de datos que describa el lado de C.
    • libffi no sabe qué es Ruby, así que no puede saber cómo desempaquetar tipos de Ruby
      La ventaja de este artículo es que, con base en la información que el usuario pasa al llamar a attach_function, el código para desempaquetar tipos queda en la práctica cacheado dentro del código máquina generado.
    • libffi no hace JIT de las llamadas FFI y aún hay que colocar directamente los valores de los argumentos. Por ejemplo, si el argumento es una cadena, hay que escribir por separado el código que convierte un objeto cadena de Ruby en un puntero a cadena de C. Y libffi es bastante lento.
      tramp.c, enlazado en el comentario hermano, es para “FFI inverso”, es decir, para exponer trabajo dinámico del usuario como puntero de función, y el JIT ahí es del nivel de apenas 3 instrucciones en total para invocar código precompilado.
    • Creía que entendía qué hacía libffi; pensé que estaba haciendo trucos con algo como la GOT. Pero parece que tienes razón.
      https://github.com/libffi/libffi/blob/master/src/tramp.c