2 pontos por GN⁺ 2024-12-06 | 1 comentários | Compartilhar no WhatsApp
  • Em pequenos benchmarks de linguagem, o CRuby ficou em terceiro de baixo para cima, mas isso mostrou que o gargalo não estava na linguagem como um todo, e sim em implementações de iteração que o YJIT não consegue inspecionar
  • No Ruby 3.3.6, em um MacBook Pro com M3, Fibonacci levou 12,17 s e Loops 28,80 s; no node.js, ambos ficaram na faixa de 1 segundo. Em um MacBook Air com M2, o Ruby foi ainda mais lento, com 16,33 s e 33,43 s, respectivamente
  • Só de ativar ruby --yjit, o Fibonacci cai fortemente de 16,88 s para 2,06 s, mas Loops cai apenas de 33,43 s para 25,57 s, deixando Range#each como gargalo
  • Métodos cuja implementação foi movida de C para Ruby, como Integer#times no Ruby 3.3 e Array#each no Ruby 3.4, passam a ser alvos de otimização do YJIT e melhoram o benchmark de repetição para a faixa de 13 a 14 segundos
  • O CRuby pode usar a implementação em Ruby quando o YJIT está ativo por meio do hook with_yjit, e manter a implementação em C quando ele está desativado, criando um movimento de migrar a biblioteca principal para código Ruby amigável ao YJIT

A posição do Ruby revelada pelos benchmarks

  • O repositório de comparação de linguagens, compartilhado recentemente, é um repositório colaborativo para criar pequenos benchmarks de várias linguagens
  • Nesse repositório, o resultado do CRuby ficou em terceiro de baixo para cima, com apenas R e Python mais lentos que Ruby
  • O benchmark observa dois eixos
    • Loops: destaca desempenho de laços, condicionais e operações matemáticas básicas
    • Fibonacci: expõe o overhead de chamadas de função e o custo da recursão
  • O exemplo de Loops executa um total de 10.000 × 100.000 iterações em laços aninhados, ou seja, 1 bilhão de iterações
  • O exemplo de Fibonacci não usa uma implementação otimizada, e sim um naive Fibonacci deliberadamente simples

Valores iniciais e efeito do YJIT

  • Em um MacBook Pro com M3, o Ruby 3.3.6 ficou mais ou menos assim
    • Fibonacci: 12,17 s
    • Loops: 28,80 s
    • node.js: pouco mais de 1 segundo em ambos os exemplos
  • Em um MacBook Air com M2, o mesmo benchmark foi mais lento
    • Ruby Fibonacci: 16,33 s
    • Ruby Loops: 33,43 s
    • node.js Fibonacci: 1,36 s
    • node.js Loops: 2,07 s
  • O comando de execução do repositório original era ruby ./code.rb 40, sem YJIT
  • Ao executar com ruby --yjit ./code.rb 40, o resultado muda
    • Fibonacci: 2,06 s
    • Loops: 25,57 s
  • O YJIT teve grande efeito em Fibonacci, mas a melhora em Loops foi limitada

Por que Range#each é desfavorável ao YJIT

  • O laço central do código de Loops usa Range#each na forma (0...10_000).each e (0...100_000).each
  • No Ruby 3.4, Range#each ainda é escrito em C
  • Em range.c, o CRuby conecta a classe Range e o método each à função C range_each
  • range_each faz ramificações por vários caminhos para lidar com diferentes formas de intervalo
    • (0...).each
    • (0...100).each
    • ("a"..."z").each
  • A função C em si pode ser rápida, mas o YJIT não consegue enxergar dentro dela
    • A otimização para no ponto da chamada da função e só continua depois que a função C retorna
    • O YJIT consegue criar otimizações especializadas para caminhos de execução quentes, e a implementação em C limita essa vantagem

