世界を動かす技術を、日本語で。

Ruby 3.5における高速メモリアロケーション

概要

  • Ruby 3.5では オブジェクトの割り当て(allocation)が大幅に高速化 されました。
  • ベンチマーク により、特にキーワード引数の最適化効果が顕著であることが示されています。
  • Class#newの仕組み と、Ruby・C間の呼び出しコストの違いが解説されています。
  • インライン化やYJITの有効化 によるパフォーマンス向上の詳細が述べられています。
  • 本記事では 最適化の背景・技術的工夫 についても詳しく説明しています。

Ruby 3.5でのオブジェクト割り当て高速化

ベンチマークと測定方法

  • Rubyアプリケーションでは 多くのオブジェクト割り当て が発生することが一般的であることを前提とすること
  • Ruby 3.5では 割り当てパフォーマンスが最大6倍高速化 されていることを確認
  • ベンチマーク手法 として、引数の種類(位置引数・キーワード引数)とYJIT有効/無効を比較すること
  • initializeへの引数数を増やし、 パラメータ増加によるパフォーマンス変化 を測定すること
  • 1イテレーションで複数回オブジェクトを割り当て、 ループ処理の影響を最小化 し割り当てコストを強調すること

ベンチマーク結果の解釈

  • Ruby 3.5は 全ての割り当てタイプでRuby 3.4.2より高速 であること
  • 位置引数の場合、 パラメータ数に関わらず一定の高速化比率 (YJIT無効で1.8倍、YJIT有効で2.3倍)であること
  • キーワード引数の場合、 引数数が増えるほど高速化比率が向上 すること
    • 例:キーワード引数3つで3倍、YJIT有効時は6.5倍以上の高速化を確認

Class#newのボトルネック分析

  • Class#newは インスタンスの割り当てとinitializeの呼び出し のみを行うシンプルなメソッドであること
  • 最適化余地は 割り当て処理の高速化 または initialize呼び出しのオーバーヘッド削減 にあること
  • Rubyの仮想マシン(YARV)は スタックベース で値や引数を受け渡しすること
  • Ruby→C間の呼び出しは 呼び出し規約の違いによる変換コスト が発生すること
    • 位置引数はスタック→レジスタへのコピーのみで済むが、キーワード引数は Hash生成・設定が必要 となるため遅くなること

最適化の経緯と技術的工夫

  • ...(ドットドットドット)によるパラメータ転送は、以前は 余計なオブジェクト割り当てが発生 していたこと
  • ...の最適化により、 追加割り当てなしでパラメータ転送可能 となったこと
  • Class#newのRuby実装を試みるも、 インラインキャッシュのヒット率低下 などの課題で断念すること
  • YARV命令を追加し、 Class#newのインライン化 を実現することにより、実質的に高速化が可能となったこと
  • インライン化とは、 呼び出し先の処理コードを呼び出し元に展開する ことで、呼び出しオーバーヘッドを削減すること

YJITとの連携と今後の展望

  • YJIT有効時は さらなる高速化が可能 であること
  • 今回の最適化は 今後のRuby高速化の基盤 となる提案であること
  • Ruby 3.5以降、 オブジェクト割り当てが多いアプリケーションで顕著な効果 が期待できること

このように、Ruby 3.5では オブジェクト割り当ての根本的な最適化 がなされており、特に キーワード引数の利用が多いコードで劇的な高速化 が実現されています。今後のバージョンでも さらなるパフォーマンス向上の余地 が残されているため、Rubyユーザーにとって非常に有益なアップデートとなっています。

Hackerたちの意見

誰か説明してくれない?YJITは新しいZJITに置き換えられちゃうの?それとも、YJITの機能であるファストアロケーションみたいなのはZJITに持っていかれるのかな? https://railsatscale.com/2025-05-14-merge-zjit/

君のソースを読んだ感じだと、ZJITが準備できてYJITと同等になるまではYJITはまだ残ると思うし、その機能もちゃんと用意されるんじゃないかな。

この理由から、今のところYJITを維持し続けることにします。そしてRuby 3.5はYJITとZJITの両方を搭載します。同時に、ZJITをYJITと同等(機能とパフォーマンス)になるまで改善していきます。YJITはウォームアップが速くてメモリ使用量の増加も最小限だと思う。ZJITはもっと伝統的だから、YJITよりも速くなるはず。でも今のところの速度向上は、CをRubyに書き換えることから来ているのがほとんどだね。

放棄されているわけじゃなくて、新しいスタイルのコンパイラを評価するために焦点を移しているだけだよ。YJITはまだバグ修正やパフォーマンス改善があるし、ZJITは従来学校で教えられているタイプのメソッドベースのJITだ。YJITはレイジー基本ブロックバージョニング(LBBV)コンパイラなんだ。YJITの開発と展開で学んだことを使って、さらに良いJITコンパイラを作っているんだ。要するに、YJITのテクニックをZJITに取り入れるつもりだよ。> もしそうなら、YJITのFast Allocationsみたいな機能はZJITに持っていくの? 投稿からは明確じゃなかったかもしれないけど、この高速割り当て戦略は実際にはバイトコードインタープリタに実装されているんだ。JITコンパイラを使わなくてもスピードアップが得られるよ。この高速パスはすでにYJITに移植されていて、ZJITに実装中なんだ。

YJITが放棄されているようには全然聞こえないね。文脈を読み取ると、彼らは今、新しい開発のほとんどを他のJITに近くて開発しやすい、あまり実験的でないアーキテクチャに投資したいみたいだけど、これがリスクのある試みだと考えていて、長期的にこの投資が実を結ぶかどうかはわからないみたい。だからZJITを試すつもりだけど、YJITやその背後にあるアイデアは決して放棄されていないよ。ただ、リライトがメンテナンスを楽にしたり、長期的により良い結果を生むかどうかを見るために一時的に様子を見ているだけなんだ。

