1 pontos por GN⁺ 2024-12-19 | 1 comentários | Compartilhar no WhatsApp
  • A gem json padrão do Ruby foi melhorada na direção de reduzir a pressão prática de ter que trocar para oj por causa de velocidade, removendo gargalos com profiling
  • O objetivo não é vencer oj a qualquer custo, mas oferecer processamento de JSON rápido e previsível o suficiente mesmo sem monkey patching como Oj.mimic_JSON e Oj.optimize_rails
  • oj era mais rápido em alguns benchmarks, mas gerava custos de estabilidade operacional e compatibilidade de API por ignorar a opção script_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.json de 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 json recentemente, 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/json ficar 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.2 e oj nã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.9ms no json 2.7.2 e 1.6ms no oj
    • A geração do mesmo documento levava 0.8ms no json 2.7.2 e 0.4ms no oj
  • 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
  • oj era 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 gem json, e Oj.optimize_rails para aplicar monkey patch em ActiveSupport::JSON
    • JSON.dump(data, script_safe: true) pode escapar </script> para <\\/script> para permitir inserir JSON com segurança dentro de uma tag <script>
    • Como oj não conhece a opção script_safe e a ignora, uma gem que seria segura sozinha pode passar a permitir ataques XSS dentro de uma aplicação que chamou Oj.mimic_JSON
    • Oj.optimize_rails també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_rails e Oj.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, oj era uma das causas mais visíveis de crashes do Ruby, ficando atrás apenas de grpc
    • 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 oj tinha 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 Oj do monólito, e nesse processo confirmou diferenças sutis entre Oj.mimic_JSON e o json real

Encontrando gargalos com benchmark e profiling

  • O objetivo era fazer ruby/json se comportar de forma parecida com oj tanto em uso real quanto em microbenchmarks, reduzindo o apelo de usar Oj.mimic_JSON por motivos de velocidade
  • O primeiro passo foi montar uma suite de benchmarks
  • 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.dump com o payload twitter.json, 9% do tempo ia para isLegalUTF8 do próprio JSON e 1.9% para rb_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 scan
    • ENC_CODERANGE_UNKNOWN: ainda não foi escaneada
    • ENC_CODERANGE_VALID: o encoding é válido
    • ENC_CODERANGE_7BIT: o encoding é válido e contém apenas caracteres ASCII
    • ENC_CODERANGE_INVALID: o encoding é inválido
  • A implementação antiga de convert_UTF8_to_JSON_ASCII chamava rb_enc_str_asciionly_p no 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 coderange já calculado
    • Em Ruby, a lógica equivale a levantar JSON::GeneratorError quando string.ascii_only? for falso e string.encoding != Encoding::UTF_8 ou !string.valid_encoding?
    • Tanto #ascii_only? quanto #valid_encoding? usam o coderange em cache, então o scan da string acontece no máximo uma vez
  • Em vez dos 9% esperados, a melhora real ficou em cerca de 3%
    • Uma parte relevante do tempo antes gasto em isLegalUTF8 migrou para convert_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 de 1077.3 i/s para 1113.3 i/s, ficando 1.03x mais rápido

Verificar primeiro as condições mais baratas e mais prováveis

  • fbuffer_inc_capa apareceu com 5.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á que fb->capa é 0 quando 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_LIKELY e RB_UNLIKELY para 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.json de 1068.6 i/s para 1224.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.generate pode receber opções como array_nl, object_nl, indent e space para 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
  • 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/s para 3,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 * com rb_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 json já sabe que o alvo é uma String e só precisa saber se é ASCII ou UTF-8, ele pode comparar diretamente o índice de encoding com RB_ENCODING_GET
  • Essa mudança aumentou o benchmark de geração de twitter.json de 1159.6 i/s para 1253.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.json subir de 1258.1 i/s para 1630.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

 
GN⁺ 2024-12-19
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.

    • Também há a excelente série do Peter Zhu: https://blog.peterzhu.ca/ruby-c-ext/
      Embora seja sobre extensões em C, ajuda a entender alguns conceitos.
    • “Produtividade enorme” está certo, mas ele também é uma pessoa incrivelmente inteligente. Já trabalhei no mesmo escritório que ele na Shopify, e ele parece estar em um nível inalcançável.
  • 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?

  • Ótimo artigo e ótimo trabalho. Ainda há algum motivo para usar Oj daqui para frente?

    • Sou o autor.
      O Oj tem uma API muito grande que a gem json padrã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.

    • Isso foi respondido em certa medida em https://news.ycombinator.com/item?id=42450085.
      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.
    • O que há de bom neste artigo é que ele trata de trabalho real de engenharia em uma base de código existente. Em vez de tentar substituir tudo ou trocar de biblioteca só para ganhar um pouco de velocidade, ele entra no código real e tenta melhorar de verdade não apenas a velocidade, mas também a eficiência.
      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?

    • Não tenho certeza do que você quer dizer exatamente com intrinsics.
      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.

    • Elas eram inúteis em CPUs modernas, mas voltaram a ser um pouco úteis em algumas CPUs. https://www.phoronix.com/news/GCC-Clang-Intel-x86-Branch-Hin...
      “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.”