O que muda ao trocar para Integer#times

  • No Ruby 3.3, Integer#times foi convertido de função C para método Ruby
  • A estrutura principal é um laço Ruby simples composto por while i < self, yield i e i = i.succ
  • Por estar escrito em Ruby, o YJIT consegue analisar e otimizar seu interior
  • Ao trocar a iteração com Range por 10_000.times e 100_000.times, o tempo de Loops cai bastante
    • Range#each: 25,57 s
    • Integer#times: 13,66 s
  • Em outro ambiente de medição, Integer#times caiu para 9 segundos, e no Ruby 3.4 houve resultado de 8 segundos

Integer#succ e otimização no nível da VM

  • A implementação de Integer#times usa i.succ em vez de i += 1 para incrementar
  • Integer#succ é o método que retorna o próximo valor inteiro
  • No bytecode da VM do Ruby, i.succ aparece como uma única etapa opt_succ
  • i += 1 é dividido em duas etapas
    • putobject_INT2FIX_1_: coloca o inteiro 1 na pilha da VM
    • opt_plus: executa a operação +
  • Em programas Ruby comuns, quase nunca é preciso se preocupar com isso, mas no nível de JIT e VM, uma etapa a menos faz diferença quando algo se repete milhões ou bilhões de vezes

Array#each também foi movido para Ruby no Ruby 3.4

  • No Ruby 3.4, Array#each também passou de C para uma implementação do lado Ruby
  • A primeira tentativa era um código Ruby simples, mas havia uma race condition relacionada ao interior do CRuby
  • A implementação final usa Primitive dentro do código Ruby
    • Primitive.attr! :inline_block, :c_trace
    • Primitive.cexpr!
    • ary_fetch_next
  • Não é Ruby puro completo, e sim uma forma mista de avaliação de código C com estrutura Ruby, mas o YJIT ainda consegue aplicar otimizações consideráveis
  • Se o array for criado previamente e iterado com Array#each, o desempenho fica parecido com Integer#times
    • Range#each: 25,57 s
    • Integer#times: 13,66 s
    • Array#each: 13,96 s

Medições do Ruby Microbench

  • O repositório separado ruby_microbench compara o exemplo original com várias formas de iteração em Ruby
  • No Ruby 3.4 com YJIT ativado, os resultados foram os seguintes
    • 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
  • No Ruby 3.4 com YJIT desativado, a maioria ficou mais lenta
    • 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
  • O exemplo com while foi mais lento do que o esperado, e isso pode ser um problema da forma como foi implementado
  • for in e array#each são quase idênticos no nível de bytecode da VM do Ruby, então o desempenho também é parecido
    • for in é basicamente açúcar sintático que, em geral, é transformado pela VM em uma chamada a #each

Comparação com outras implementações de Ruby

  • O mesmo benchmark também foi executado em várias implementações de Ruby
  • Alguns resultados foram os seguintes
    • 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 com monkey patch em Range#each em Ruby

  • Ao aplicar monkey patch em Range#each com uma implementação Ruby simples, o desempenho melhora bastante
  • A implementação usa uma forma simples com begin, end, loop, yield e i.succ
  • Os resultados medidos foram os seguintes
    • Implementação em C de Range#each: 25,57 s
    • Implementação em Ruby de Range#each: 16,64 s
  • Essa implementação não trata todos os casos de Range e é uma versão simplificada em excesso
  • Ainda assim, ao mover de C para Ruby, o YJIT consegue otimizar e possibilita ganhos de desempenho difíceis ou impossíveis de reproduzir com código C comum

Biblioteca padrão do YJIT e with_yjit

  • Em Ruby Outperforms C, de Aaron Patterson, uma extensão C para parsing de GraphQL foi reescrita em Ruby, e o código Ruby ficou mais rápido que o C graças às otimizações do YJIT
  • A equipe principal do YJIT no CRuby está aplicando uma abordagem de remover código C de algumas funções centrais, ou usar a implementação em Ruby apenas quando o YJIT estiver ativado
  • O bloco with_yjit aplica a implementação Ruby correspondente apenas quando o YJIT está ativo
    • Se o YJIT estiver desativado, a implementação em C continua em uso
    • Se o YJIT estiver ativado, passa a ser usada a versão Ruby que o YJIT consegue otimizar
  • Desde o Ruby 3.3, o YJIT pode ser inicializado de forma tardia, e o código em with_yjit aplica a versão apropriada do método no momento em que o YJIT é ativado
  • with_yjit é um hook do YJIT e, depois de chamado, é removido em tempo de execução com undef :with_yjit

