Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby: FFIを高速化する小さなJIT(翻訳)

概要

CC BY-NC-SA 4.0 Deedに基づいて翻訳・公開いたします。

CC BY-NC-SA 4.0 Deed | 表示 - 非営利 - 継承 4.0 国際 | Creative Commons

参考: Foreign function interface(FFI) - Wikipedia

Ruby: FFIを高速化する小さなJIT(翻訳)

CRubyでもっと高速なFFI(Foreign function interface)を使えるでしょうか?はい、可能です。

🔗 CRubyでもっと高速なFFIを使えるか

私はRubyでプログラミングするのが好きなので、なるべくRubyでプログラミングするよう多くの人々に呼びかけています。しかし場合によっては、どうしてもネイティブコードを書くしかないこともあります。しかし私は、そんな場合であっても「可能な限りRubyで書く」ことを推奨しています。最大の理由は、YJITはRubyコードを最適化できても、Cコードを最適化できないからです。

この考え方を突き詰めると、ネイティブライブラリの機能を呼び出したいときは、極力必要最小限の処理だけを行うネイティブ拡張を書き、それ以外のほとんどの作業はRuby側で行いましょう、と解釈できます。つまりネイティブコードは「実際に呼び出したい関数を囲む非常に薄いラッパー」にとどめ、Rubyの型をネイティブ関数で必要な型に変換する以外のことは行わないということです。

もちろん、こんな単純極まるAPIならFFIのようなライブラリで扱うのにうってつけでしょう。

ffi/ffi - GitHub

率直に申し上げると、普段の私はFFIを避けています。その理由は、要するにネイティブ拡張と同等のパフォーマンスを発揮できないからです。

私の言いたいことをご理解いただくために、ごく簡単なベンチマークの例を見てみましょう。
このベンチマークでは、Cのstrlen関数をFFIでラップしています。

このFFI実装と、同じ処理を実行するC拡張(本記事のために私が書いたstrlen gemを利用)を比較します。また、String#bytesizeを間接的に呼び出す場合と、String#bytesizeを直接呼び出す場合についても比較します。

tenderlove/strlen - GitHub

require "ffi"
require "strlen"
require "benchmark/ips"

module A
  extend FFI::Library
  ffi_lib 'c'
  attach_function :strlen, [:string], :int
end

module B
  def self.strlen(x)
    x.bytesize
  end
end

str = "foo"

Benchmark.ips do |x|
  x.report("strlen-ffi")  { A.strlen(str) }
  x.report("strlen-ruby") { B.strlen(str) }
  x.report("strlen-cext") { Strlen.strlen(str) }
  x.report("ruby-direct") { str.bytesize }
  x.compare!
end

以下はベンチマークの出力結果です。

ruby 3.5.0dev (2025-02-11T16:42:26Z master 4ac75f6f64) +PRISM [arm64-darwin24]
Warming up --------------------------------------
          strlen-ffi     1.557M i/100ms
         strlen-ruby     2.875M i/100ms
         strlen-cext     3.047M i/100ms
         ruby-direct     4.048M i/100ms
Calculating -------------------------------------
          strlen-ffi     15.682M (± 0.5%) i/s   (63.77 ns/i) -     79.398M in   5.063141s
         strlen-ruby     28.697M (± 0.3%) i/s   (34.85 ns/i) -    143.747M in   5.009135s
         strlen-cext     30.661M (± 0.8%) i/s   (32.61 ns/i) -    155.406M in   5.068838s
         ruby-direct     39.879M (± 0.6%) i/s   (25.08 ns/i) -    202.412M in   5.075857s

Comparison:
         ruby-direct: 39878845.7 i/s
         strlen-cext: 30661398.4 i/s - 1.30x  slower
         strlen-ruby: 28697184.3 i/s - 1.39x  slower
          strlen-ffi: 15681971.0 i/s - 2.54x  slower

最初のString#bytesize直接呼び出しが最も高速なので、これを基準として考えましょう。間接参照を追加すると必然的にオーバーヘッドが増加するので、おそらくこの数値を「上回る」ことはないでしょう。

2番目に速いのは、C拡張を介してstrlenを呼び出す場合、その次がRubyのString#bytesizeを間接的に呼び出す場合、最後にFFI実装が最も低速でした。

これらのベンチマーク結果から、いくつか興味深い点がわかります。

まず、1位の"ruby-direct"ベンチマークと3位の"strlen-ruby"ベンチマークの違いから、スタックフレームのpushとpopにオーバーヘッドが存在することは確実です。このオーバーヘッドの排除は、YJITなどのJITコンパイラが得意とする処理の1つです。

