Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby 3.5でClass#newのアロケーションが6倍高速化される(翻訳)

概要

CC BY-NC-SA 4.0 International Deedに基づいて翻訳・公開いたします。

CC BY-NC-SA 4.0 Deed | 表示 - 非営利 - 継承 4.0 国際 | Creative Commons

日本語タイトルは内容に即したものにしました。

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が有効な場合と無効な場合の組み合わせを指定してベンチマークコードを実行すると、次のグラフを得られました。

Benchmark results graph

このグラフは、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関数は、関数本体で引数12をスタックに積んでからadd関数を呼び出しています。
呼び出されたadd関数が+という処理を行うためにパラメータを読み出すときには、スタックからabに相当する値を読み込みます。つまり、呼び出し元(call_add)で渡した値が、呼び出し先(add)のパラメータになるわけです。この動作について詳しくは、以下のZJIT導入に関する記事で取り上げています。

RubyにマージされたZJITの概要を理解する(翻訳)

この「呼び出し規約」の便利な点は、スタックに積んだパラメータがそのまま呼び出し先にパラメータとして渡される場合に、パラメータを別の場所にコピーする必要がないことです。仮に12という値が保存されているメモリアドレスを調べたとすると、abの値に使われるのと同じメモリアドレスになっていることがわかるでしょう。

🔗 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環境ではパラメータabがそれぞれ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!

関連記事

Ruby: FFIを高速化する小さなJIT(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。
OSZAR »