Tiny JITs para acelerar o FFI
(railsatscale.com)- No Ruby, o FFI é conveniente para chamar funções nativas, mas no benchmark de
strleno overhead de chamada é maior do que em extensões C, tornando-se um gargalo de desempenho - No benchmark de referência, a chamada direta de
String#bytesizeatingiu 39.879M i/s, a extensão C 30.661M i/s, a chamada indireta em Ruby 28.697M i/s, e o FFI foi o mais lento com 15.682M i/s - A ideia central é usar, no momento de
attach_function, o nome da função, os tipos dos argumentos e o tipo de retorno, que já são conhecidos, para gerar em tempo de execução código de máquina para chamada de função externa - A prova de conceito FJIT elevou a chamada de
strlenpara 32.508M i/s em um ambiente Ruby 3.5.0dev ARM64, ficando mais de 2 vezes mais rápida que o FFI e até um pouco mais rápida que a extensão C - Atualmente há limitações como ARM64, argumento único e retorno único, tipos limitados, dependência de
--rjit --rjit-disablee de um commit específico do Ruby head, então ainda é preciso expandir a implementação para uso real
Onde o FFI fica lento no Ruby
- A direção básica é escrever o máximo possível em Ruby e chamar código nativo apenas quando realmente necessário
- O YJIT consegue otimizar código Ruby, mas não consegue otimizar código C
- Se uma biblioteca nativa for necessária, o ideal é colocar um wrapper fino em extensão C ao redor da função real e deixar a maior parte do trabalho em Ruby
- APIs simples assim combinam bem com FFI, mas o FFI existente não consegue entregar o mesmo desempenho de uma extensão C
Benchmark com base em strlen
- Há quatro itens de comparação
- Chamada do
strlende C via FFI - Chamada indireta de
x.bytesizeno método RubyB.strlen - Chamada de uma extensão C feita com a Gem Ruby
strlen - Chamada direta de
str.bytesize
- Chamada do
- Resultados em Ruby 3.5.0dev, ambiente ARM64:
ruby-direct: 39.879M i/s, 25.08 ns/istrlen-cext: 30.661M i/s, 32.61 ns/i, 1.30x mais lento que a chamada diretastrlen-ruby: 28.697M i/s, 34.85 ns/i, 1.39x mais lento que a chamada diretastrlen-ffi: 15.682M i/s, 63.77 ns/i, 2.54x mais lento que a chamada direta
- A chamada direta de
String#bytesizefoi a mais rápida, e chamadas indiretas adicionais aumentam o overhead - A diferença entre
ruby-directestrlen-rubymostra o custo de push/pop de stack frames, e remover esse overhead é exatamente o tipo de trabalho que compiladores JIT como o YJIT fazem bem - A diferença entre
strlen-cextestrlen-ffirevela que o custo adicional ao chamar funções nativas via FFI é grande
Substituindo a chamada FFI por JIT
- No momento da chamada
attach_function :strlen, [:string], :int, as informações necessárias já existem- Nome da função a ser chamada:
strlen - Tipo do argumento: string
- Tipo de retorno: int
- Nome da função a ser chamada:
- Com essas informações, é possível gerar código de máquina que desempacota valores Ruby em tipos nativos, chama a função externa e depois empacota o valor de retorno de volta em um objeto Ruby
- São necessários três componentes
- Gem AArch64: geração de código de máquina ARM64
- Gem Fisk: geração de código de máquina x86_64
- Gem JITBuffer: alocação de memória executável
- Apenas gerar código de máquina não basta; o Ruby também precisa conseguir saltar para esse código para contornar o overhead do FFI
O caminho usando o RJIT
- O RJIT é um compilador JIT para Ruby escrito em Ruby e distribuído junto com o Ruby
- Sua estrutura interna é parecida com a do YJIT, mas como não foi feito com foco em uso em produção, não é tão conhecido quanto o YJIT
- Kokubun abriu uma solicitação de funcionalidade para extrair o RJIT como Gem
- A proposta fornece duas bases para facilitar a criação de compiladores JIT de terceiros para Ruby
- Separar o RJIT em uma Gem
- Criar tipos internos do Ruby como estruturas de dados Ruby, permitindo que JITs de terceiros obtenham as informações necessárias para empacotar e desempacotar tipos de dados Ruby
- Outra mudança é executar sempre que houver um ponteiro de função de entrada do JIT
- Se um JIT de terceiros registrar código de máquina, o Ruby poderá saltar automaticamente para esse código
- Com esses dois elementos, torna-se possível criar um compilador JIT pequeno e de propósito único que funcione como interface de FFI
Prova de conceito do FJIT
- A prova de conceito do FJIT, abreviação de “FFI JIT”, gera em tempo de execução código de máquina para chamar funções externas
- O exemplo conecta
strlencom uma interface parecida com a do FFImodule Cextend FJITattach_function :strlen, [:string], :int
- Quando
attach_functioné chamado, o FJIT gera código de máquina que desempacota a string Ruby, chama ostrlende C e retorna o comprimento da string como objeto Ruby
Resultados do benchmark do FJIT
- Resultados em Ruby 3.5.0dev,
+RJIT +PRISM, ambiente ARM64:ruby-direct: 41.907M i/s, 23.86 ns/istrlen-fjit: 32.508M i/s, 30.76 ns/i, 1.29x mais lento que a chamada diretastrlen-cext: 29.778M i/s, 33.58 ns/i, 1.41x mais lento que a chamada diretastrlen-ruby: 28.851M i/s, 34.66 ns/i, 1.45x mais lento que a chamada diretastrlen-ffi: 15.629M i/s, 63.98 ns/i, 2.68x mais lento que a chamada direta
- A chamada direta de
String#bytesizecontinua sendo a mais rápida - O código de máquina gerado pelo FJIT foi o segundo mais rápido e teve resultado ligeiramente melhor que a extensão C de
strlen - O FJIT é mais de 2 vezes mais rápido que chamadas FFI, e também mais rápido que chamadas indiretas em Ruby
- Isso sugere que é possível manter a abordagem de “escrever o máximo possível em Ruby” e ainda obter velocidade igual ou superior à de extensões C
Limitações restantes antes do uso real
- O compilador JIT da prova de conceito está atualmente limitado à plataforma ARM64
- Para expandi-lo para uma implementação real, seria preciso adicionar um backend x86_64
- Ainda não lida com todos os tipos de argumentos e de retorno
- Considera-se possível oferecer suporte a todos os tipos de argumentos, e o volume de trabalho não parece excessivo
- No momento, só lida com funções que recebem um único argumento e retornam um único valor
- Por enquanto, é necessário executar o Ruby com as flags
--rjit --rjit-disable- Espera-se que essa condição deixe de ser necessária quando a funcionalidade de Kokubun entrar
- A prova de conceito só rodava no Ruby head atual da época
- Na atualização mais recente, o RJIT foi removido do Ruby head, então para executar o script é preciso fazer checkout do Ruby no commit
f32d5071b7b01f258eb45cf533496d82d5c0f6a1
1 comentários
Opiniões do Hacker News
O resolvedor de restrições em Java Timefold lidou bastante com FFI para permitir chamadas a funções definidas no CPython, e a maioria dos problemas de desempenho surgiu ao transitar por proxies entre a linguagem hospedeira e a linguagem externa
Chamadas FFI diretas via JNI ou uma nova interface externa são rápidas a ponto de se aproximarem de uma chamada direta de método Java, mas os garbage collectors do CPython e do Java não combinam bem entre si, então a sincronização exige uma espécie de magia negra
Já proxies como JPype ou GraalPy precisam converter parâmetros e valores de retorno, e podem ainda provocar chamadas FFI adicionais no sentido oposto. Ao passar um objeto CPython para o Java, o Java cria um proxy desse objeto; ao passar esse proxy de volta ao CPython, ele não o desembrulha e acaba criando um proxy do proxy
Como resultado, o proxy do JPype foi 1402% mais lento do que chamar o CPython diretamente por FFI, e o proxy do GraalPy foi 453% mais lento
No fim, traduziram bytecode do CPython para bytecode Java e criaram estruturas de dados Java correspondentes às classes CPython usadas, ficando 100 vezes mais rápido do que com proxies. Acrescento que é melhor não tentar traduzir nem ler bytecode do CPython, porque ele é muito instável, mal documentado, e as características da VM são complicadas, o que dificulta mapeá-lo diretamente para outro bytecode
Detalhes foram organizados no artigo: https://timefold.ai/blog/java-vs-python-speed
Código Go e código C precisam combinar como vão compartilhar recursos como espaço de endereçamento, manipuladores de sinais e slots TLS de threads, mas, na prática, isso está mais perto de dizer que Go precisa contornar as suposições do código C. O código C pode assumir que roda em uma única thread, ou pode não estar preparado de forma alguma para um ambiente multithread
Como C não conhece a convenção de chamada do Go nem suas stacks expansíveis, para chamar código C é preciso registrar os detalhes da stack da goroutine, trocar para uma stack C e então executar código C que não sabe como foi chamado nem conhece o runtime Go como um todo
Seja fazendo bindings para código C ou envolvendo-o com Python, Java usando JNI, uma linguagem usando libffi, ou Go usando cgo, no fim das contas você está vivendo no mundo de C
https://dave.cheney.net/2016/01/18/cgo-is-not-go / https://archive.vn/GZoMK
Graças à Rails At Scale e aos textos do byroot, é uma ótima época para ver artigos que tratam a fundo da estrutura interna e do desempenho do Ruby. Considerando também as melhorias recentes em Ruby e Rails, é um período bem bom para ser desenvolvedor Ruby
Ele ainda é popular em certos tipos de aplicação, mas seu auge parece já ter ficado bem para trás; as melhorias recentes são boas, mas não sei se JIT é tão tecnicamente interessante em 2025
A abordagem de “em vez de chamar uma biblioteca de terceiros, será que não dá simplesmente para fazer JIT do código necessário para chamar funções externas?” parece bem próxima da base do LuaJIT FFI: https://luajit.org/ext_ffi.html
Então acho que é por isso que o FFI do LuaJIT é tão rápido
Não dá para entender bem a frase “escreva o máximo possível em Ruby. Em especial, o YJIT consegue otimizar código Ruby, mas não código C”
Ruby não é uma linguagem bem lenta? Se for para descer para nativo, parece que eu ia querer deixar o máximo possível em código nativo
Em uma versão principal, o código Java que tratava o comportamento de algum elemento de UI foi identificado como gargalo e, na versão principal seguinte, foi reescrito em C
Depois, quando o JIT passou a ser realmente útil, o overhead de FFI ficou maior do que a diferença entre o código C ajustado à mão e o código gerado pelo JIT, e na versão principal seguinte voltaram para uma implementação Java pura
Para uma linguagem daquela geração, a FFI do Java era bem rápida, mas algumas versões depois ela mudou para uma abordagem melhor; nessa altura eu já não fazia muito código de UI em Java, então parei de acompanhar. Como, na mesma época, também estavam arrumando a interface entre código específico de plataforma e código Java de UI comum, não tenho certeza de como isso terminou
Nesse tipo de trabalho, é preciso ficar sempre de olho nesse efeito gangorra. É preciso avaliar se vale mais esperar alguns marcos para reduzir o trabalho de ajuste manual ou se, por motivos políticos ou técnicos, isso é necessário agora
Para tarefas que executam uma vez e acabam, isso pode ser ineficiente, mas em cargas de trabalho de desktop e servidor de longa duração há compensação do ponto de vista da aplicação inteira
Por exemplo, o JIT do Dalvik era bem fraco, então chamar funções matemáticas em C costumava ser mais rápido, mas depois do ART isso deixou de ser necessário, e o JIT passou a conseguir superar o custo de uma chamada C
https://developer.android.com/reference/android/util/FloatMa...
Às vezes chamam esse movimento de “self-hosting”, e navegadores também usam bastante isso, movendo para JavaScript privilegiado partes que originalmente pareceriam ter sido escritas em C/C++. Uma parte surpreendentemente grande da biblioteca padrão não é código nativo
Mesmo em linguagens muito mais rápidas e com custo de interoperabilidade quase zero, como C#, ainda existe custo de chamada e, às vezes, até custo de mudança de flags de estado da VM ou de transição do coletor de lixo
Se o Ruby YJIT começou a se tornar um fator mensurável, essa regra também vai ficar cada vez mais importante
eachdo Ruby foi reescrito em Ruby: https://jpcamara.com/2024/12/01/speeding-up-ruby.html / https://bugs.ruby-lang.org/issues/20182Também há um post bônus do tender love: https://railsatscale.com/2023-08-29-ruby-outperforms-c/
Em resumo, o JIT vence
FFI é Foreign Function Interface, ou seja, a forma de chamar C a partir de Ruby
Depois, a partir de Ruby, você executa esse programa C no terminal com flags ou dados, e assim o Ruby consegue executar o código C
Não entendo por que isso precisa ser compilado com JIT. Se dá para escrever em C, não daria simplesmente para compilar no momento do carregamento?
Isso aumenta muito a produtividade e permite compartilhar o mesmo código entre CRuby, JRuby e TruffleRuby
Se fosse possível descobrir estaticamente todos os bindings na inicialização, daria para escrever stubs e colocá-los na tabela de métodos, mas ainda assim isso seria algo que acontece em runtime, portanto seria JIT. E, como não conseguiria se adaptar aos tipos que fluem pelo sistema, teria de ser conservador nos valores aceitos e nas otimizações, o que é bem próximo do que a libffi faz hoje
A abordagem AOT é escrever uma extensão nativa
Como observação lateral, normalmente acabo evitando não a FFI em si, mas gems que usam FFI. A compilação muitas vezes é tão trabalhosa que era mais fácil pular a etapa intermediária do Rubygems/bundler e compilar diretamente
Um pouco relacionado: esta biblioteca usa JVMCI para gerar na hora código arm64/amd64 que chama bibliotecas nativas sem JNI: https://github.com/apangin/nalim
Não é exatamente isso que a libffi faz?
Na libffi, você cria um objeto descritor para a função, e essa estrutura de dados em tempo de execução representa os tipos dos argumentos e do valor de retorno
Ao fazer uma chamada FFI, é preciso passar o descritor e um array de ponteiros para os valores que você quer passar. Internamente, provavelmente ela percorre o array de valores junto com o descritor, coloca os valores na pilha de acordo com seus tipos e, quando a função termina, extrai o valor conforme o tipo de retorno. É bem provável que haja ramificações por tipo em vários pontos desse processo
Mesmo que o mecanismo de chamada da libffi fosse JITado, a preparação do array de argumentos ainda seria lenta. É menos direto do que um FFI JIT, que acessa os argumentos diretamente sem passar por um array intermediário
O código de FFI JIT recebe os valores dos argumentos diretamente, converte-os de tipos Ruby para tipos C, coloca cada valor no lugar correto da pilha ou dos registradores com código inline, chama a função e converte o valor de retorno para um tipo Ruby. Na prática, é parecido com escrever código de extensão à mão
Com inferência de tipos, é possível pular verificações de tipo no código de conversão. Por exemplo, se houver garantia de que
arg1é uma string Ruby, dá para usar uma versão unsafe mais rápida da função de conversãoNo pior caso, o código JIT só precisa refletir algo como os tipos Ruby, e não precisa de arrays ou listas relacionados aos argumentos. Como o tipo C para o qual cada valor é convertido está hardcoded no código, não há necessidade de percorrer em tempo de execução uma estrutura de dados que descreva o lado C
O mérito deste texto está no fato de que, com base nas informações passadas pelo usuário ao chamar
attach_function, o código de unboxing de tipos fica, na prática, cacheado dentro do código de máquina geradoO
tramp.clinkado no comentário irmão é para “FFI reversa”, ou seja, expor uma operação dinâmica do usuário como ponteiro de função; ali, o JIT está no nível de apenas 3 instruções no total para chamar código pré-compiladohttps://github.com/libffi/libffi/blob/master/src/tramp.c