2 pontos por GN⁺ 2025-02-14 | 1 comentários | Compartilhar no WhatsApp
  • No Ruby, o FFI é conveniente para chamar funções nativas, mas no benchmark de strlen o 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#bytesize atingiu 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 strlen para 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-disable e 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 strlen de C via FFI
    • Chamada indireta de x.bytesize no método Ruby B.strlen
    • Chamada de uma extensão C feita com a Gem Ruby strlen
    • Chamada direta de str.bytesize
  • Resultados em Ruby 3.5.0dev, ambiente ARM64:
    • ruby-direct: 39.879M i/s, 25.08 ns/i
    • strlen-cext: 30.661M i/s, 32.61 ns/i, 1.30x mais lento que a chamada direta
    • strlen-ruby: 28.697M i/s, 34.85 ns/i, 1.39x mais lento que a chamada direta
    • strlen-ffi: 15.682M i/s, 63.77 ns/i, 2.54x mais lento que a chamada direta
  • A chamada direta de String#bytesize foi a mais rápida, e chamadas indiretas adicionais aumentam o overhead
  • A diferença entre ruby-direct e strlen-ruby mostra 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-cext e strlen-ffi revela 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
  • 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
  • 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 strlen com uma interface parecida com a do FFI
    • module C
    • extend FJIT
    • attach_function :strlen, [:string], :int
  • Quando attach_function é chamado, o FJIT gera código de máquina que desempacota a string Ruby, chama o strlen de 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/i
    • strlen-fjit: 32.508M i/s, 30.76 ns/i, 1.29x mais lento que a chamada direta
    • strlen-cext: 29.778M i/s, 33.58 ns/i, 1.41x mais lento que a chamada direta
    • strlen-ruby: 28.851M i/s, 34.66 ns/i, 1.45x mais lento que a chamada direta
    • strlen-ffi: 15.629M i/s, 63.98 ns/i, 2.68x mais lento que a chamada direta
  • A chamada direta de String#bytesize continua 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

 
GN⁺ 2025-02-14
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

    • Falando sem experiência prática, o FFI de Python e Java, cada um indo para C, parece muito melhor; talvez ligar os dois com uma pequena ponte em C acabe virando a solução genérica
    • cgo is not Go também vale como referência
      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
    • Fico curioso sobre onde métodos de comunicação entre processos, como filas, arquivos ou HTTP, se encaixariam nesses casos
  • 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

    • Será mesmo? Ruby parece estar em queda https://www.tiobe.com/tiobe-index/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

    • Houve um pequeno drama desses na época em que o Java estava ganhando um JIT decente
      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
    • É nesse ponto que entra o JIT. Idealmente, o JIT consegue reotimizar o código para um estado melhor
      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...
    • Em linguagens gerenciadas que têm compilador JIT ou AOT, muitas vezes o ideal é escrever bastante código nessa própria linguagem. Assim, ficam possíveis inlining e outras otimizações que não são possíveis ao cruzar a fronteira de uma chamada C
      À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
    • FFI cria uma fronteira opaca e impossível de otimizar entre códigos. Código que fica fazendo ida e volta desse jeito com frequência tem um custo alto
      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
    • Há um ótimo texto do JPCamara explicando por que o método each do Ruby foi reescrito em Ruby: https://jpcamara.com/2024/12/01/speeding-up-ruby.html / https://bugs.ruby-lang.org/issues/20182
      També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

    • A abordagem totalmente segura e sensata é escrever código C que, ao executar, receba dados pela linha de comando e despeje o resultado na linha de comando ou em uma página de memória
      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?

    • Não sei o que você quer dizer com “dá para escrever em C”. Uma biblioteca FFI permite vincular dinamicamente métodos de biblioteca a partir de Ruby e executá-los sem escrever uma extensão nativa
      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?

    • A libffi é lenta e, até onde sei, não faz JIT
      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ão
      No 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
    • Como a libffi não sabe o que é Ruby, ela não tem como saber como fazer unboxing dos tipos Ruby
      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 gerado
    • A libffi não faz JIT de chamadas FFI e ainda precisa posicionar diretamente os valores dos argumentos. Por exemplo, se o argumento for uma string, é preciso escrever separadamente o código que converte o objeto string do Ruby em um ponteiro para string em C. E a libffi é bem lenta
      O tramp.c linkado 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é-compilado
    • Eu achava que sabia o que a libffi fazia; pensei que ela mexesse com algo tipo GOT. Mas parece que você tem razão
      https://github.com/libffi/libffi/blob/master/src/tramp.c