YjitはMaximeの基本ブロックバージョニングの博士論文(JS JIT)に基づいていて、両者が取ったアプローチは非常に動的型に焦点を当てているんだ。コードが構築されるときに型情報を基本的に伝播させる賢い方法だよ。主な利点は、比較的早く健全なJITが得られて、ほとんどの動的型シナリオでうまく機能することなんだ。彼らは(成功裏に?)より伝統的な方法にシフトしていて、まずインタープリターがコードをプロファイリングして(型を特定するために)から、より重い最適化を施したメソッド全体を生成するようにしているんだ。BBVアプローチはすぐに使えるけど、多くのコンパイラライターにはちょっと馴染みがないかもしれない(採用の問題?)し、あまり複雑さがない割にパフォーマンスの限界があるかもしれない。どの方法が勝つかの大きな問いは、Rubyコードが実際にはどれくらい「単一型」または「多型」なのかにかかっているんだ。単一型というのは、コンパイラの観点から見て、あるコードパスを通るのは一つの「実際の型」だけだってこと(だから、複数の型を許可するための余分な仕組みはあまり利益をもたらさない)。

ちょっと早とちりかもしれないけど、典型的なRailsアプリケーションでどのくらいの速度向上が期待できるんだろう。特にActive Recordに関して。

今のところここに違いはないね (https://speed.yjit.org/)。でもビルドは5月14日のだから、新しいビルドでは出てくるかもね?

今のところ、外から見るとRubyはRailsそのものだね。

ってことは、全てのRails/Active Recordコレクションがもっと速くなるってこと?

なんか、全ての言語がWASMみたいな方向に向かっている気がする。20年後には、WASMが全てのアプリがコンパイルできるデファクトプラットフォームになって、全てのOSがほぼネイティブに動かせるようになるのかな。WASIみたいな薄いレイヤーがあって、もっと便利になってるかも。

これってJVMのアイデアじゃなかったっけ?

2014年にここで予測された通りだね: https://www.destroyallsoftware.com/talks/the-birth-and-death...

これは1958年にUNCOLが議論された時からのアイデアだよ。https://en.wikipedia.org/wiki/UNCOL 1958年以降、バイトコードベースのプラットフォームは数え切れないほどあって、著名なXerox PARCシステムも含まれている(CPUはマイクロコード化されていて、起動時に関連する翻訳コードを読み込んでいた)けど、WASMが最初にそれをやっているってことが何度も取り上げられているんだ。Kubernetesクラスターやサーバーレスクラウドベンダーで動いているWASIコンテナを何と呼ぶか知ってる?アプリケーションサーバーだよ。https://en.wikipedia.org/wiki/Application_server

https://news.ycombinator.com/item?id=44057476

そこでは議論がないから、想像上のインターネットポイント以外はあまり価値がないね。

割り当てを速くすることにはずっと興味があったんだ。RubyからC関数を呼び出すとオーバーヘッドが発生することが分かっていて、そのオーバーヘッドは渡すパラメータのタイプによって変わるんだ。> トリプルドットのフォワーディング構文(...)を使うのは自然なことだと思ったんだけど。> 残念ながら、...を使うのはかなり高コストだった。> それで、...の最適化を実装することにしたんだ。素晴らしいヤクシェービングだね。言語に関わらず、発言することは良いニュースだよ、たとえ割り当てが速くなくても。

ありがとう!Class#newがうまくいってたらよかったけど、やってみたことに後悔はないよ。 :)

コードが同じタイプのオブジェクトを連続して何度も割り当てることは非常に稀だから、インスタンスローカル変数のクラスは頻繁に変わることになる。これは危険な考え方だよ、コンストラクタは二峰性分布になるからね。呼び出しやオブジェクトのグラフには、多くのユニークなオブジェクト、交互に現れるオブジェクトの層、または一種類のオブジェクトが大量に含まれることになる。たとえば、map関数は同じオブジェクトの束を返す傾向がある。中央値と平均がこうやって乖離すると、パフォーマンスについての考えが曖昧になるよ。インラインキャッシュはリスト内包表記での大量割り当てを速くするけど、DAGの作成を速くするわけじゃない。一つでもある方がないよりはマシだね。

一つでもある方がないよりはマシだね。必ずしもそうとは限らないよ。インラインキャッシュは安価だけど無料じゃないし、Class#newをCからRubyに移動させるコストもあるからね。99%のコストを犠牲にして1%を速くする価値はないかもしれない。> インラインキャッシュはリスト内包表記での大量割り当てを速くするけど、もしその内包表記が正確に一種類のオブジェクトを作るならの話で、二種類を作ると遅くなるし、ゼロ(データ抽出だけ)を作るなら何も効果がないよ。

例えば、マップ関数は同じオブジェクトの束を返す傾向があるよね。でも、もしその返されるオブジェクトを決定する過程で一時的なオブジェクトが作成されると、割り当てのシーケンスはまだ均一じゃないんだ。記事によると、Rubyでは名前付き引数でコンストラクタを呼び出すだけでも割り当てが発生するから、異なるタイプのオブジェクトを割り当てるサイクルに陥るのはすごく簡単なんだ。同時に、.new()の呼び出し元はほぼ常に同じクラスのインスタンスを作成している。ターゲットの式はほとんど常に定数名だから、これがその呼び出し元でのインラインキャッシュにとって良い候補になるんだ。

Rubyはどんどん良くなってるね。新しいプロジェクトをRubyで始めるのにためらわないよ。