Como ver o código de máquina gerado pelo YJIT

  • Ao compilar o CRuby com a opção --enable-yjit=dev, é possível ver a desmontagem do código de máquina gerado pelo YJIT
  • Um exemplo de build é o seguinte
    • ./configure --enable-yjit=dev
    • make install
  • Na execução, usa-se a flag --yjit-dump-disasm
    • ./ruby --yjit --yjit-dump-disasm test.rb 40
  • Em Integer#times, i.succ aparece como opt_succ no bytecode da VM
  • A implementação em Rust do YJIT faz o seguinte para opt_succ
    • Coloca uma guarda para verificar se o receptor é um Fixnum
    • Se não for Fixnum, segue por outro caminho de execução
    • Se for Fixnum, por causa da representação interna com tag, na prática soma 2 para adicionar 1
    • Se ocorrer overflow, segue por outro caminho
  • Esse exemplo mostra que a otimização por JIT funciona atravessando várias camadas, do código Ruby ao código C, ao bytecode da VM, à implementação em Rust e ao código de máquina

A direção da otimização do CRuby

  • O trabalho de implementação do Ruby é feito em grande parte em linguagens de nível mais baixo que Ruby, principalmente C e um pouco de Rust no CRuby
  • Camadas como o YJIT abrem a possibilidade de mover mais recursos da linguagem para código Ruby comum
  • Se mais funcionalidades centrais passarem a ser escritas em Ruby, pode ficar mais fácil para desenvolvedores Ruby contribuírem com o CRuby
  • Assim como no Java, fala-se em um possível futuro do CRuby em que a maior parte da linguagem seja escrita nela mesma, sobre um pequeno núcleo de baixo nível
  • Na direção atual, mais importante do que manter as implementações em C como estão é introduzir implementações em Ruby que o YJIT consiga otimizar nos caminhos críticos

