Optimización de JSON en Ruby, Parte 1
(byroot.github.io)- La gema
jsonincluida por defecto en Ruby mejoró eliminando cuellos de botella mediante profiling, con el objetivo de reducir la presión práctica de tener que cambiarse aojpor velocidad - La meta no era ganarle siempre a
oj, sino ofrecer un procesamiento de JSON suficientemente rápido y predecible sin monkey patching comoOj.mimic_JSONyOj.optimize_rails ojera más rápido en algunos benchmarks, pero generaba una carga para la estabilidad operativa y la compatibilidad de API por ignorar la opciónscript_safe, diferencias en la serialización de Rails y hasta crashes de Ruby- Las optimizaciones principales consisten en eliminar validaciones UTF-8 duplicadas, revisar primero las condiciones más comunes, reducir el costo de configuración del generador, evitar el seguimiento de punteros de encoding y usar una comprobación de escape basada en tablas de búsqueda
- En el benchmark de generación de
twitter.jsonde 467 KiB, cada cambio produjo mejoras de 3%, 8%, 15% y 30%, y la generación de un Hash pequeño se volvió 1.51 veces más rápida solo con la reducción del costo de configuración
Contexto de cómo se aceleró la gema json
- Tras convertirse recientemente en maintainer de la gema
json, el autor se enfocó no solo en corregir bugs antiguos sino también en mejorar el rendimiento, y como resultado terminó siendo el parser y generador JSON más rápido para Ruby en la mayoría de los benchmarks - La mayoría de los parches de rendimiento se parecen menos a una técnica secreta y más a usar profiling para encontrar cuellos de botella y eliminar desperdicios simples
- La motivación central era lograr que
ruby/jsonfuera lo bastante rápido como para que los usuarios no tuvieran que elegir una gema alternativa solo por velocidad
La carga que trajo usar oj como reemplazo
- La diferencia entre
json 2.7.2yojno era tan grande en algunos benchmarks de tamaño más cercano a la realidad- Parsear un documento JSON de 467 KiB con 100 tuits tomaba
1.9msconjson 2.7.2y1.6msconoj - Generar ese mismo documento tomaba
0.8msconjson 2.7.2y0.4msconoj
- Parsear un documento JSON de 467 KiB con 100 tuits tomaba
- En muchos casos de uso, la parte lenta no es la serialización JSON en sí, sino la capa superior que convierte modelos de Active Record en Hashes y Arrays de Ruby
ojse usaba en muchos proyectos, incluido el codebase de Shopify, y su popularidad probablemente se explicaba por la velocidad-
Incompatibilidades de API por monkey patching
Oj.mimic_JSONsuele usarse para aplicar monkey patching a la gemajson, yOj.optimize_railsaActiveSupport::JSONJSON.dump(data, script_safe: true)puede escapar</script>como<\/script>para insertar JSON de forma segura dentro de una etiqueta<script>- Como
ojno conoce la opciónscript_safe, la ignora, así que una gema que era segura por sí sola puede volverse vulnerable a ataques XSS dentro de una aplicación que invoqueOj.mimic_JSON Oj.optimize_railstambién puede introducir diferencias sutiles en la serialización de objetos- Con
ActiveSupport::JSON::Encoding.time_precision = 0,ActiveSupport::JSON.encode(t)puede producir una cadena con precisión de segundos - Después de
Oj.optimize_railsyOj.mimic_JSON, hay casos donde se emite una cadena que incluye milisegundos - Este caso es un corner case causado por el orden de carga, pero en el pasado hubo todavía más cambios de comportamiento
-
Problemas de estabilidad en producción
- En entornos grandes,
ojera una de las causas más notorias de crashes de Ruby, solo detrás degrpc - Escribir una gema nativa requiere entender la VM de Ruby y en especial el GC; de lo contrario pueden aparecer crashes o corrupción de memoria
- El codebase de
ojtenía hacks que dificultaban confiar en él, e incluso llegó a desactivar el GC en ciertas situaciones para evitar bugs - Al volver a activar el GC, eso puede disparar un major GC cycle
- Ese tipo de código puede favorecer los microbenchmarks, pero empeorar el rendimiento real en producción
- Por esta experiencia, Shopify eliminó
Ojde su monolito, y durante el proceso confirmó diferencias sutiles entreOj.mimic_JSONy la gemajsonreal
- En entornos grandes,
Encontrar cuellos de botella con benchmarks y profiling
- El objetivo era que
ruby/jsonse comportara de forma similar aojtanto en uso real como en microbenchmarks, reduciendo así el atractivo de usarOj.mimic_JSONsolo por velocidad - El primer paso fue armar un suite de benchmarks
- Incluye tanto microbenchmarks como benchmarks más cercanos a escenarios reales
- Se tomó como base el suite de benchmarks de la gema rapidjson-ruby de John Hawthorn y se agregaron algunos casos adicionales
- Como profiler de C se usó samply
- Su ventaja es que genera reportes compatibles con Firefox Profiler, lo que facilita compartirlos
Eliminar validaciones UTF-8 duplicadas
- Al perfilar
JSON.dumpcon el payloadtwitter.json, el9%del tiempo se iba enisLegalUTF8del propio JSON y1.9%enrb_enc_str_asciionly_p - Ruby String tiene una propiedad interna llamada
coderange, que cachea el estado de encoding o si la cadena es solo ASCII después de escanearla una vezENC_CODERANGE_UNKNOWN: todavía no escaneadaENC_CODERANGE_VALID: encoding válidoENC_CODERANGE_7BIT: encoding válido y solo caracteres ASCIIENC_CODERANGE_INVALID: encoding inválido
- La función previa
convert_UTF8_to_JSON_ASCIIllamaba primero arb_enc_str_asciionly_py luego volvía a escanear manualmente la cadena para verificar la validez UTF-8, haciendo trabajo duplicado - Tras el cambio, la validez UTF-8 se determina comparando el
coderangeya calculado- En Ruby, esto equivale a una estructura que, si
string.ascii_only?es falso, lanzaJSON::GeneratorErrorcuandostring.encoding != Encoding::UTF_8o!string.valid_encoding? - Tanto
#ascii_only?como#valid_encoding?aprovechan elcoderangecacheado, así que la cadena se escanea como máximo una vez
- En Ruby, esto equivale a una estructura que, si
- A diferencia del 9% esperado, la mejora real fue de alrededor de 3%
- Gran parte del tiempo que antes aparecía en
isLegalUTF8se trasladó aconvert_UTF8_to_JSON - La razón no está clara, pero es posible que una porción importante de ese 9% fuera el costo de traer bytes de la cadena desde RAM al cache de CPU
- El benchmark de generación de
twitter.jsonpasó de1077.3 i/sa1113.3 i/s, una mejora de1.03x
- Gran parte del tiempo que antes aparecía en
Revisar primero las condiciones más baratas y probables
fbuffer_inc_capaaparecía con5.7%del runtime total, y la mayor parte del tiempo se gastaba verificando si el buffer ya estaba asignado- Esta función se llama cada vez que se escribe algo en el buffer, pero después de la primera llamada el buffer ya siempre está asignado
- La estructura anterior revisaba primero una condición que casi nunca se cumplía, generando desperdicio, y además si el buffer aún no estaba asignado entonces
fb->capaera0, así que también había cierta duplicación con la comprobaciónrequired > fb->capa - Después de la corrección, primero se revisa el caso más común, “la capacidad del buffer es suficiente”, y se usan
RB_LIKELYyRB_UNLIKELYpara darle pistas de branch prediction al CPU - La función también se marcó como
inline, reduciendo el costo de llamada, y en la mayoría de los casos el trabajo necesario se reduce básicamente a una resta y una comparación - Este cambio llevó el benchmark de generación de
twitter.jsonde1068.6 i/sa1224.7 i/s, es decir, 1.15 veces más rápido - El mismo principio también puede aplicarse a código Ruby: revisar primero la condición más barata y con mayor probabilidad de cumplirse
Reducir el costo de configuración del generador JSON
- El committer de Ruby Yusuke Endoh aka Mame también participó en la optimización de
ruby/json, y existía un PR antiguo con varias optimizaciones - Muchos cambios se enfocaban en reducir el costo de configuración necesario antes de generar JSON
- parseo de argumentos
- asignación del generador y de estructuras relacionadas
- trabajo de preparación antes de iniciar la generación
- En
ruby/json, ese costo de configuración era mayor que en implementaciones alternativas, lo que hacía que se viera peor en microbenchmarks JSON.generatepuede recibir opciones comoarray_nl,object_nl,indentyspacepara generar JSON con formato pretty- Antes se precalculaban buffers de separadores a partir de las cadenas proporcionadas
- Por ejemplo:
",#{opts[:array_nl]}",",#{opts[:object_nl]}",":#{opts[:space]}" - La intención era hacer append de un fragmento más largo de una vez, pero el ahorro real era pequeño
- En la mayoría de los casos esas opciones no se usan, por lo que el costo del precálculo pesaba más
- Por ejemplo:
- Mame prácticamente revirtió esa optimización, reduciendo mucho el costo de configuración
- En benchmarks grandes la diferencia no fue importante
- El benchmark de generar un Hash pequeño de 65 bytes pasó de
2,112,189.3 i/sa3,199,311.0 i/s, o sea 1.51 veces más rápido
Evitar el seguimiento de punteros y comparar índices de encoding
- Otra optimización de Mame fue eliminar una llamada a
rb_enc_get - JSON necesita verificar con frecuencia si una cadena es compatible con UTF-8, y antes se obtenía un
rb_encoding *conrb_enc_get(obj)para luego compararlo con US-ASCII o UTF-8 rb_enc_getes una API de alto nivel y defensiva, así que realiza varias validaciones de tipo- Puede manejar distintos tipos de objeto como String, Symbol, Regexp, File y Data
- Tiene muchas ramas condicionales, y si falla la branch prediction del CPU el costo puede crecer
- Conceptualmente Ruby String tiene una referencia a su encoding, pero en la práctica guarda un índice de encoding de 7 bits más pequeño dentro de un bitmap interno de cada String, en lugar de un puntero de 64 bits
- Para obtener el puntero completo del objeto encoding, hay que buscar el encoding real a través de un arreglo global interno de la VM, lo que en código low-level equivale a seguir punteros
- Si eso ya está en el cache de CPU es rápido, pero si hay que traerlo desde RAM el CPU debe esperar
- Como
jsonya sabe que el objeto es un String y solo necesita saber si es ASCII o UTF-8, puede comparar directamente el índice de encoding usandoRB_ENCODING_GET - Este cambio elevó el benchmark de generación de
twitter.jsonde1159.6 i/sa1253.3 i/s, una mejora de 1.08x
Acelerar el escape de cadenas con tablas de búsqueda
- Al hacer dump de cadenas JSON, hay que revisar si cada carácter puede copiarse tal cual o necesita escape, por lo que este paso es costoso
- Un enfoque ingenuo revisa varias condiciones por cada carácter
- si es un carácter de control ASCII
- si es
\n,\r,\t,\fo\b - si es
"o\
- El enfoque con tabla de búsqueda precalcula esa decisión en un arreglo estático, y por cada carácter en vez de múltiples comparaciones solo lee un boolean en un offset dinámico
- A cambio de usar un poco más de memoria estática, el loop se vuelve mucho más rápido
- Asumiendo que la mayoría de las cadenas no contienen caracteres que necesiten escape, Mame agregó primero una precondición barata para verificar si aplica el fast path, y si aplica entonces copia toda la cadena al buffer de una sola vez
- El patch de Mame es más complejo por estar en C, pero sigue el mismo patrón
- Solo este cambio llevó el benchmark de generación de
twitter.jsonde1258.1 i/sa1630.2 i/s, o sea 1.30 veces más rápido
Optimizaciones que continúan
- Aún quedan más optimizaciones por cubrir, y se adelanta una publicación de seguimiento
- Después se publicó la parte dos
1 comentarios
Opiniones en Hacker News
Me encanta el trabajo de byroot. No solo por el tipo de contribuciones, sino porque la escala de su productividad siempre me sorprende
Intenté meterme algunas veces en el trabajo del core de Ruby, pero no encontré algo que estuviera a mi nivel y donde pudiera aportar de forma positiva; si pasan semanas sin resultados, se pierde la motivación. Es muy difícil tener el contexto como el que comparte en el artículo
Si la gente de Ruby C escribiera más seguido, creo que habría más personas con las capacidades necesarias para mejorar Ruby. También estuvo bueno el consejo sobre profilers en C, y me hizo pensar que podría empezar tomando una gem de Ruby con código C y volver a trabajar en su optimización
Aunque trata sobre extensiones en C, ayuda a entender varios conceptos
La parte 2 también ya está publicada: https://byroot.github.io/ruby/json/2024/12/18/optimizing-rub...
Algo que vale la pena mencionar es jbuilder, que es el uso por defecto en Rails. Aunque jbuilder en sí no es la serialización JSON, si hablamos de qué hace lento el renderizado de JSON en Ruby/Rails, estaría al principio de mi lista
Renderizar muchos partials con jbuilder se vuelve realmente lento
Los artículos sobre este tema son fáciles de seguir y me dan ganas de benchmarkear y optimizar mi propio código Ruby. Tanto el artículo como el trabajo estuvieron muy buenos
Puede que se me haya pasado, pero ¿en algún lado dice cuánto tarda la nueva versión, con todas las optimizaciones aplicadas, en parsear/codificar el dump JSON de Twitter?
https://github.com/ruby/json/releases/tag/v2.7.3
https://github.com/ruby/json/releases/tag/v2.8.0
Excelente artículo y buen trabajo. ¿Todavía hay alguna razón para usar Oj en adelante?
Oj tiene una API muy grande que la gem
jsonestándar no tiene intención de imitar. Por ejemplo, “SAJ” (parsing estilo SAX), varios modos de escape, etc.Mi objetivo es solamente hacer que Oj no sea necesario para aproximadamente el 95% de los casos de uso, así que Oj seguirá siendo útil para varias cosas
Después de este artículo, me da curiosidad saber cuánto más rápido quedó comparado con esta implementación que ahora ya no se mantiene
https://netflixtechblog.com/fast-json-api-serialization-with...
Me parecía bastante limpia esta implementación en Ruby puro, pero nunca la usé en producción real. Quedó abandonada hace mucho
En general también me da curiosidad el estado de las implementaciones en Ruby puro. Parece que eliminaron
json_pure, lo cual sería una lástima. ¿Alguien sabe más detalles? La parte más interesante del artículo para mí no es tanto la optimización en C, sino la optimización en RubyFue una lectura entretenida. Aunque, para optimizaciones que no son específicas de Ruby, como una tabla de consulta para caracteres de escape, me pregunto por qué no aprovechar bibliotecas existentes como simdjson, que ya hacen ese tipo de cosas
En resumen,
ruby/jsonse distribuye junto con Ruby, así que debe ser compatible con las restricciones de Ruby, y hoy eso significa C99 puro y nada de C++. La licencia Apache 2 de simdjson también podría ser un problema, aunque no estoy seguroEn general, me encantaría usar buenas bibliotecas en C++ como dragonbox, pero no puedo
Además, la última vez que revisé, simdjson solo ofrecía un parser. La gem ruby/json hace tanto parsing como encoding, así que solo ayudaría con la mitad del problema
En los proyectos modernos no se hace suficiente este tipo de trabajo. Si se hiciera con más regularidad, me pregunto si bibliotecas como simdjson u oj habrían sido necesarias en primer lugar. Este dominio de problema no es tan difícil
¿Ruby JSON usa intrinsics? ¿Puede usarlos?
¿Y cómo encaja eso con los distintos JIT?
La gem
jsonestá implementada en C, así que para YJIT, es decir, el JIT de la implementación de referencia, es una caja negraEl JIT de TruffleRuby antes podía interpretar extensiones en C con sulong y hacer JIT cruzando la frontera entre lenguajes, pero según entiendo recientemente dejaron de usar ese enfoque por varios problemas de compatibilidad
Además, en TruffleRuby el parser JSON está implementado en C, pero el encoder está en Ruby puro: https://github.com/ruby/json/blob/e1f6456499d497f33f69ae4c1a...
Si mal no recuerdo, las pistas de predicción de ramas no sirven en CPU modernas
“A partir de la microarquitectura Redwood Cove, si el predictor no tiene información almacenada sobre una rama y esa rama tiene una pista Intel SSE2 branch taken, es decir, el prefijo de instrucción 3EH, cuando el decodificador decodifica la rama, cambia la predicción de rama de not-taken a taken. Luego vacía el front-end del pipeline y guía el pipeline para traer la ruta taken
...
Esta pista solo se usa cuando el predictor no tiene información almacenada sobre esa rama. Para evitar el crecimiento del código y la reducción del ancho de banda de fetch de instrucciones, no se deben agregar pistas a ramas en hot code, como ramas dentro de bucles con muchas iteraciones, porque es muy probable que el predictor ya tenga información almacenada sobre esa rama. Idealmente, solo se deberían agregar pistas a ramas que se ejecutan rara vez pero que casi siempre son taken, aunque identificar esas ramas puede ser difícil. Se recomienda que el compilador agregue pistas como parte de la optimización basada en perfiles cuando no pueda colocar una de las rutas de ejecución como fall-through. La microarquitectura Redwood Cove introduce nuevos eventos de monitoreo de rendimiento para orientar la ubicación de las pistas”