Ruby 3.5でClass#newのアロケーションが6倍高速化される(翻訳)
多くのRubyアプリケーションはさまざまなオブジェクトをメモリにアロケーション(allocation: 割り当て)します。このオブジェクトアロケーションを6倍高速化できるとしたらどうでしょうか?ぜひ続きをお読みください!
🔗 Rubyのアロケーションを高速化する
Ruby 3.5ではオブジェクトのアロケーションが従来よりも大幅に高速化されます。本記事ではまずベンチマークとグラフを示したいと思いますが、最後までお読みいただければ、この高速化をどのように実現したかについても解説いたします。
アロケーションのベンチマークを取得するうえで、YJITを有効にした場合と無効にした場合のパラメータの種別(位置パラメータとキーワードパラメータ)を比較することにします。
また、初期化時に渡すパラメータ数を順次増やすことで、パラメータ数が変わるとパフォーマンスにどのような影響が生じるかも見ていくことにします。
ベンチマークは基本的に以下のようなつくりになっていますが、完全なコードはその下で展開可能にしてあります。
class Foo
# パラメータが増えたときのパフォーマンスを測定する
def initialize(a1, a2, aN)
end
end
def test
i = 0
while i < 5_000_000
Foo.new(1, 2, N)
Foo.new(1, 2, N)
Foo.new(1, 2, N)
Foo.new(1, 2, N)
Foo.new(1, 2, N)
i += 1
end
end
test
▶完全なベンチマークコード(クリックで展開)
位置パラメータのベンチマーク:
N = (ARGV[0] || 0).to_i
class Foo
class_eval <<-eorb
def initialize(#{N.times.map { "a#{_1}" }.join(", ") })
end
eorb
end
eval <<-eorb
def test
i = 0
while i < 5_000_000
Foo.new(#{N.times.map { _1.to_s }.join(", ") })
Foo.new(#{N.times.map { _1.to_s }.join(", ") })
Foo.new(#{N.times.map { _1.to_s }.join(", ") })
Foo.new(#{N.times.map { _1.to_s }.join(", ") })
Foo.new(#{N.times.map { _1.to_s }.join(", ") })
i += 1
end
end
eorb
test
キーワードパラメータのベンチマーク:
N = (ARGV[0] || 0).to_i
class Foo
class_eval <<-eorb
def initialize(#{N.times.map { "a#{_1}:" }.join(", ") })
end
eorb
end
eval <<-eorb
def test
i = 0
while i < 5_000_000
Foo.new(#{N.times.map { "a#{_1}: #{_1}" }.join(", ") })
Foo.new(#{N.times.map { "a#{_1}: #{_1}" }.join(", ") })
Foo.new(#{N.times.map { "a#{_1}: #{_1}" }.join(", ") })
Foo.new(#{N.times.map { "a#{_1}: #{_1}" }.join(", ") })
Foo.new(#{N.times.map { "a#{_1}: #{_1}" }.join(", ") })
i += 1
end
end
eorb
test
このスクリプトは、渡すパラメータの数と型を変えて実行時間を測定します。ループ実行の影響を最小限に抑えながらオブジェクトアロケーションのコストを重視するため、ベンチマークでは繰り返しのたびに複数のオブジェクトをアロケーションしています。
パラメータが0~8個の場合、パラメータ種別が位置パラメータとキーワードパラメータの場合、YJITが有効な場合と無効な場合の組み合わせを指定してベンチマークコードを実行すると、次のグラフを得られました。
このグラフは、Ruby 3.4.2での実行時間をRuby 3.5での実行時間で割ることで算出された高速化率を示しています。つまり、値が1 未満の場合は速度が低下したことを表し、1を超える値は速度が向上したことを表します。
Ruby 3.5と Ruby 3.4.2を比較するときは、両方ともYJITを無効にした場合と、両方ともYJITを有効した場合について測定しました。つまり、「Ruby 3.5とRuby 3.4.2」、および「Ruby 3.5+YJITとRuby 3.4.2+YJIT」を比較することになります。
X軸は初期化時に渡したパラメータの個数、Y軸は高速化率を示しています。
- 青いバー: YJITなしの位置パラメータ
- 緑のバー: YJITありの位置パラメータ
- 灰色のバー: YJITなしのキーワードパラメータ
- 黄色のバー: YJITありのキーワードパラメータ
グラフを見て最初に気づくことは、どのバーも1より大きい、つまりRuby 3.5ではどのタイプのアロケーションもRuby 3.4.2より高速化されているということです。位置パラメータの高速化率は、パラメータ数にかかわらず一定のままです。
🔗 位置パラメータの速度比較
位置パラメータの場合、パラメータ数が少なくても多くても高速化率は一定です。
YJITを無効にすると、Ruby 3.5は常にRuby 3.4.2よりも約1.8倍高速です。
YJITを有効にすると、Ruby 3.5は常にRuby 3.4.2よりも約2.3倍高速です。
🔗 キーワードパラメータの速度比較
キーワードパラメータの結果には、もう少し興味深い点があります。
インタープリタとYJITのどちらについても、キーワードパラメータの個数が多いほど高速化率も増加しているのです。つまり、キーワードパラメータの個数が多いほど、この変更の効果は大きくなります。
初期化時に渡すキーワードパラメータが3つだけの場合、Ruby 3.5はRuby 3.4.2の3倍高速になり、YJITを有効にするとRuby 3.4.2の6.5倍以上高速になります。
🔗 Class#new
のボトルネック
最近の私はアロケーションの高速化、具体的にはClass#new
のアロケーションに関心がありました。なぜClass#new
のアロケーションが遅いのでしょうか?
Class#new
は非常にシンプルなメソッドであり、そこでやっている処理は「インスタンスのアロケーション」「initialize
メソッドに全パラメータを渡す」「アロケーションされたインスタンスを返す」だけです。
Class#new
を仮にRubyで実装するとしたら、以下のような感じになったでしょう。
class Class
def self.new(...)
instance = allocate
instance.initialize(...)
instance
end
end
この実装は2つの部分でできています。
1つ目は、allocate
で素のオブジェクトをアロケーションすることです。
2つ目はinitialize
メソッドを呼び出して、new
メソッドに渡された全パラメータを転送することです。
つまり、このClass#new
メソッドを高速化するには、オブジェクトのアロケーションを高速化するか、あるいはinitialize
メソッドの呼び出しを高速化する方法が考えられます。
allocate
を高速化することは、すなわちガベージコレクタ(GC)を高速化することです。GCを高速化することにも確かにメリットがありますが、ここでは実行時の側面、つまり、メソッド呼び出しのオーバーヘッドを削減することに注目したいと思います。
では、メソッド呼び出しが遅くなる原因は何でしょうか?
🔗 1: RubyからRubyのメソッドを呼び出す場合
Rubyの仮想マシンであるYARVは、値を処理するための一時記憶領域としてスタックを使っています。このスタックは、非常に大きなヒープ領域にアロケーションされた配列と考えることが可能です。YARVインストラクションを処理するたびに、このヒープ領域にアロケーションされた配列への読み書きが行われます。これは、関数間でパラメータを渡す場合も同様です。
Rubyで関数を呼び出すと、呼び出し元はパラメータをスタックにプッシュしてから、呼び出し先の関数を呼び出します。次に、呼び出し先の関数はスタックからパラメータを読み取って必要な処理を実行し、呼び出し元に戻ります。
def add(a, b)
a + b
end
def call_add
add(1, 2)
end
たとえば上のコードでは、呼び出し元であるcall_add
関数は、関数本体で引数1
と2
をスタックに積んでからadd
関数を呼び出しています。
呼び出されたadd
関数が+
という処理を行うためにパラメータを読み出すときには、スタックからa
とb
に相当する値を読み込みます。つまり、呼び出し元(call_add
)で渡した値が、呼び出し先(add
)のパラメータになるわけです。この動作について詳しくは、以下のZJIT導入に関する記事で取り上げています。
この「呼び出し規約」の便利な点は、スタックに積んだパラメータがそのまま呼び出し先にパラメータとして渡される場合に、パラメータを別の場所にコピーする必要がないことです。仮に1
と2
という値が保存されているメモリアドレスを調べたとすると、a
とb
の値に使われるのと同じメモリアドレスになっていることがわかるでしょう。
🔗 2: RubyからCのメソッドを呼び出す場合
残念ながら、C関数ではRuby関数のような呼び出し規約が使われていません。つまりRubyからC関数を呼び出すときは、メソッドパラメータをそれに対応するC関数の呼び出し規約に合わせて変換しなければなりません。
C関数のパラメータは、パラメータをレジスタまたはマシンスタックを経由して受け渡しします。つまり、RubyコードからC関数を呼び出す場合は、Rubyスタックに積んである値をレジスタにいちいちコピーする必要が生じるということです。逆にC関数からRuby関数を呼び出す場合も、同様にレジスタの値をRubyのスタックにコピーする必要があります。
呼び出し規約の変換はどちらの向きでも時間がかかるため、ここが最適化の対象となります。
RubyからC関数を呼び出すときに、位置パラメータについては直接レジスタにコピーできます。
static VALUE
foo(VALUE a, VALUE b)
{
return INT2NUM(NUM2INT(a) + NUM2INT(b));
}
# Cの`foo`関数を呼び出す
foo(1, 2)
上の例は、ARM64環境ではパラメータa
とb
がそれぞれX0レジスタとX1レジスタに対応します。このfoo
関数をRubyから呼び出すと、Rubyスタックから直接X0レジスタとX1レジスタにコピーされます。
残念ながら、キーワードパラメータの場合は単純にはいきません。C言語はキーワードパラメータをサポートしていないため、C関数にはキーワードパラメータをハッシュ形式で渡さなければならないのです。つまり、ハッシュをアロケーションし、パラメータをイテレートしてハッシュに設定する作業が発生するということです。
これは、Ruby 3.4.2で以下のプログラムを実行することで確認できます。
class Foo
def initialize(a:)
end
end
def measure_allocations
x = GC.stat(:total_allocated_objects)
yield
GC.stat(:total_allocated_objects) - x
end
def test
measure_allocations { Foo.new(a: 1) }
end
# インラインキャッシュはRubyオブジェクトなので
# 測定前に何度かウォームアップが必要
# したがって結果は厳密ではない
test # warmup
test # warmup
p test
上のプログラムをRuby 3.4.2で実行すると、test
メソッドがオブジェクトを2個アロケーションすることがわかります。1つはFoo
のインスタンス、もう1つはClass#new
のC実装にキーワードパラメータを渡すためのハッシュです。
🔗 アロケーションの高速化を実現する
まず、ここまでの経緯を簡単に説明しておきたいと思います。
私はかなり以前からアロケーションの高速化に関心がありました。RubyからC関数を呼び出すとオーバーヘッドが発生し、そのオーバーヘッドは、渡されるパラメータの種類によって異なっていることは当時からわかっていました。
そこで当初は、Class#new
をRuby言語で書き直すことを思いつきました。Class#new
は全パラメータをinitialize
に渡しているだけなので、例のトリプルドット構文...
でパラメータを転送するのが自然なやり方に思えたのです。なお、当時の試みの残骸は#9289にあります。
しかし残念ながら、...
はかなりコストが高いことが判明しました。...
は*, **, &
のシンタックスシュガーなので、これらのsplatパラメータを表現するために余計なオブジェクトがアロケーションされてしまいます。
これがきっかけで、...
の最適化を実装することにしました(#10510)。...
を最適化したおかげで、パラメータを転送するときに余分なオブジェクトがアロケーションされなくなったのです。この最適化は他の方面についても一般的に有用ですが、私の頭にあったのはClass#new
でした。
そして数か月後、ついに新しい最適化を用いたClass#new
をRubyで実装することに成功しました(#9289-diff)。
当初のベンチマーク結果も快調で、アロケーションの排除に成功し、new
からinitialize
へのパラメータ渡しによるコストも削減できました。しかし、呼び出し側でのインラインキャッシュミス(#9289-diff)が少々気がかりでした。
上のリンク先にあるClass#new
実装は少々複雑ですが、本質的には本記事冒頭に記載した以下の実装と同じです。
class Class
def self.new(...)
instance = allocate
instance.initialize(...)
instance
end
end
上のコードの問題点は、initialize
を呼び出す側のインラインキャッシュです。このinitialize
メソッドを呼び出すと、Rubyはその呼び出し先をキャッシュしようとします。これによって、呼び出し側が同種の場合に以後の呼び出しが高速化されます。
CRubyにあるのはモノモーフィックなインラインキャッシュだけなので、呼び出し側がどこであっても保存されるインラインキャッシュは1つだけです。このインラインキャッシュは、呼び出すメソッドを探索するのに使われますが、キャッシュの探索キーはレシーバーのクラスになります(ここではinstance
ローカル変数のクラス)。すると、レシーバーの型が変わるたびにキャッシュミスが発生するため、メソッド探索が低速になってしまいます。
まったく同じ型のオブジェクトを立て続けにアロケーションすることは極めてまれなので、instance
ローカル変数のクラスはたびたび変更されることになります。つまり、キャッシュヒット率が相当下がってしまう可能性があるのです。
仮に呼び出し側でキャッシュエントリを複数サポートしたとしても(「ポリモーフィック」インラインキャッシュ)、呼び出し側のカーディナリティがかなり高くなるため、キャッシュヒット率は引き続き極めて低くなってしまうでしょう。
このプルリクをYARVの作者であるKoichi Sasadaに見せたところ、Class#new
をRubyで実装し直すよりも、Class#new
実装を「インライン化」する新しいインストラクションをYARVに追加してどうかと提案してくれました。そこで私はJohn Hawthornと共同でこれを実装し、1週間も経たずにプロトタイプの実装を終えました。幸か不幸か、このプロトタイプはClass#new
のRuby実装バージョンよりも「ずっと」高速だったので、私のRuby実装バージョンは見捨てることに決めました。
🔗 Class#new
のインライン化とは
ところでインライン化(inlining)とは何でしょうか?
インライン化とは、言ってみれば呼び出し先のコードを呼び出し元にコピペすることで呼び出しを不要にすることです。
Foo.new
Rubyコンパイラは、上のようなコードを見つけると、通常のようなnew
メソッド呼び出しを生成する代わりに、そのnew
メソッドによって「実行されるはずの」インストラクションを(new
の呼び出し先ではなく)new
の呼び出し元に生成します。
話を具体的にするため、上のコードをインライン化する前と後のインストラクションを見比べてみましょう。
以下は、インライン化前のFoo.new
のバイトコードです。
> ruby -v --dump=insns -e'Foo.new'
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +PRISM [arm64-darwin24]
== disasm: #<ISeq:<main>@-e:1 (1,0)-(1,7)>
0000 opt_getconstant_path <ic:0 Foo> ( 1)[Li]
0002 opt_send_without_block <calldata!mid:new, argc:0, ARGS_SIMPLE>
0004 leave
以下は、インライン化後のFoo.new
のバイトコードです。
> ./ruby -v --dump=insns -e'Foo.new'
ruby 3.5.0dev (2025-04-29T20:36:06Z master b5426826f9) +PRISM [arm64-darwin24]
== disasm: #<ISeq:<main>@-e:1 (1,0)-(1,7)>
0000 opt_getconstant_path <ic:0 Foo> ( 1)[Li]
0002 putnil
0003 swap
0004 opt_new <calldata!mid:new, argc:0, ARGS_SIMPLE>, 11
0007 opt_send_without_block <calldata!mid:initialize, argc:0, FCALL|ARGS_SIMPLE>
0009 jump 14
0011 opt_send_without_block <calldata!mid:new, argc:0, ARGS_SIMPLE>
0013 swap
0014 pop
0015 leave
インライン化前のインストラクションは、Foo
定数を探索してからnew
メソッドを呼び出しています。
インライン化後のインストラクションでは、Foo
定数を探索する点は同じですが、new
メソッドを呼び出しておらず、代わりに別のインストラクションをいくつも実行しています。
置き換え後の新しいインストラクションのうち、最も重要なのはopt_new
インストラクションです。これは新規インスタンスをアロケーションしてから、そのインスタンスをスタックに書き込みます。そしてopt_new
インストラクションの実行直後には、initialize
メソッドの呼び出しが配置されています。
この一連のインストラクションによって、そのインスタンスのアロケーションとinitialize
メソッドの呼び出しが効率よく行われるようになり、Class#new
で行っていたのと「同等の」処理を、Class#new
を呼び出さずに行うようになります。
この実装の実に素晴らしい点は、スタックにプッシュされるあらゆるパラメータがスタックに残るので、それらをそのままinitialize
メソッドで利用できることです。Cの実装でいちいち行っていたパラメータのコピーが、丸ごと不要になったのです!Class#new
でスタックフレームへのプッシュ/ポップが不要になったおかげで、コードがさらに高速化されました。
最後に、new
呼び出しでは必ずinitialize
呼び出しも行われるため、キャッシュヒット率がClass#new
の純粋なRuby実装バージョンに比べてずっと高くなりました。initialize
の呼び出し側は1箇所ではなく、ありとあらゆるnew
呼び出しでもれなくinitialize
呼び出しが行われるからです。
この最適化による主な改善点は、「スタックフレームが不要になった」「パラメータのコピーが不要になった」「インラインキャッシュのヒット率が改善された」ことです。
🔗 インライン化の欠点
当然ながら、この最適化に欠点がないわけではありません。
第1に、インライン化すればインストラクション数が増加し、メモリ使用量も増加します。しかしメモリ使用量の増加は、new
を使う呼び出し側の個数にのみ比例します。私たちのモノリスで測定してみたところ、インストラクションシーケンスの量はわずか0.5%の増加にとどまりました。ヒープ全体のサイズ増加率はこれよりずっと小さく済みます。
第2に、この最適化によって後方互換性がわずかに失われます。
以下のコードで考えてみましょう。
class Foo
def initialize
puts caller
end
end
def test
Foo.new
end
test
上のコードをRuby 3.4で実行したときの出力結果は以下のようになります。
> ruby -v test.rb
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +PRISM [arm64-darwin24]
test.rb:8:in 'Class#new'
test.rb:8:in 'Object#test'
test.rb:11:in '<main>'
しかし同じコードをRuby 3.5で実行したときの出力結果は以下のようになります。
> ./ruby -v test.rb
ruby 3.5.0dev (2025-04-29T20:36:06Z master b5426826f9) +PRISM [arm64-darwin24]
test.rb:8:in 'Object#test'
test.rb:11:in '<main>'
Ruby 3.5ではClass#new
のフレームがなくなったため、Class#new
のフレームが出力されていないことがわかります。
🔗 まとめ
本記事をここまでお読みいただいた方は、このトピックに興味をお持ちいただけたかと思います。私と同様に、本年度末にリリースされるRuby 3.5を皆さんも心待ちにしていることを願っています!
インライン化とopt_new
インストラクションのアイデアを提供してくれたKoichi Sasadaと、実装を手伝ってくれたJohn Hawthornに感謝申し上げます。
ご興味がおありでしたら、プルリク#13080と、bugs.ruby-lang.orgの#21254もぜひご覧ください。本記事では実装のあらゆる詳細(クラスでないものに対してnew
メソッドを呼び出した場合の挙動など)までは解説しておりませんので、ご質問は私にメールまたはSNS経由でお気軽にどうぞ。
Have a good day!
概要
CC BY-NC-SA 4.0 International Deedに基づいて翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。