1 comentários

 
GN⁺ 2024-12-06
Opiniões no Hacker News
  • O exemplo do loop parece um benchmark estranho que executa loops aninhados 1 bilhão de vezes, e, se otimizado manualmente, parece que mais de 99% do tempo de execução ficaria concentrado no começo
    Com análise de liveness por elemento do array, parece que seria possível remover todo o loop externo, e fico curioso se existe algum compilador que realmente faça esse tipo de análise
    Mesmo que u não seja conhecido em tempo de compilação, o loop interno também poderia ser transformado em algumas poucas instruções, e isso parece uma otimização mais padrão, que algo como clang talvez faça em breve

    • Compiladores normalmente não fazem análise de liveness para elementos individuais de arrays
      Há dados demais para rastrear, e é provável que isso só seja útil em casos como esse código equivocado
      Antigamente, quando eu trabalhava em um compilador de IA, a análise de liveness de elementos individuais de tensores teria sido realmente útil, mas não fizemos porque o tempo de compilação e os requisitos de memória seriam absurdos
    • Na maioria dos casos, dá para calcular em forma fechada:
      result = ((u * (u - 1)) / 2 * (100000/u)) + (100000%u * (100000%u - 1) / 2) + r)
  • O texto trata de uma versão futura do Ruby e, por curiosidade, vi que o ruby 3.4.0 deve sair neste Natal, e o ruby 3.5.0 deve sair no próximo Natal
    Também fico curioso sobre que impacto o JIT mínimo do Python teria em loops como esse
    No Python 3.13, é preciso compilar com o JIT habilitado, então seria interessante se alguém rodasse o benchmark em um ambiente já compilado assim: https://drew.silcock.dev/blog/everything-you-need-to-know-ab...

    • O Ruby sempre é lançado no Natal, então é uma agenda previsível e simpática
      Pelo que lembro, melhorias de desempenho também podiam entrar em point releases
  • Integer#succ acaba sendo usado com frequência não só por desempenho, mas também por legibilidade
    Por exemplo, no método #bytes de uma biblioteca de UUID, usei duas vezes para manter a cabeça em “modo de fatiamento de bits” ao ler o código
    O loop de 16 iterações é expresso como 0xF.succ, e a parte dentro do loop que divide por 256 como 0xFF.succ: https://github.com/okeeblow/DistorteD/blob/ba48d10/Globe%20G...

    • Fico curioso por que, nesse caso, 0xF.succ parece melhor do que 0x10
  • Estou contribuindo para https://github.com/bddicken/languages e, depois de tentar a abordagem em Lua, também lembrei do TruffleRuby, mencionado em algum lugar, mas ao rodar main.rb praticamente não houve grande diferença entre o TruffleRuby e o Ruby comum
    Às vezes o Ruby comum era mais rápido que o TruffleRuby
    Quero verificar depois de qual mudança saiu o benchmark de velocidade do TruffleRuby no texto e, se eu conseguir validar, gostaria de adicioná-lo como commit no repositório principal
    Se a implementação em TruffleRuby for realmente mais rápida que Node.js e ficar perto de Bun ou Go, isso seria bastante surpreendente

    • No TruffleRuby, é preciso levar em conta o tempo de inicialização e o tempo até atingir o desempenho máximo, e isso varia conforme a configuração do runtime nativo/JVM
      Para mais detalhes, veja https://github.com/oracle/truffleruby
  • Ruby realmente ficou rápido, e o TruffleRuby em especial é ainda mais impressionante

    • Ele é da Oracle: https://github.com/oracle/truffleruby
    • Rails não roda no Truffle e, pelo que entendi, deve continuar difícil por um tempo
      É uma pena, porque essa é a combinação com maior potencial de impactar o desempenho do Ruby
  • Eu não sabia que o YJIT foi escrito em Rust; isso é muito interessante

    • No começo ele foi escrito em C e depois portado para Rust, e parece ter sido uma boa decisão
      A desvantagem é que, se não houver um toolchain ou uma plataforma adequados, ele pode não ser habilitado na build, mas parece um compromisso razoável
  • Também existe um repositório de comparação de linguagens que está há mais tempo em operação e cobre mais linguagens: https://github.com/niklas-heer/speed-comparison

    • Mais um repositório de comparação de linguagens, mas a forma de apresentação é difícil de ler
      Os rótulos dos eixos e das barras do gráfico se sobrepõem, e também não há linhas de grade verticais
      Eu preferiria uma tabela HTML simples
  • É interessante que Python tenha sido a linguagem mais lenta no benchmark e, ainda assim, seja a linguagem mais usada no GitHub em outubro de 2024
    Parece haver uma correlação de que quanto mais lenta a linguagem, mais popular ela é

    • Para comparar de novo, seria preciso incluir também o tempo de compilação e amortizá-lo pelo número esperado de execuções daquela build
      Gosto bastante de Rust, mas todas as linguagens e runtimes, interpretadores e compiladores são ferramentas
      Dependendo do problema e da abordagem de solução, é preciso um bom conjunto de ferramentas, e, se o programa talvez rode só algumas poucas vezes, uma velocidade de execução baixa não importa tanto
      Isso acontece muito com programas em Python, R e similares
    • É parecido com comida: se você coloca açúcar por cima, as pessoas gostam muito mais
      Em geral, Ruby é lento, mas é muito gostoso de programar, então fica mais atraente para iniciantes
    • Linguagens lentas são mais de alto nível, então são mais fáceis de usar
    • Fico curioso se essa correlação se mantém olhando as 20 linguagens mais populares
  • Minhas soluções do Advent of Code são surpreendentemente parecidas, então isso parece uma mudança capaz de virar o jogo