- Rubyでネイティブ関数を呼び出す際、FFI は便利だが、
strlen ベンチマークではC拡張より呼び出しオーバーヘッドが大きく、性能ボトルネックになる
- 基準ベンチマークでは
String#bytesize の直接呼び出しは 39.879M i/s、C拡張は 30.661M i/s、Rubyの間接呼び出しは 28.697M i/s、FFIは 15.682M i/s で最も遅かった
- 核心となるアイデアは、
attach_function の時点ですでに分かっている関数名・引数型・戻り値型を使って、外部関数呼び出し用の機械語をランタイムに生成すること
- 概念実証 FJIT は Ruby 3.5.0dev の ARM64 環境で
strlen 呼び出しを 32.508M i/s まで引き上げ、FFIより2倍以上高速で、C拡張よりもわずかに速かった
- 現時点では ARM64、単一引数・単一戻り値、限定された型、
--rjit --rjit-disable、特定の Ruby head コミット依存という制約があり、実運用には実装の拡張が必要
RubyでFFIが遅くなる箇所
- 基本方針は Ruby コードをできるだけ多く書き、どうしても必要な場合にのみネイティブコードを呼び出すこと
- YJIT は Ruby コードを最適化できるが、Cコードは最適化できない
- ネイティブライブラリが必要なら、実際の関数の周囲に薄いC拡張ラッパーを置き、ほとんどの処理は Ruby で行うのが理想的
- このような単純なAPIは FFI と相性が良いが、既存の FFI は C拡張と同等の性能を出せない
strlen 基準ベンチマーク
- 比較対象は4つ
- FFIでCの
strlen を呼び出す
- Rubyメソッド
B.strlen で x.bytesize を間接呼び出しする
strlen Ruby Gem で作ったC拡張を呼び出す
str.bytesize を直接呼び出す
- Ruby 3.5.0dev、ARM64 環境での結果:
ruby-direct: 39.879M i/s, 25.08 ns/i
strlen-cext: 30.661M i/s, 32.61 ns/i, 直接呼び出しより1.30倍遅い
strlen-ruby: 28.697M i/s, 34.85 ns/i, 直接呼び出しより1.39倍遅い
strlen-ffi: 15.682M i/s, 63.77 ns/i, 直接呼び出しより2.54倍遅い
String#bytesize の直接呼び出しが最も速く、追加の 間接呼び出し はさらにオーバーヘッドを増やす
ruby-direct と strlen-ruby の差は、スタックフレームの push/pop コストを示しており、この種のオーバーヘッド除去は YJIT のようなJITコンパイラが得意とする領域
strlen-cext と strlen-ffi の差は、FFIでネイティブ関数を呼ぶ際の追加コストが大きいことを示している
FFI呼び出しをJITで置き換える方法
attach_function :strlen, [:string], :int の呼び出し時点で、必要な情報はすでに揃っている
- 呼び出す関数名:
strlen
- 引数型: string
- 戻り値型: int
- この情報を使えば、Rubyの値をネイティブ型に展開し、外部関数を呼び出した後、戻り値を再び Ruby オブジェクトに包む 機械語コード を生成できる
- 必要な構成要素は3つ
- 機械語を生成するだけでは不十分で、Ruby がその機械語へジャンプできなければ FFIオーバーヘッド を飛ばせない
RJITを活用する経路
- RJIT は Ruby で書かれた Ruby 用JITコンパイラで、Ruby とともに配布されている
- 内部構造は YJIT に似ているが、本番利用を目標としたものではないため、YJIT ほど広く知られてはいない
- Kokubun は RJIT を Gem として切り出す 機能要望 を提出している
- この提案は、サードパーティ製 Ruby JIT コンパイラを容易に作れるようにするため、2つの基盤を提供する
- RJIT を Gem に分離する
- Ruby内部型を Ruby のデータ構造として生成し、サードパーティJITが Ruby のデータ型を包んだり展開したりするのに必要な情報を得られるようにする
- もう1つの変更は、JITエントリ関数ポインタ が存在すれば常にそれを実行するようにすること
- サードパーティJITが機械語を登録すれば、Ruby は自動的にそのコードへジャンプできる
- この2つがあれば、FFIインターフェースの役割を果たす、小さく単一目的のJITコンパイラを作れる
FJITの概念実証
- FJITの概念実証 は “FFI JIT” の略で、ランタイムに外部関数を呼び出す機械語を生成する
- 例では FFI に似たインターフェースで
strlen を結び付ける
module C
extend FJIT
attach_function :strlen, [:string], :int
attach_function が呼ばれると、FJIT は Ruby 文字列を展開し、C の strlen を呼び出し、文字列長を Ruby オブジェクトとして返す機械語を生成する
FJITベンチマーク結果
- Ruby 3.5.0dev、
+RJIT +PRISM、ARM64 環境での結果:
ruby-direct: 41.907M i/s, 23.86 ns/i
strlen-fjit: 32.508M i/s, 30.76 ns/i, 直接呼び出しより1.29倍遅い
strlen-cext: 29.778M i/s, 33.58 ns/i, 直接呼び出しより1.41倍遅い
strlen-ruby: 28.851M i/s, 34.66 ns/i, 直接呼び出しより1.45倍遅い
strlen-ffi: 15.629M i/s, 63.98 ns/i, 直接呼び出しより2.68倍遅い
String#bytesize の直接呼び出しは依然として最速
- FJIT が生成した機械語は2番目に速く、
strlen のC拡張よりわずかに良い結果だった
- FJIT は FFI 呼び出しより 2倍以上高速 で、Ruby の間接呼び出しよりも速い
- この結果は、「Rubyをできるだけ多く書く」という方針を維持しつつ、C拡張と同等またはそれ以上の速度を得られる可能性を示している
実運用前に残る制約
- 概念実証JITコンパイラは現在 ARM64プラットフォーム に限定されている
- 実装を実用レベルに広げるには x86_64 バックエンドの追加が必要
- まだすべての引数型と戻り値型を扱えるわけではない
- すべての引数型のサポートは可能で、作業量も過大ではなさそうだと見られている
- 現時点では単一の引数を受け取り、単一の値を返す関数しか扱えない
- 現状では Ruby を
--rjit --rjit-disable フラグ付きで実行する必要がある
- Kokubun の機能が入れば、この条件は不要になる見込み
- 概念実証は当時の current Ruby head でしか動作しなかった
- 更新時点では Ruby head から RJIT が削除されているため、スクリプトを実行するには Ruby を
f32d5071b7b01f258eb45cf533496d82d5c0f6a1 コミットにチェックアウトする必要がある
1件のコメント
Hacker News のコメント
Java の制約ソルバー Timefold で、CPython に定義された関数を呼び出せるようにするため FFI をかなり扱ったが、性能問題の大半はホスト言語と外部言語の間を プロキシ 経由で行き来するときに発生した
JNI や新しい外部インターフェースで直接 FFI 呼び出しを行うと、Java メソッドの直接呼び出しに近いほど高速だが、CPython と Java のガベージコレクタは互いに相性が悪く、同期には黒魔術が必要になる
一方、JPype や GraalPy のようなプロキシでは、引数と戻り値の変換が必要で、逆方向の FFI 呼び出しまで追加で発生することがある。CPython オブジェクトを Java に渡すと Java がそのオブジェクトのプロキシを作り、そのプロキシを再び CPython に渡すと、展開せずにプロキシのプロキシを作ってしまう
結果として、JPype のプロキシは CPython を FFI で直接呼び出すより 1402% 遅く、GraalPy のプロキシは 453% 遅かった
最終的には CPython バイトコードを Java バイトコードに変換し、使用された CPython クラスに対応する Java データ構造を生成することで、プロキシ比で 100 倍高速になった。付け加えると、CPython バイトコードは非常に不安定で、ドキュメントも乏しく、VM の特性も厄介で、別のバイトコードへ直接マッピングしにくいため、変換したり読もうとしたりしないほうがよい
詳細は記事にまとめてある: https://timefold.ai/blog/java-vs-python-speed
Go コードと C コードは、アドレス空間、シグナルハンドラ、スレッド TLS スロットのようなリソースをどう共有するかをすり合わせる必要があるが、実際には Go が C コードの前提を迂回しなければならない、という意味合いに近い。C コードは単一スレッドでしか動かないと仮定していたり、マルチスレッド環境にまったく備えていなかったりすることもある
C は Go の呼び出し規約や成長するスタックを知らないため、C コードを呼び出すにはゴルーチンのスタック詳細情報を記録して C スタックへ切り替えたうえで、自分がどのように呼び出されたのかも Go ランタイム全体も知らない C コードを実行しなければならない
Python、JNI を使う Java、libffi を使う言語、cgo を使う Go のいずれで C コードをバインドしたりラップしたりしても、結局は C の世界 で生きることになる
https://dave.cheney.net/2016/01/18/cgo-is-not-go / https://archive.vn/GZoMK
Rails At Scale と byroot の記事のおかげで、Ruby の内部構造と性能を深く扱う記事を読むには本当に良い時期だ。最近の Ruby と Rails の改善まで考えると、Ruby 開発者でいるにもなかなか良い時期だ
特定の種類のアプリケーションでは今も人気があるが、全盛期はかなり前に過ぎたように見えるし、最近の改善は良いものの、2025 年に JIT が技術的にそれほど興味深いものなのかは分からない
「サードパーティライブラリを呼び出す代わりに、外部関数を呼び出すために必要なコードをそのまま JIT できるのではないか?」というアプローチは、LuaJIT FFI の基盤に近いように見える: https://luajit.org/ext_ffi.html
だから LuaJIT の FFI は非常に速いのだと思う
「できるだけ多くをRubyで書け。特にYJITはRubyコードは最適化できるが、Cコードは最適化できない」という話がよく理解できない
Rubyはかなり遅い言語ではないのか? ネイティブに降りるなら、できるだけ多くの部分をネイティブコードにしたくなりそうだ
あるメジャーリリースで、特定のUI要素の動作を処理するJavaコードがボトルネックだと判明し、次のメジャーリリースでCで書き直された
その後JITが本当に有用になると、FFIのオーバーヘッドが、手でチューニングしたCコードとJITが生成するコードとの差よりも大きくなり、さらに次のメジャーリリースで再び純粋なJava実装へ戻された
あの世代の言語としてはJavaのFFIはかなり速い部類だったが、数リリース後にはよりよい方式に変わった。そのころにはJava UIコードをあまり扱っていなかったので、それ以上は追っていない。同じ時期に、プラットフォーム別コードと通常のJava UIコードの間のインターフェースも整理していたため、最終的にどうなったのかは確かではない
こうした作業では、このようなシーソー効果を常に注視する必要がある。いくつかのマイルストーンを待って手作業のチューニングを減らすほうがよいのか、政治的・技術的理由から今すぐ必要なのかを見極める必要がある
1回実行して終わる処理では非効率かもしれないが、長時間動作するデスクトップやサーバーのワークロードでは、アプリケーション全体の観点で見返りがある
たとえばDalvikのJITはかなり弱く、数学関数はCを呼び出すほうが速かったが、ART以降はもはやその必要がなくなり、JITがC呼び出しのコストを上回れるようになった
https://developer.android.com/reference/android/util/FloatMa...
こうした流れは時に「セルフホスティング」と呼ばれ、ブラウザでも、本来ならC/C++で書かれていそうな部分を権限付きJavaScriptへ移す形で広く活用されている。標準ライブラリのかなり大きな部分は、意外にもネイティブコードではない
C#のようにはるかに高速で相互運用コストがほぼゼロに近い言語でも、依然として呼び出しコストはあり、場合によってはVMの状態フラグ変更やガベージコレクション切り替えのコストまで支払うことになる
Ruby YJITが測定可能な要因になり始めたなら、上の規則もますます重要になるはずだ
eachメソッドがRubyで書き直された理由を、JPCamaraが見事に解説した記事がある: https://jpcamara.com/2024/12/01/speeding-up-ruby.html / https://bugs.ruby-lang.org/issues/20182tender loveによるボーナス記事もある: https://railsatscale.com/2023-08-29-ruby-outperforms-c/
要するに、JITが勝つということだ
FFIはForeign Function Interface、つまりRubyからCを呼び出す方法である
その後Rubyからターミナル経由で、そのCプログラムをフラグやデータとともに実行すれば、RubyがCコードを実行できる
なぜこれをJITコンパイルしなければならないのか分からない。Cで書けるなら、ロード時にそのままコンパイルすることもできるのでは?
これは生産性を大きく高め、同じコードをCRuby、JRuby、TruffleRubyで共有できるようにする
起動時にすべてのバインディングを静的に把握できるなら、スタブを書いてメソッドテーブルに入れることはできるだろうが、それでもランタイムで起こることなのでJITに該当する。そしてシステム内を流れる型に適応できないため、受け入れる値や最適化について保守的にならざるを得ず、それは今日のlibffiが行っていることに近い
AOTアプローチとは、ネイティブ拡張を書くことだ
余談だが、たいていはFFIそのものよりも、FFIを使うgemを避けることになる。コンパイルが非常に面倒なことが多く、Rubygems/bundlerの中間段階を省いて直接ビルドするほうが簡単だった
少し関連して、このライブラリはJVMCIを使い、JNIなしでネイティブライブラリを呼び出すarm64/amd64コードをその場で生成する: https://github.com/apangin/nalim
これって、まさに libffi がやっていることでは?
libffi では関数のディスクリプタオブジェクトを作り、このランタイムデータ構造が引数と戻り値の型を表す
FFI 呼び出し時には、渡したい値のポインタ配列とディスクリプタを渡す必要がある。内部ではおそらく、値の配列とディスクリプタを一緒に走査しながら、型に合わせて値をスタックに配置し、関数が終わると戻り値の型に応じて値を取り出すのだろう。この過程のあちこちに型ごとの分岐が入る可能性が高い
たとえ libffi の呼び出しメカニズムが JIT されたとしても、引数配列を準備する作業は依然として遅い。中間配列を経由せずに引数へ直接アクセスする FFI JIT より直接的ではない
FFI JIT コードは引数の値を直接受け取り、Ruby 型から C 型へ変換し、各値についてインラインコードでスタックやレジスタの正しい位置に入れてから関数を呼び出し、戻り値を Ruby 型に変換する。事実上、手で拡張コードを書いたのと似ている
型推論があれば、変換コードで型チェックを省ける。たとえば
arg1が Ruby の文字列であることが保証されていれば、より高速な unsafe 版の変換関数を使えるJIT コードは最悪の場合でも Ruby 型の程度を反映すればよく、引数関連の配列やリストは不要だ。どの C 型に変換するかはコードにハードコードされているため、ランタイムに C 側を説明するデータ構造を走査する必要がない
この記事の利点は、ユーザーが
attach_functionを呼び出したときに渡した情報に基づいて、型のアンボックス処理コードが生成された機械語コードの中に事実上キャッシュされる点にある兄弟コメントでリンクされている
tramp.cは「逆方向 FFI」、つまり動的なユーザー処理を関数ポインタとして公開するためのもので、そこでの JIT は事前コンパイル済みコードを呼び出すための合計 3 命令程度にすぎないhttps://github.com/libffi/libffi/blob/master/src/tramp.c