2 puntos por GN⁺ 2024-12-06 | 1 comentarios | Compartir por WhatsApp
  • 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).each y (0...100_000).each
  • En Ruby 3.4, Range#each sigue estando escrito en C
  • range.c de CRuby conecta la clase Range y el método each con la función C range_each
  • range_each ramifica 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#times pasó 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 i e i = i.succ
  • Al estar escrito en Ruby, YJIT puede analizar y optimizar su interior
  • Si se cambian las iteraciones con Range por 10_000.times y 100_000.times, el tiempo de Loops se reduce considerablemente
    • Range#each: 25.57 s
    • Integer#times: 13.66 s
  • En otros entornos de medición, Integer#times bajó 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#times usa i.succ para la operación de incremento, en lugar de i += 1
  • Integer#succ es un método que devuelve el siguiente valor de un entero
  • En el bytecode de la VM de Ruby, i.succ se representa como un solo paso, opt_succ
  • En cambio, i += 1 se divide en dos pasos
    • putobject_INT2FIX_1_: coloca el entero 1 en el stack de la VM
    • opt_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#each tambié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 Primitive dentro del código Ruby
    • Primitive.attr! :inline_block, :c_trace
    • Primitive.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 de Integer#times
    • Range#each: 25.57 s
    • Integer#times: 13.66 s
    • Array#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 s
    • range#each: 26.61 s
    • times: 13.12 s
    • for: 14.91 s
    • while: 37.10 s
    • loop 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 s
    • range#each: 33.88 s
    • times: 33.18 s
    • for: 36.32 s
    • while: 37.14 s
    • loop do: 50.65 s
  • El ejemplo while fue más lento de lo esperado, y podría deberse a la forma de implementación
  • for in y array#each son casi idénticos a nivel de bytecode de la VM de Ruby, por lo que su rendimiento también es similar
    • for in es, 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 s
      • range#each: 0.92 s
      • times: 2.39 s
      • for: 2.06 s
      • while: 3.90 s
      • loop do: 0.77 s
    • MRuby 3.3
      • Fibonacci: 28.83 s
      • array#each: 144.65 s
      • range#each: 126.40 s
      • times: 128.22 s
    • Artichoke
      • Fibonacci: 19.71 s
      • array#each: 236.10 s
      • range#each: 214.55 s
      • times: 214.51 s

Experimento de monkey patching de Range#each en Ruby

  • Al hacer monkey patch de Range#each con una implementación Ruby simple, el rendimiento mejora mucho
  • La implementación es una forma simple que usa begin, end, loop, yield e i.succ
  • Las mediciones fueron las siguientes
    • Implementación C de Range#each: 25.57 s
    • Implementación Ruby de Range#each: 16.64 s
  • 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_yjit aplica 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_yjit aplica la versión de método adecuada en el momento en que YJIT se activa
  • with_yjit es un hook de YJIT y, después de ser llamado, se elimina en runtime con undef :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=dev
    • make install
  • Al ejecutar, se usa la flag --yjit-dump-disasm
    • ./ruby --yjit --yjit-dump-disasm test.rb 40
  • El i.succ de Integer#times aparece como opt_succ en 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

 
GN⁺ 2024-12-06
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 u no 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 como clang podría hacer pronto.

    • Los compiladores normalmente no hacen análisis de vida útil sobre elementos individuales de un arreglo.
      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.
    • En la mayoría de los casos se puede calcular en forma cerrada:
      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...

    • Ruby siempre se lanza en Navidad, así que es un calendario predecible y simpático.
      Recuerdo que las mejoras de rendimiento también podían entrar en releases puntuales.
  • Integer#succ se usa bastante no solo por rendimiento, sino también por legibilidad.
    Por ejemplo, en el método #bytes de 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 como 0xFF.succ: https://github.com/okeeblow/DistorteD/blob/ba48d10/Globe%20G...

    • Me da curiosidad por qué en este caso 0xF.succ se siente mejor que 0x10.
  • 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.rb prá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.

    • En TruffleRuby hay que considerar el tiempo de arranque y el tiempo hasta alcanzar el rendimiento máximo, y eso depende de la configuración del runtime nativo/JVM.
      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.

    • Eso es de Oracle: https://github.com/oracle/truffleruby
    • Rails no funciona en Truffle y, por lo que entiendo, seguirá siendo difícil por un buen tiempo.
      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.

    • Al principio estaba escrito en C y luego fue portado a Rust, y parece que fue una buena decisión.
      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

    • Es otro repositorio de comparación de lenguajes, pero la forma de presentación es difícil de leer.
      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.

    • Para volver a comparar, también habría que incluir el tiempo de compilación y amortizarlo sobre la cantidad esperada de ejecuciones de esa build.
      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.
    • Es parecido a la comida: si le pones azúcar encima, a la gente le gusta mucho más.
      En general Ruby es lento, pero es realmente agradable para programar, así que resulta más atractivo para principiantes.
    • Los lenguajes lentos son de más alto nivel, así que son más fáciles de usar.
    • Me pregunto si esa correlación se mantiene al ver los 20 lenguajes más populares.
  • Mis soluciones de Advent of Code se ven sorprendentemente parecidas, así que esto parece un cambio que podría alterar el panorama.