- 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).eache(0...100_000).each - No Ruby 3.4,
Range#eachainda é escrito em C - Em
range.c, o CRuby conecta a classeRangee o métodoeachà função Crange_each range_eachfaz 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#timesfoi convertido de função C para método Ruby - A estrutura principal é um laço Ruby simples composto por
while i < self,yield iei = 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.timese100_000.times, o tempo de Loops cai bastanteRange#each: 25,57 sInteger#times: 13,66 s
- Em outro ambiente de medição,
Integer#timescaiu 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#timesusai.succem vez dei += 1para incrementar Integer#succé o método que retorna o próximo valor inteiro- No bytecode da VM do Ruby,
i.succaparece como uma única etapaopt_succ - Já
i += 1é dividido em duas etapasputobject_INT2FIX_1_: coloca o inteiro 1 na pilha da VMopt_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#eachtambé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
Primitivedentro do código RubyPrimitive.attr! :inline_block, :c_tracePrimitive.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 comInteger#timesRange#each: 25,57 sInteger#times: 13,66 sArray#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 srange#each: 26,61 stimes: 13,12 sfor: 14,91 swhile: 37,10 sloop do: 13,95 s
- No Ruby 3.4 com YJIT desativado, a maioria ficou mais lenta
- Fibonacci: 16,49 s
array#each: 34,29 srange#each: 33,88 stimes: 33,18 sfor: 36,32 swhile: 37,14 sloop do: 50,65 s
- O exemplo com
whilefoi mais lento do que o esperado, e isso pode ser um problema da forma como foi implementado for inearray#eachsão quase idênticos no nível de bytecode da VM do Ruby, então o desempenho também é parecidofor 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 srange#each: 0,92 stimes: 2,39 sfor: 2,06 swhile: 3,90 sloop do: 0,77 s
- MRuby 3.3
- Fibonacci: 28,83 s
array#each: 144,65 srange#each: 126,40 stimes: 128,22 s
- Artichoke
- Fibonacci: 19,71 s
array#each: 236,10 srange#each: 214,55 stimes: 214,51 s
- TruffleRuby 24.1
Experimento com monkey patch em Range#each em Ruby
- Ao aplicar monkey patch em
Range#eachcom uma implementação Ruby simples, o desempenho melhora bastante - A implementação usa uma forma simples com
begin,end,loop,yieldei.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
- Implementação em C de
- Essa implementação não trata todos os casos de
Rangee é 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_yjitaplica 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_yjitaplica 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 comundef :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=devmake install
- Na execução, usa-se a flag
--yjit-dump-disasm./ruby --yjit --yjit-dump-disasm test.rb 40
- Em
Integer#times,i.succaparece comoopt_succno 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
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
unã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 comoclangtalvez faça em breveHá 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
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...
Pelo que lembro, melhorias de desempenho também podiam entrar em point releases
Integer#succacaba sendo usado com frequência não só por desempenho, mas também por legibilidadePor exemplo, no método
#bytesde uma biblioteca de UUID, usei duas vezes para manter a cabeça em “modo de fatiamento de bits” ao ler o códigoO loop de 16 iterações é expresso como
0xF.succ, e a parte dentro do loop que divide por 256 como0xFF.succ: https://github.com/okeeblow/DistorteD/blob/ba48d10/Globe%20G...0xF.succparece melhor do que0x10Estou 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.rbpraticamente 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
Para mais detalhes, veja https://github.com/oracle/truffleruby
Ruby realmente ficou rápido, e o TruffleRuby em especial é ainda mais impressionante
É 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
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
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 é
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
Em geral, Ruby é lento, mas é muito gostoso de programar, então fica mais atraente para iniciantes
Minhas soluções do Advent of Code são surpreendentemente parecidas, então isso parece uma mudança capaz de virar o jogo