次に、2位の"strlen-cext"ベンチマークと4位の"strlen-ffi"ベンチマークの違いから、ネイティブ関数をFFI経由で呼び出すと、かなりのオーバーヘッドが発生することがわかります。C拡張呼び出しは、String#bytesizeを直接呼び出すよりも遅くなりますが、FFI経由のstrlen呼び出しは、C拡張呼び出しよりもさらにオーバーヘッドが増加します。

つまり、やりたいことを実現できるメソッドがRubyに備わっているなら、Rubyが提供するメソッドを素直に使うに越したことはないということです。ただし、外部関数を呼び出す必要がある場合は、小さなC拡張ラッパーの方がFFIラッパーよりも一般にオーバーヘッドが小さく済みます。

私がFFIを避けたのは、FFIがC拡張よりも「本質的に劣る」と考えたからではありません。実際にはFFIのオーバーヘッドを避けたかっただけなのです。

🔗 現実を変えることは可能か?

数年前にChris Seatonから「サードパーティのライブラリを呼び出すのではなく、外部関数を呼び出すのに必要なコードをJITが生成すればよいのでは?」というアイデアをもらい、以来そのことがずっと頭を離れません。

以下のFFIラッパーの例を見てみましょう。

module A
  extend FFI::Library
  ffi_lib 'c'
  attach_function :strlen, [:string], :int
end

このattach_function呼び出しを見れば、「呼び出す必要のある関数名(strlen)」「パラメータの型(string)」「戻り値型(int)」がわかります。ラッパー関数が定義される時点でこれらの型がわかっているので、Ruby型のラップとアンラップに必要なマシンコードを生成し、外部関数を呼び出すことは可能です。

どうやってこれを実現するか、私は数年もの間模索し続けていましたが、今年後半のRuby 3.5でようやく運命の星たちが一直線に並ぶだろうと見込んでいます。

この夢を現実のものにするには、いくつかの要素が必要です。

第1に、マシンコードを生成する手段が必要です。私がAArch64 gem(ARM64マシンコードを生成)とFisk gem(x86_64マシンコードを生成)という2つのgemを書いた理由は、そのためでした。

tenderlove/aarch64 - GitHub
tenderlove/fisk - GitHub

第2に、マシンコードを実際に実行できるように、実行可能メモリをアロケーションする手段が必要です。マシンコードをアセンブルするだけでは不十分で、そのマシンコードを"executable"(実行可能)とマーキングしたメモリに配置する必要があります。そのために、JITBufferという独創的な名前のgemも作成しました。

tenderlove/jit_buffer - GitHub

これらのユーティリティを使えば、実行可能なマシンコードを生成できるようになります。
しかし残念ながら、乗り越えなければならないハードルがもう1つあります。それは、Rubyをマシンコードにジャンプさせることです。

実行可能なマシンコードを生成するだけなら胡散臭い寄せ集めチームでもできますが、それだけでは不十分です。RubyをそのマシンコードにジャンプさせることでFFIオーバーヘッドをスキップできるようにする必要もあるのです。

🔗 RJITの活用

ご存じない方のために補足すると、RJITはRuby用JITコンパイラで、Ruby言語自身で書かれており、Rubyに同梱されています。RJITの内部構造はYJITとかなり似ていますが、production環境での利用は想定されていません。おそらく多くの人は、YJITは知っていてもRJITを知らないかもしれません。

