2 ポイント 投稿者 GN⁺ 2025-02-14 | 1件のコメント | WhatsAppで共有
  • 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.strlenx.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-directstrlen-ruby の差は、スタックフレームの push/pop コストを示しており、この種のオーバーヘッド除去は YJIT のようなJITコンパイラが得意とする領域
  • strlen-cextstrlen-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件のコメント

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

    • 経験なしに言うなら、Python と Java がそれぞれ C に向かう FFI はずっと良さそうなので、小さな C ブリッジ で両者をつなぐのが一般解になるのではないかと思う
    • cgo is not Go も参考になる
      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
    • このような場合、キュー、ファイル、HTTP のような プロセス間通信 方式がどのあたりでうまくはまるのか気になる
  • Rails At Scale と byroot の記事のおかげで、Ruby の内部構造と性能を深く扱う記事を読むには本当に良い時期だ。最近の Ruby と Rails の改善まで考えると、Ruby 開発者でいるにもなかなか良い時期だ

    • 本当にそうなのかと思う。Ruby は下降傾向に見える https://www.tiobe.com/tiobe-index/ruby/
      特定の種類のアプリケーションでは今も人気があるが、全盛期はかなり前に過ぎたように見えるし、最近の改善は良いものの、2025 年に JIT が技術的にそれほど興味深いものなのかは分からない
  • 「サードパーティライブラリを呼び出す代わりに、外部関数を呼び出すために必要なコードをそのまま JIT できるのではないか?」というアプローチは、LuaJIT FFI の基盤に近いように見える: https://luajit.org/ext_ffi.html
    だから LuaJIT の FFI は非常に速いのだと思う

  • 「できるだけ多くをRubyで書け。特にYJITはRubyコードは最適化できるが、Cコードは最適化できない」という話がよく理解できない
    Rubyはかなり遅い言語ではないのか? ネイティブに降りるなら、できるだけ多くの部分をネイティブコードにしたくなりそうだ

    • JavaがまともなJITを備えつつあった時期に、こうした小さなドラマがあった
      あるメジャーリリースで、特定のUI要素の動作を処理するJavaコードがボトルネックだと判明し、次のメジャーリリースでCで書き直された
      その後JITが本当に有用になると、FFIのオーバーヘッドが、手でチューニングしたCコードとJITが生成するコードとの差よりも大きくなり、さらに次のメジャーリリースで再び純粋なJava実装へ戻された
      あの世代の言語としてはJavaのFFIはかなり速い部類だったが、数リリース後にはよりよい方式に変わった。そのころにはJava UIコードをあまり扱っていなかったので、それ以上は追っていない。同じ時期に、プラットフォーム別コードと通常のJava UIコードの間のインターフェースも整理していたため、最終的にどうなったのかは確かではない
      こうした作業では、このようなシーソー効果を常に注視する必要がある。いくつかのマイルストーンを待って手作業のチューニングを減らすほうがよいのか、政治的・技術的理由から今すぐ必要なのかを見極める必要がある
    • ここでJITが登場する。理想的には、JITがコードをよりよい状態に再最適化できる
      1回実行して終わる処理では非効率かもしれないが、長時間動作するデスクトップやサーバーのワークロードでは、アプリケーション全体の観点で見返りがある
      たとえばDalvikのJITはかなり弱く、数学関数はCを呼び出すほうが速かったが、ART以降はもはやその必要がなくなり、JITがC呼び出しのコストを上回れるようになった
      https://developer.android.com/reference/android/util/FloatMa...
    • JITやAOTコンパイラを備えたマネージド言語では、多くのコードをその言語で書くのが理想的な場合が多い。そうすることで、C呼び出しの境界を越えると不可能なインライン化やその他の最適化が可能になる
      こうした流れは時に「セルフホスティング」と呼ばれ、ブラウザでも、本来ならC/C++で書かれていそうな部分を権限付きJavaScriptへ移す形で広く活用されている。標準ライブラリのかなり大きな部分は、意外にもネイティブコードではない
    • FFIはコード同士の間に不透明で最適化できない境界を作る。このように頻繁に往復するコードはコストが大きい
      C#のようにはるかに高速で相互運用コストがほぼゼロに近い言語でも、依然として呼び出しコストはあり、場合によってはVMの状態フラグ変更やガベージコレクション切り替えのコストまで支払うことになる
      Ruby YJITが測定可能な要因になり始めたなら、上の規則もますます重要になるはずだ
    • RubyのeachメソッドがRubyで書き直された理由を、JPCamaraが見事に解説した記事がある: https://jpcamara.com/2024/12/01/speeding-up-ruby.html / https://bugs.ruby-lang.org/issues/20182
      tender loveによるボーナス記事もある: https://railsatscale.com/2023-08-29-ruby-outperforms-c/
      要するに、JITが勝つということだ
  • FFIはForeign Function Interface、つまりRubyからCを呼び出す方法である

    • 完全に安全で正気なアプローチは、実行時にコマンドラインでデータを受け取り、結果をコマンドラインやメモリページに吐き出すCコードを書くことだ
      その後Rubyからターミナル経由で、そのCプログラムをフラグやデータとともに実行すれば、RubyがCコードを実行できる
  • なぜこれをJITコンパイルしなければならないのか分からない。Cで書けるなら、ロード時にそのままコンパイルすることもできるのでは?

    • 何をCで書けるという意味なのか分からない。FFIライブラリは、ネイティブ拡張を書かなくてもRubyからライブラリメソッドを動的にバインドして実行できるようにするものだ
      これは生産性を大きく高め、同じコードをCRuby、JRuby、TruffleRubyで共有できるようにする
      起動時にすべてのバインディングを静的に把握できるなら、スタブを書いてメソッドテーブルに入れることはできるだろうが、それでもランタイムで起こることなのでJITに該当する。そしてシステム内を流れる型に適応できないため、受け入れる値や最適化について保守的にならざるを得ず、それは今日のlibffiが行っていることに近い
      AOTアプローチとは、ネイティブ拡張を書くことだ
  • 余談だが、たいていはFFIそのものよりも、FFIを使うgemを避けることになる。コンパイルが非常に面倒なことが多く、Rubygems/bundlerの中間段階を省いて直接ビルドするほうが簡単だった

  • 少し関連して、このライブラリはJVMCIを使い、JNIなしでネイティブライブラリを呼び出すarm64/amd64コードをその場で生成する: https://github.com/apangin/nalim

  • これって、まさに libffi がやっていることでは?

    • libffi は遅いし、私の知る限り JIT はしない
      libffi では関数のディスクリプタオブジェクトを作り、このランタイムデータ構造が引数と戻り値の型を表す
      FFI 呼び出し時には、渡したい値のポインタ配列とディスクリプタを渡す必要がある。内部ではおそらく、値の配列とディスクリプタを一緒に走査しながら、型に合わせて値をスタックに配置し、関数が終わると戻り値の型に応じて値を取り出すのだろう。この過程のあちこちに型ごとの分岐が入る可能性が高い
      たとえ libffi の呼び出しメカニズムが JIT されたとしても、引数配列を準備する作業は依然として遅い。中間配列を経由せずに引数へ直接アクセスする FFI JIT より直接的ではない
      FFI JIT コードは引数の値を直接受け取り、Ruby 型から C 型へ変換し、各値についてインラインコードでスタックやレジスタの正しい位置に入れてから関数を呼び出し、戻り値を Ruby 型に変換する。事実上、手で拡張コードを書いたのと似ている
      型推論があれば、変換コードで型チェックを省ける。たとえば arg1 が Ruby の文字列であることが保証されていれば、より高速な unsafe 版の変換関数を使える
      JIT コードは最悪の場合でも Ruby 型の程度を反映すればよく、引数関連の配列やリストは不要だ。どの C 型に変換するかはコードにハードコードされているため、ランタイムに C 側を説明するデータ構造を走査する必要がない
    • libffi は Ruby が何かを知らないので、Ruby 型をアンボックスする方法を知りようがない
      この記事の利点は、ユーザーが attach_function を呼び出したときに渡した情報に基づいて、型のアンボックス処理コードが生成された機械語コードの中に事実上キャッシュされる点にある
    • libffi は FFI 呼び出しを JIT せず、依然として引数の値を直接配置する必要がある。たとえば文字列引数なら、Ruby の文字列オブジェクトを C の文字列ポインタへ変換するコードを別途書かなければならない。そして libffi はかなり遅い
      兄弟コメントでリンクされている tramp.c は「逆方向 FFI」、つまり動的なユーザー処理を関数ポインタとして公開するためのもので、そこでの JIT は事前コンパイル済みコードを呼び出すための合計 3 命令程度にすぎない
    • libffi が何をしているのか分かっているつもりだったが、GOT みたいなものをいじっているのだと思っていた。でも君の言うことが正しいようだ
      https://github.com/libffi/libffi/blob/master/src/tramp.c