Otimização de JSON no Ruby, Parte 1
(byroot.github.io)- A gem
jsonpadrão do Ruby foi melhorada na direção de reduzir a pressão prática de ter que trocar paraojpor causa de velocidade, removendo gargalos com profiling - O objetivo não é vencer
oja qualquer custo, mas oferecer processamento de JSON rápido e previsível o suficiente mesmo sem monkey patching comoOj.mimic_JSONeOj.optimize_rails ojera mais rápido em alguns benchmarks, mas gerava custos de estabilidade operacional e compatibilidade de API por ignorar a opçãoscript_safe, diferenças na serialização do Rails e crashes do Ruby- As principais otimizações incluem remoção de verificações UTF-8 duplicadas, verificação primeiro das condições mais comuns, redução do custo de configuração do generator, evitar rastreamento de ponteiros de encoding e checagem de escape baseada em tabela de consulta
- No benchmark de geração com
twitter.jsonde 467KiB, as mudanças renderam melhorias de 3%, 8%, 15% e 30% por etapa, e a geração de pequenos Hashes ficou 1,51x mais rápida só com a redução do custo de configuração
O contexto por trás de acelerar a gem json
- Depois de se tornar maintainer da gem
jsonrecentemente, o autor passou a focar também em melhorias de desempenho enquanto corrigia bugs antigos, e o resultado foi torná-la o parser e gerador de JSON mais rápido para Ruby na maioria dos benchmarks - A maior parte dos patches de performance ficou mais próxima de encontrar gargalos com profiling e cortar desperdícios simples do que de algum truque especial
- A motivação central era fazer
ruby/jsonficar rápido o suficiente para que usuários não precisassem escolher uma gem alternativa apenas por velocidade
O peso de usar oj como substituto
- A diferença entre
json 2.7.2eojnão era grande em alguns benchmarks mais próximos de tamanhos reais- O parsing de um documento JSON de 467KiB com 100 tweets levava
1.9msnojson 2.7.2e1.6msnooj - A geração do mesmo documento levava
0.8msnojson 2.7.2e0.4msnooj
- O parsing de um documento JSON de 467KiB com 100 tweets levava
- Em muitos casos de uso, a parte lenta não é a serialização JSON em si, mas a camada superior que converte modelos do Active Record em Hashes e Arrays Ruby
ojera usado em muitos projetos, inclusive na base de código da Shopify, e essa popularidade provavelmente vinha da velocidade-
Incompatibilidade de API causada por monkey patching
Oj.mimic_JSONé usado com frequência para aplicar monkey patch na gemjson, eOj.optimize_railspara aplicar monkey patch emActiveSupport::JSONJSON.dump(data, script_safe: true)pode escapar</script>para<\\/script>para permitir inserir JSON com segurança dentro de uma tag<script>- Como
ojnão conhece a opçãoscript_safee a ignora, uma gem que seria segura sozinha pode passar a permitir ataques XSS dentro de uma aplicação que chamouOj.mimic_JSON Oj.optimize_railstambém pode introduzir diferenças sutis na serialização de objetos- Com
ActiveSupport::JSON::Encoding.time_precision = 0,ActiveSupport::JSON.encode(t)pode gerar uma string com precisão de segundos - Depois de
Oj.optimize_railseOj.mimic_JSON, há casos em que a saída inclui uma string com milissegundos - Esse exemplo é um corner case causado pela ordem de carregamento, mas no passado houve mudanças de comportamento ainda maiores
-
Problemas de estabilidade em produção
- Em ambientes de grande escala,
ojera uma das causas mais visíveis de crashes do Ruby, ficando atrás apenas degrpc - Escrever gems nativas exige entender a VM do Ruby e especialmente o GC; sem isso, podem surgir crashes ou corrupção de memória
- A base de código do
ojtinha hacks que dificultavam a confiança, e em certo momento ele chegou a desativar o GC em algumas situações para contornar bugs - Ao reativar o GC, isso pode disparar um major GC cycle
- Esse tipo de código pode favorecer microbenchmarks, mas piorar o desempenho real em produção
- Por causa dessa experiência, a Shopify removeu
Ojdo monólito, e nesse processo confirmou diferenças sutis entreOj.mimic_JSONe ojsonreal
- Em ambientes de grande escala,
Encontrando gargalos com benchmark e profiling
- O objetivo era fazer
ruby/jsonse comportar de forma parecida comojtanto em uso real quanto em microbenchmarks, reduzindo o apelo de usarOj.mimic_JSONpor motivos de velocidade - O primeiro passo foi montar uma suite de benchmarks
- Ela inclui microbenchmarks e benchmarks mais realistas
- Foi baseada na suite de benchmarks da gem rapidjson-ruby de John Hawthorn com alguns acréscimos
- Como profiler de C, foi usado o samply
- A vantagem é gerar relatórios compatíveis com o Firefox Profiler, fáceis de compartilhar
Removendo verificações UTF-8 duplicadas
- Ao perfilar
JSON.dumpcom o payloadtwitter.json,9%do tempo ia paraisLegalUTF8do próprio JSON e1.9%pararb_enc_str_asciionly_p - Uma String do Ruby tem uma propriedade interna chamada
coderange, que faz cache do estado de encoding da string ou do fato de ela conter apenas ASCII após um primeiro scanENC_CODERANGE_UNKNOWN: ainda não foi escaneadaENC_CODERANGE_VALID: o encoding é válidoENC_CODERANGE_7BIT: o encoding é válido e contém apenas caracteres ASCIIENC_CODERANGE_INVALID: o encoding é inválido
- A implementação antiga de
convert_UTF8_to_JSON_ASCIIchamavarb_enc_str_asciionly_pno começo e depois fazia outro scan manual da string para verificar a validade UTF-8, gerando trabalho duplicado - Depois da mudança, a validade UTF-8 passou a ser decidida comparando o
coderangejá calculado- Em Ruby, a lógica equivale a levantar
JSON::GeneratorErrorquandostring.ascii_only?for falso estring.encoding != Encoding::UTF_8ou!string.valid_encoding? - Tanto
#ascii_only?quanto#valid_encoding?usam ocoderangeem cache, então o scan da string acontece no máximo uma vez
- Em Ruby, a lógica equivale a levantar
- Em vez dos 9% esperados, a melhora real ficou em cerca de 3%
- Uma parte relevante do tempo antes gasto em
isLegalUTF8migrou paraconvert_UTF8_to_JSON - A razão não é totalmente clara, mas é possível que boa parte daqueles 9% fosse custo de trazer os bytes da string da RAM para o cache da CPU
- No benchmark de geração de
twitter.json, o desempenho passou de1077.3 i/spara1113.3 i/s, ficando1.03xmais rápido
- Uma parte relevante do tempo antes gasto em
Verificar primeiro as condições mais baratas e mais prováveis
fbuffer_inc_capaapareceu com5.7%do tempo total de execução, e a maior parte era gasta verificando se o buffer já tinha sido alocado- Essa função é chamada toda vez que algo é escrito no buffer, mas depois da primeira chamada o buffer praticamente sempre já está alocado
- A estrutura anterior verificava primeiro uma condição que quase nunca se confirmava, desperdiçando trabalho, e ainda havia alguma redundância com a checagem
required > fb->capa, já quefb->capaé0quando o buffer ainda não foi alocado - Após a correção, o código passou a verificar primeiro o caso mais comum, “a capacidade do buffer é suficiente”, e usa
RB_LIKELYeRB_UNLIKELYpara dar dicas de branch prediction à CPU - A função foi marcada como
inline, reduzindo o custo de chamada, e na maioria dos casos o trabalho necessário caiu para algo como subtração e comparação - Essa mudança elevou o benchmark de geração de
twitter.jsonde1068.6 i/spara1224.7 i/s, deixando-o 1.15x mais rápido - O mesmo princípio também pode ser aplicado em código Ruby: verificar primeiro as condições mais baratas e mais prováveis
Reduzindo o custo de configuração do gerador JSON
- O committer do Ruby Yusuke Endoh aka Mame também participou da otimização de
ruby/json, e já existia um PR antigo com várias otimizações - Muitas mudanças focavam em reduzir o custo de configuração necessário antes da geração do JSON
- parsing de argumentos
- alocação do generator e de structs relacionadas
- trabalho preparatório antes de iniciar a geração
- Em
ruby/json, esse custo de configuração era maior do que em implementações alternativas, o que fazia mal para os microbenchmarks JSON.generatepode receber opções comoarray_nl,object_nl,indentespacepara gerar JSON formatado- Antes, buffers de separadores eram pré-calculados com as strings fornecidas
- Ex.:
",#{opts[:array_nl]}",",#{opts[:object_nl]}",":#{opts[:space]}" - A intenção era fazer append de um trecho maior de uma vez, mas a economia real de trabalho era pequena
- Como na maioria dos casos essas opções nem eram usadas, o custo da pré-computação acabava pesando mais
- Ex.:
- Mame basicamente desfez essa otimização, reduzindo bastante o custo de configuração
- Em benchmarks grandes, a diferença não foi tão relevante
- No benchmark de geração de um pequeno Hash de 65 bytes, o desempenho passou de
2,112,189.3 i/spara3,199,311.0 i/s, ou seja, 1.51x mais rápido
Evitar rastreamento de ponteiros e comparar índices de encoding
- Outra otimização de Mame foi remover uma chamada a
rb_enc_get - O JSON precisa checar com frequência se uma string é compatível com UTF-8, e antes isso era feito obtendo um
rb_encoding *comrb_enc_get(obj)e depois comparando se era US-ASCII ou UTF-8 rb_enc_geté uma API de alto nível e defensiva, então executa várias verificações de tipo- Ela foi feita para lidar com vários tipos de objeto, como String, Symbol, Regexp, File e Data
- Há muitos condicionais, e erros de branch prediction da CPU podem custar caro
- Conceitualmente, a String do Ruby tem uma referência ao encoding, mas na prática, em vez de um ponteiro de 64 bits, ela guarda um índice de encoding de 7 bits no bitmap interno de cada String
- Para obter o ponteiro do objeto de encoding completo, é preciso buscar o encoding real por meio de um array global interno da VM, o que em código de baixo nível equivale a rastrear ponteiros
- Se já estiver no cache da CPU, é rápido; se precisar vir da RAM, a CPU pode ficar esperando
- Como
jsonjá sabe que o alvo é uma String e só precisa saber se é ASCII ou UTF-8, ele pode comparar diretamente o índice de encoding comRB_ENCODING_GET - Essa mudança aumentou o benchmark de geração de
twitter.jsonde1159.6 i/spara1253.3 i/s, deixando-o 1.08x mais rápido
Acelerando o escape de strings com tabela de consulta
- Fazer dump de strings em JSON é custoso porque é preciso verificar cada caractere para saber se ele pode ser copiado como está ou se precisa de escape
- A abordagem ingênua testa várias condições para cada caractere
- verificar se é um caractere de controle ASCII
- verificar se é
\n,\r,\t,\f,\b - verificar se é
\"ou\\
- A abordagem com tabela de consulta pré-calcula essa decisão em um array estático, e em vez de várias comparações por caractere, faz uma leitura booleana em um offset dinâmico
- Em troca de usar um pouco mais de memória estática, o loop fica muito mais rápido
- Partindo da premissa de que a maioria das strings não contém caracteres que precisam de escape, Mame também adicionou uma pré-condição para verificar de forma barata se o fast path se aplica; se sim, a string inteira é copiada para o buffer de uma vez
- O patch do Mame é mais complexo por ser em C, mas segue o mesmo padrão
- Só essa mudança fez o benchmark de geração de
twitter.jsonsubir de1258.1 i/spara1630.2 i/s, um ganho de 1.30x
Próximas otimizações
- Ainda restam outras otimizações para abordar, então o texto anuncia uma continuação
- Depois foi publicada a parte dois
1 comentários
Comentários do Hacker News
Gosto muito do trabalho do byroot. Não só pelo tipo de contribuição, mas a escala de produtividade dele é sempre impressionante.
Já tentei me envolver algumas vezes com o core do Ruby, mas não encontrei algo compatível com meu nível em que eu pudesse contribuir positivamente, e, depois de algumas semanas sem resultado, a motivação desapareceu. É muito difícil adquirir o tipo de contexto que ele compartilhou no artigo.
Se as pessoas que trabalham com Ruby em C escrevessem com mais frequência, acho que haveria mais gente com as competências necessárias para melhorar ainda mais o Ruby. A dica sobre profilers de C também foi boa, e me fez pensar que eu poderia começar pegando uma gem Ruby com código em C e voltando a mexer na otimização dela.
Embora seja sobre extensões em C, ajuda a entender alguns conceitos.
A parte 2 também já foi publicada: https://byroot.github.io/ruby/json/2024/12/18/optimizing-rub...
Uma coisa que vale mencionar é o jbuilder, que é o jeito padrão de uso no Rails. O jbuilder em si não é a parte de serialização JSON, mas, se eu fosse falar de algo que torna a renderização de JSON lenta em Ruby/Rails, ele ficaria no topo da minha lista.
Renderizar muitos partials com jbuilder fica realmente lento.
Os textos sobre esse tema são fáceis de acompanhar e dão vontade de fazer benchmark e otimizar meu próprio código Ruby. Tanto o artigo quanto o trabalho ficaram ótimos.
Posso ter deixado passar, mas existe algum lugar mostrando quanto tempo a nova versão com todas as otimizações leva para fazer parse/codificar o dump JSON do Twitter?
https://github.com/ruby/json/releases/tag/v2.7.3
https://github.com/ruby/json/releases/tag/v2.8.0
Ótimo artigo e ótimo trabalho. Ainda há algum motivo para usar Oj daqui para frente?
O Oj tem uma API muito grande que a gem
jsonpadrão não tem intenção de imitar. Por exemplo, “SAJ” (parsing no estilo SAX), vários modos de escape etc.Meu objetivo é apenas fazer com que o Oj não seja necessário para algo em torno de 95% dos casos de uso, então ele ainda continuará útil para várias finalidades.
Depois desse artigo, fico curioso para saber quanto mais rápido ficou em comparação com esta implementação que hoje não é mais mantida:
https://netflixtechblog.com/fast-json-api-serialization-with...
Eu achava essa implementação em Ruby puro bem limpa, mas nunca a usei de fato em produção. Ela foi abandonada há bastante tempo.
No geral, também fico curioso sobre o estado das implementações em Ruby puro. Parece que removeram o
json_pure, o que seria uma pena. Alguém sabe mais detalhes? A parte mais interessante do artigo, para mim, é a otimização em Ruby, mais do que a otimização em C.Foi uma leitura interessante. Mas, para otimizações que não são específicas de Ruby, como uma tabela de consulta para caracteres de escape, fico me perguntando por que não aproveitar bibliotecas existentes como simdjson, que já fazem esse tipo de coisa.
Resumindo, o
ruby/jsoné distribuído junto com o Ruby, então precisa ser compatível com as restrições do Ruby, o que hoje significa C99 puro e nada de C++. A licença Apache 2 do simdjson também poderia ser um problema, mas não tenho certeza.No geral, eu adoraria usar ótimas bibliotecas em C++ como dragonbox, mas não posso.
Além disso, da última vez que verifiquei, o simdjson oferecia apenas o parser. A gem ruby/json faz tanto parsing quanto encoding, então ele só ajudaria em metade do domínio do problema.
Esse tipo de trabalho não acontece o suficiente em projetos modernos. Se acontecesse com mais regularidade, fico pensando se bibliotecas como simdjson ou oj teriam sido necessárias para começo de conversa. Esse domínio do problema não é tão difícil assim.
O Ruby JSON usa intrinsics? Pode usar?
E como isso interage com os vários JITs?
A gem
jsoné implementada em C, então, para o YJIT — isto é, o JIT da implementação de referência — ela é uma caixa-preta.O JIT do TruffleRuby antes conseguia interpretar extensões em C com o sulong e fazer JIT atravessando a fronteira entre linguagens, mas, pelo que sei, esse caminho foi abandonado recentemente por causa de vários problemas de compatibilidade.
Além disso, no TruffleRuby, o parser JSON é implementado em C, mas o encoder é Ruby puro: https://github.com/ruby/json/blob/e1f6456499d497f33f69ae4c1a...
Se bem me lembro, dicas de predição de desvio são inúteis em CPUs modernas.
“A partir da microarquitetura Redwood Cove, se o preditor não tiver informações armazenadas sobre determinado desvio e esse desvio tiver uma Intel SSE2 branch taken hint, isto é, o prefixo de instrução 3EH, o codec inverte a predição do desvio de not-taken para taken ao decodificá-lo. Em seguida, ele faz flush do front-end do pipeline e conduz o pipeline a buscar o caminho taken.
...
Essa dica só é usada quando o preditor não tem informações armazenadas sobre aquele desvio. Para evitar aumento de código e redução da largura de banda de busca de instruções, não se deve adicionar dicas a desvios em código quente, como desvios dentro de loops com muitas iterações, porque é provável que o preditor já tenha armazenado informações sobre eles. Idealmente, as dicas deveriam ser adicionadas apenas a desvios raramente executados, mas que na maioria das vezes são taken; porém, identificar esses desvios pode ser difícil. Recomenda-se que o compilador adicione dicas como parte da otimização baseada em perfil quando não for possível posicionar um dos caminhos de execução como fall-through. A microarquitetura Redwood Cove introduz novos eventos de monitoramento de desempenho para orientar o posicionamento das dicas.”