RJITの作者であるK0kubunは、最近RJITをgemに切り出すフィーチャーリクエストを提出しました(#21116)。この抽出によって得られる主な機能は、Ruby用のJITコンパイラをサードパーティgemとして作りやすくなることです。提案では以下の2つの重要なことを行います。

第1に、RJITがgemとして抽出されます。
RJITはRustのbindgenに似たメカニズムを使っており、Rubyのすべての内部型をマップするRuby データ構造を生成します(生成されたコードの一部はこちらで確認できます)。
つまり、サードパーティのJITコンパイラがRubyデータ型をラップ・アンラップするのに必要な情報を取得できるようになります。

第2の重要な点は、JITエントリ関数ポインタが存在する場合は必ずそれを実行することです。これが重要な理由は、サードパーティのJITがマシンコードを登録する方法を持てるようになり、Rubyが自動的にそのマシンコードにジャンプすることになるからです。

これら2つの要素があれば、FFIインターフェイスとして動作する非常に小規模で単一目的のJITコンパイラを作成できるようになります。

🔗 概念実証

私は概念実証用に、非常に小さな「FJIT」という名前のJITを作成しました。FJITは「FFI JIT」の略で、その名の通りの機能を果たします。つまり、実行時に外部関数を呼び出せるマシンコードを生成します。ここでは、FJITを使ってstrlen関数を呼び出します。

このプログラムは「小さい」とはいえ、JITコンパイラがまるごと入っているので、本記事にはプログラム全体を載せていません。重要なのはベンチマークです。

module A
  extend FFI::Library
  ffi_lib 'c'
  attach_function :strlen, [:string], :int
end

module B
  def self.strlen(x)
    x.bytesize
  end
end

module C
  extend FJIT
  attach_function :strlen, [:string], :int
end

str = "foo"

Benchmark.ips do |x|
  x.report("strlen-ffi")  { A.strlen(str) }
  x.report("strlen-ruby") { B.strlen(str) }
  x.report("strlen-cext") { Strlen.strlen(str) }
  x.report("ruby-direct") { str.bytesize }
  x.report("strlen-fjit") { C.strlen(str) }
  x.compare!
end

更新したベンチマークのCモジュールは、FJITモジュールを使っており、そのインターフェイスはFFIのインターフェイスと非常によく似ていることがわかります。attach_functionが呼び出されると、FJITはRuby文字列をアンラップするのに必要なマシンコードを生成し、strlen関数を呼び出して、文字列の長さをRubyオブジェクトとして返します。

以下はベンチマークの結果です。

ruby 3.5.0dev (2025-02-11T16:42:26Z master 4ac75f6f64) +RJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
          strlen-ffi     1.558M i/100ms
         strlen-ruby     2.953M i/100ms
         strlen-cext     2.981M i/100ms
         ruby-direct     4.142M i/100ms
         strlen-fjit     3.206M i/100ms
Calculating -------------------------------------
          strlen-ffi     15.629M (± 0.7%) i/s   (63.98 ns/i) -     79.455M in   5.083899s
         strlen-ruby     28.851M (± 0.3%) i/s   (34.66 ns/i) -    144.704M in   5.015659s
         strlen-cext     29.778M (± 2.8%) i/s   (33.58 ns/i) -    149.025M in   5.008456s
         ruby-direct     41.907M (± 0.8%) i/s   (23.86 ns/i) -    211.219M in   5.040449s
         strlen-fjit     32.508M (± 0.9%) i/s   (30.76 ns/i) -    163.504M in   5.030060s

Comparison:
         ruby-direct: 41907248.7 i/s
         strlen-fjit: 32507961.2 i/s - 1.29x  slower
         strlen-cext: 29778234.0 i/s - 1.41x  slower
         strlen-ruby: 28850712.3 i/s - 1.45x  slower
          strlen-ffi: 15629443.7 i/s - 2.68x  slower

String#bytesizeの直接呼び出しが最速なのは変わりませんが、FJITが生成したマシンコードは2着につけています。
しかも驚いたことに、Cのstrlen拡張よりもわずかに高速でした。しかしこの先は、間接的なRuby呼び出しよりも速く、FFI経由の呼び出しの倍以上の速度を達成できると期待しています。

🔗 結論

これは非常にエキサイティングな結果だと思います。「できるだけRubyで書く」という原則を損なうことなく、C拡張と同程度(もしくはそれ以上)の速度を実現できるからです。私は、FFIを使わずにネイティブコードを呼び出せるZigのようなプログラミング言語が羨ましくてたまらなかったのですが、こうした可動部分をすべて落ち着かせることに成功すれば、Rubyでも同じメリットが得られるでしょう。

🔗 注意事項

結論は最後に書くものであることぐらいわかっていますが、私としては皆さんが大事な点に辿り着く前に現時点の注意事項を読み飛ばして欲しくなかったのです。

  1. 私の書いた概念実証用JITコンパイラは、ARM64プラットフォームにしか対応していません。これを「現実」のものにするにはx86_64用のバックエンドを追加する必要があります。追加はもちろん可能で、最後までやる必要があるというだけです。

  2. 現時点では一部のパラメータ型や戻り値型については処理できません。私は、すべてのパラメータ型をサポート可能であると確信しており、面倒な作業にはならないでしょう。

  3. 現時点のJITコンパイラが処理できるのは、1個のパラメータを受け取って1個のパラメータを返す関数だけです。繰り返しになりますが、これはコンパイラの残りの部分を具体化するだけの問題だと思います。

  4. 現時点ではRubyを実行するときに--rjit --rjit-disableフラグを付ける必要があります。K0kubunのフィーチャーリクエストがリリースされればフラグは不要になります。

  5. この概念実証JITコンパイラが動作するのは、現時点ではRubyの最新ヘッドコミットのみです。

制限事項がかなり多いのは承知していますが、平均的なEULAの文面ほど長くはないので、克服できないほどではありません。

とにかく今回は以上です。良い一日を!

原注

更新情報: Rubyヘッドの動きは早く、RJITが早速削除されました!本記事のスクリプトで遊んでみたい方は、Rubyのf32d5071b7b01f258eb45cf533496d82d5c0f6a1コミットをチェックする必要があります。

関連記事

YJIT: CRuby向けの新しいJITコンパイラを構築する(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。