- En un pequeño benchmark de lenguajes, CRuby quedó tercero desde el final, pero esto muestra que el cuello de botella no está en todo el lenguaje, sino en una implementación de iteración que YJIT no puede inspeccionar
- Ruby 3.3.6 tardó 12.17 s en Fibonacci y 28.80 s en Loops en una MacBook Pro M3, mientras que node.js estuvo en el rango de 1 segundo en ambos; en una MacBook Air M2, Ruby fue más lento, con 16.33 s y 33.43 s respectivamente
- Con solo activar
ruby --yjit, Fibonacci baja mucho, de 16.88 s a 2.06 s, pero Loops solo baja de 33.43 s a 25.57 s, por lo que Range#each sigue siendo el cuello de botella - Los métodos cuya implementación en C se trasladó al lado de Ruby, como Integer#times en Ruby 3.3 y Array#each en Ruby 3.4, pasan a ser objetivo de optimización de YJIT, lo que mejora el benchmark de iteración al rango de 13~14 s
- CRuby puede usar implementaciones Ruby cuando YJIT está activo mediante el hook
with_yjit, y mantener la implementación en C cuando está inactivo; esto está generando una tendencia a convertir bibliotecas core en código Ruby compatible con YJIT
La posición de Ruby revelada por el benchmark
- El language comparison repo, compartido recientemente, es un repositorio colaborativo que construye pequeños benchmarks para varios lenguajes
- En los resultados de CRuby de ese repositorio, solo R y Python fueron más lentos que Ruby, quedando tercero desde el final
- El benchmark observa dos ejes
- Loops: enfatiza el rendimiento de bucles, condicionales y operaciones matemáticas básicas
- Fibonacci: expone el overhead de llamadas a funciones y el costo de la recursión
- El ejemplo Loops ejecuta, con bucles anidados, un total de 10,000 × 100,000 iteraciones, es decir, 1,000 millones de iteraciones
- El ejemplo Fibonacci no usa una implementación optimizada, sino una naive Fibonacci intencionalmente simple
Mediciones iniciales y efecto de aplicar YJIT
- En una MacBook Pro M3, Ruby 3.3.6 estuvo en estos niveles
- Fibonacci: 12.17 s
- Loops: 28.80 s
- node.js: en ambos ejemplos, apenas por encima de 1 segundo
- En una MacBook Air M2, el mismo benchmark dio resultados más lentos
- Ruby Fibonacci: 16.33 s
- Ruby Loops: 33.43 s
- node.js Fibonacci: 1.36 s
- node.js Loops: 2.07 s
- El comando de ejecución del repositorio original era de la forma
ruby ./code.rb 40, sin YJIT - Al ejecutarlo como
ruby --yjit ./code.rb 40, los resultados cambian- Fibonacci: 2.06 s
- Loops: 25.57 s
- YJIT tuvo un gran efecto en Fibonacci, pero en Loops la mejora fue limitada
Por qué Range#each es desfavorable para YJIT
- La iteración central del código Loops usa Range#each en formas como
(0...10_000).eachy(0...100_000).each - En Ruby 3.4,
Range#eachsigue estando escrito en C range.cde CRuby conecta la claseRangey el métodoeachcon la función Crange_eachrange_eachramifica por distintos caminos para manejar múltiples formas de rangos(0...).each(0...100).each("a"..."z").each
- La función C en sí puede ser rápida, pero YJIT no puede ver su interior
- La optimización se detiene en el punto de llamada a la función y vuelve a continuar después de que la función C retorna
- YJIT puede crear optimizaciones especializadas para rutas de ejecución calientes, pero una implementación en C limita esta ventaja
Cambios al usar Integer#times
- En Ruby 3.3,
Integer#timespasó de ser una función C a un método Ruby - Su estructura central es un bucle Ruby simple formado por
while i < self,yield iei = i.succ - Al estar escrito en Ruby, YJIT puede analizar y optimizar su interior
- Si se cambian las iteraciones con Range por
10_000.timesy100_000.times, el tiempo de Loops se reduce considerablementeRange#each: 25.57 sInteger#times: 13.66 s
- En otros entornos de medición,
Integer#timesbajó hasta 9 s, y en Ruby 3.4 también hubo resultados de 8 s
Integer#succ y optimización a nivel de VM
- La implementación de
Integer#timesusai.succpara la operación de incremento, en lugar dei += 1 Integer#succes un método que devuelve el siguiente valor de un entero- En el bytecode de la VM de Ruby,
i.succse representa como un solo paso,opt_succ - En cambio,
i += 1se divide en dos pasosputobject_INT2FIX_1_: coloca el entero 1 en el stack de la VMopt_plus: ejecuta la operación+
- En programas Ruby comunes casi no hace falta preocuparse por esto, pero a nivel de JIT y VM, cuando se repite de millones a miles de millones de veces, incluso una diferencia de un paso puede afectar el rendimiento
Array#each también pasó a una implementación Ruby en Ruby 3.4
- En Ruby 3.4,
Array#eachtambién pasó de C a una implementación del lado de Ruby - El primer intento fue código Ruby simple, pero había una race condition relacionada con partes internas de CRuby
- La implementación final usa
Primitivedentro del código RubyPrimitive.attr! :inline_block, :c_tracePrimitive.cexpr!ary_fetch_next
- Más que Ruby puro por completo, mezcla evaluación de código C con estructura Ruby, pero YJIT aún puede realizar una optimización considerable
- Si se crea un arreglo de antemano y se itera con
Array#each, el rendimiento es similar al deInteger#timesRange#each: 25.57 sInteger#times: 13.66 sArray#each: 13.96 s
Mediciones de Ruby Microbench
- El repositorio separado ruby_microbench compara el ejemplo original con varias formas de iteración en Ruby
- Con YJIT activado en Ruby 3.4, los resultados fueron los siguientes
- Fibonacci: 2.19 s
array#each: 14.02 srange#each: 26.61 stimes: 13.12 sfor: 14.91 swhile: 37.10 sloop do: 13.95 s
- Al desactivar YJIT en Ruby 3.4, la mayoría se vuelve más lento
- Fibonacci: 16.49 s
array#each: 34.29 srange#each: 33.88 stimes: 33.18 sfor: 36.32 swhile: 37.14 sloop do: 50.65 s
- El ejemplo
whilefue más lento de lo esperado, y podría deberse a la forma de implementación for inyarray#eachson casi idénticos a nivel de bytecode de la VM de Ruby, por lo que su rendimiento también es similarfor ines, en general, azúcar sintáctica que en la VM se convierte en una llamada a#each
Comparación con otras implementaciones de Ruby
- El mismo benchmark también se ejecutó en varias implementaciones de Ruby
- Algunos resultados son los siguientes
- TruffleRuby 24.1
- Fibonacci: 0.92 s
array#each: 0.97 srange#each: 0.92 stimes: 2.39 sfor: 2.06 swhile: 3.90 sloop do: 0.77 s
- MRuby 3.3
- Fibonacci: 28.83 s
array#each: 144.65 srange#each: 126.40 stimes: 128.22 s
- Artichoke
- Fibonacci: 19.71 s
array#each: 236.10 srange#each: 214.55 stimes: 214.51 s
- TruffleRuby 24.1
Experimento de monkey patching de Range#each en Ruby
- Al hacer monkey patch de
Range#eachcon una implementación Ruby simple, el rendimiento mejora mucho - La implementación es una forma simple que usa
begin,end,loop,yieldei.succ - Las mediciones fueron las siguientes
- Implementación C de
Range#each: 25.57 s - Implementación Ruby de
Range#each: 16.64 s
- Implementación C de
- Esta implementación es una versión demasiado simplificada que no maneja todos los casos de
Range - Aun así, al moverlo de C a Ruby, YJIT puede optimizarlo, haciendo posibles mejoras de rendimiento que serían difíciles o imposibles de reproducir con código C general
Biblioteca estándar de YJIT y with_yjit
- Ruby Outperforms C de Aaron Patterson es un caso en el que una extensión C para parseo de GraphQL fue reescrita en Ruby y, gracias a la optimización de YJIT, el código Ruby fue más rápido que C
- El equipo core de YJIT en CRuby está eliminando código C en algunas funciones core, o aplicando implementaciones Ruby solo cuando YJIT está activado
- El bloque
with_yjitaplica esa implementación Ruby solo cuando YJIT está activo- Si YJIT está desactivado, se sigue ejecutando la implementación C
- Si YJIT está activado, se usa la versión Ruby que YJIT puede optimizar
- Desde Ruby 3.3, YJIT puede inicializarse de forma diferida, y el código
with_yjitaplica la versión de método adecuada en el momento en que YJIT se activa with_yjites un hook de YJIT y, después de ser llamado, se elimina en runtime conundef :with_yjit
Cómo ver el código máquina generado por YJIT
- Si se compila CRuby con la opción
--enable-yjit=dev, se puede ver el desensamblado del código máquina que genera YJIT - Un ejemplo de compilación es el siguiente
./configure --enable-yjit=devmake install
- Al ejecutar, se usa la flag
--yjit-dump-disasm./ruby --yjit --yjit-dump-disasm test.rb 40
- El
i.succdeInteger#timesaparece comoopt_succen el bytecode de la VM - La implementación en Rust de YJIT hace lo siguiente para
opt_succ- Verifica con un guard que el receptor sea Fixnum
- Si no es Fixnum, sale por otra ruta de ejecución
- Si es Fixnum, debido a su representación interna con tags, en realidad suma 2 para sumar 1
- Si ocurre overflow, sale por otra ruta
- Este ejemplo muestra que la optimización JIT opera atravesando varias capas: código Ruby, código C, bytecode de la VM, implementación en Rust y código máquina
Dirección de la optimización de CRuby
- El trabajo de implementación de Ruby se hace mucho en lenguajes de nivel más bajo que Ruby; en CRuby se usan principalmente C y algo de Rust
- Capas como YJIT abren la posibilidad de mover más funcionalidades del lenguaje a código Ruby común
- Si más funcionalidades core se escriben en Ruby, podría volverse más fácil para los desarrolladores Ruby contribuir a CRuby
- Una de las posibles direcciones futuras de CRuby que se menciona es una estructura como la de Java, donde la mayor parte del lenguaje está escrita en sí mismo sobre un core pequeño y de bajo nivel
- En la tendencia actual, en lugar de mantener sin cambios las implementaciones en C, seguirá siendo importante introducir en las rutas core implementaciones Ruby que YJIT pueda optimizar
1 comentarios
Opiniones en Hacker News
El ejemplo del bucle parece un benchmark raro que ejecuta bucles anidados mil millones de veces, y si se optimizara a mano, más del 99% del tiempo de ejecución probablemente se concentraría al principio.
Con un análisis de vida útil (liveness analysis) por elemento del arreglo, parece que se podría eliminar todo el bucle externo; me pregunto si hay compiladores que realmente hagan ese tipo de análisis.
Aunque
uno se conozca en tiempo de compilación, el bucle interno también podría convertirse en unas pocas instrucciones, y eso parece una optimización más estándar que algo comoclangpodría hacer pronto.Hay demasiados datos que rastrear, y probablemente solo sea útil en casos como este código incorrecto.
Cuando antes trabajé en un compilador de IA, el análisis de vida útil de elementos individuales de tensores habría sido realmente útil, pero no lo hicimos porque el tiempo de compilación y los requisitos de memoria eran absurdos.
result = ((u * (u - 1)) / 2 * (100000/u)) + (100000%u * (100000%u - 1) / 2) + r)El artículo trata sobre una versión futura de Ruby, y por curiosidad vi que ruby 3.4.0 saldrá esta Navidad, y ruby 3.5.0 parece que saldrá la próxima Navidad.
También me da curiosidad qué efecto tendrá el JIT mínimo de Python en bucles como estos.
Como Python 3.13 debe compilarse con JIT habilitado, sería interesante que alguien corriera el benchmark en un entorno donde ya lo haya compilado: https://drew.silcock.dev/blog/everything-you-need-to-know-ab...
Recuerdo que las mejoras de rendimiento también podían entrar en releases puntuales.
Integer#succse usa bastante no solo por rendimiento, sino también por legibilidad.Por ejemplo, en el método
#bytesde una biblioteca de UUID, lo usé dos veces para mantener mi cabeza en “modo slicing de bits” mientras leía el código.El bucle de 16 iteraciones está expresado como
0xF.succ, y la parte dentro del bucle que divide entre 256 como0xFF.succ: https://github.com/okeeblow/DistorteD/blob/ba48d10/Globe%20G...0xF.succse siente mejor que0x10.Estoy contribuyendo a https://github.com/bddicken/languages, y después de probar el enfoque con Lua también recordé TruffleRuby, que se había mencionado en algún lado, pero al ejecutar
main.rbprácticamente no hubo gran diferencia entre TruffleRuby y Ruby normal.A veces Ruby normal era más rápido que TruffleRuby.
Quisiera revisar después de qué cambio salió el benchmark de velocidad de TruffleRuby del artículo y, si se puede verificar, intentar agregarlo como commit al repositorio principal.
Si la implementación de TruffleRuby realmente es más rápida que Node.js y está cerca de Bun o Go, sería bastante sorprendente.
Para más detalles, ver https://github.com/oracle/truffleruby.
Ruby realmente se volvió rápido, y TruffleRuby en particular es todavía más impresionante.
Es una lástima porque esa combinación probablemente tendría el mayor impacto en el rendimiento de Ruby.
No sabía que YJIT estaba escrito en Rust; es realmente interesante.
La desventaja es que puede no activarse al compilar si no se cuenta con el toolchain o la plataforma adecuados, pero parece un compromiso razonable.
También hay un repositorio de comparación de lenguajes que lleva más tiempo funcionando y cubre más lenguajes: https://github.com/niklas-heer/speed-comparison
Las etiquetas de los ejes del gráfico y las etiquetas de las barras se superponen, y tampoco hay líneas de cuadrícula verticales.
Preferiría una tabla HTML sencilla.
Es interesante que Python haya sido el lenguaje más lento en el benchmark y aun así sea el lenguaje más usado en GitHub a octubre de 2024.
Parece haber una correlación de que cuanto más lento es un lenguaje, más popular es.
Aunque me gusta Rust bastante a fondo, todos los lenguajes y runtimes, intérpretes y compiladores son herramientas.
Según el problema y el enfoque de solución, se necesita un buen conjunto de herramientas, y si un programa quizá solo se ejecute unas pocas veces, la baja velocidad de ejecución no importa mucho.
Eso pasa mucho con programas en Python, R y similares.
En general Ruby es lento, pero es realmente agradable para programar, así que resulta más atractivo para principiantes.
Mis soluciones de Advent of Code se ven sorprendentemente parecidas, así que esto parece un cambio que podría alterar el panorama.