Ruby: FFIを高速化する小さなJIT(翻訳)
CRubyでもっと高速なFFI(Foreign function interface)を使えるでしょうか?はい、可能です。
🔗 CRubyでもっと高速なFFIを使えるか
私はRubyでプログラミングするのが好きなので、なるべくRubyでプログラミングするよう多くの人々に呼びかけています。しかし場合によっては、どうしてもネイティブコードを書くしかないこともあります。しかし私は、そんな場合であっても「可能な限りRubyで書く」ことを推奨しています。最大の理由は、YJITはRubyコードを最適化できても、Cコードを最適化できないからです。
この考え方を突き詰めると、ネイティブライブラリの機能を呼び出したいときは、極力必要最小限の処理だけを行うネイティブ拡張を書き、それ以外のほとんどの作業はRuby側で行いましょう、と解釈できます。つまりネイティブコードは「実際に呼び出したい関数を囲む非常に薄いラッパー」にとどめ、Rubyの型をネイティブ関数で必要な型に変換する以外のことは行わないということです。
もちろん、こんな単純極まるAPIならFFIのようなライブラリで扱うのにうってつけでしょう。
率直に申し上げると、普段の私はFFIを避けています。その理由は、要するにネイティブ拡張と同等のパフォーマンスを発揮できないからです。
私の言いたいことをご理解いただくために、ごく簡単なベンチマークの例を見てみましょう。
このベンチマークでは、Cのstrlen
関数をFFIでラップしています。
このFFI実装と、同じ処理を実行するC拡張(本記事のために私が書いたstrlen
gemを利用)を比較します。また、String#bytesize
を間接的に呼び出す場合と、String#bytesize
を直接呼び出す場合についても比較します。
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を書いた理由は、そのためでした。
第2に、マシンコードを実際に実行できるように、実行可能メモリをアロケーションする手段が必要です。マシンコードをアセンブルするだけでは不十分で、そのマシンコードを"executable"(実行可能)とマーキングしたメモリに配置する必要があります。そのために、JITBufferという独創的な名前のgemも作成しました。
これらのユーティリティを使えば、実行可能なマシンコードを生成できるようになります。
しかし残念ながら、乗り越えなければならないハードルがもう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でも同じメリットが得られるでしょう。
🔗 注意事項
結論は最後に書くものであることぐらいわかっていますが、私としては皆さんが大事な点に辿り着く前に現時点の注意事項を読み飛ばして欲しくなかったのです。
- 私の書いた概念実証用JITコンパイラは、ARM64プラットフォームにしか対応していません。これを「現実」のものにするにはx86_64用のバックエンドを追加する必要があります。追加はもちろん可能で、最後までやる必要があるというだけです。
-
現時点では一部のパラメータ型や戻り値型については処理できません。私は、すべてのパラメータ型をサポート可能であると確信しており、面倒な作業にはならないでしょう。
-
現時点のJITコンパイラが処理できるのは、1個のパラメータを受け取って1個のパラメータを返す関数だけです。繰り返しになりますが、これはコンパイラの残りの部分を具体化するだけの問題だと思います。
-
現時点ではRubyを実行するときに
--rjit --rjit-disable
フラグを付ける必要があります。K0kubunのフィーチャーリクエストがリリースされればフラグは不要になります。 -
この概念実証JITコンパイラが動作するのは、現時点ではRubyの最新ヘッドコミットのみです。
制限事項がかなり多いのは承知していますが、平均的なEULAの文面ほど長くはないので、克服できないほどではありません。
とにかく今回は以上です。良い一日を!
原注
更新情報: Rubyヘッドの動きは早く、RJITが早速削除されました!本記事のスクリプトで遊んでみたい方は、Rubyのf32d5071b7b01f258eb45cf533496d82d5c0f6a1コミットをチェックする必要があります。
概要
CC BY-NC-SA 4.0 Deedに基づいて翻訳・公開いたします。
参考: Foreign function interface(FFI) - Wikipedia