Techouse Developers Blog

テックハウス開発者ブログ|マルチプロダクト型スタートアップ|エンジニアによる技術情報を発信|SaaS、求人プラットフォーム、DX推進

RubyKaigi 2025 - Speeding up Class#new (Day2)

ogp

はじめに

クラウドハウス労務で2024年4月から長期インターンをしているWakiYuukuです。今回はRubyKaigi 2025の二日目に行われたAaron Pattersonさんのセッション「Speeding up Class#new」の内容について紹介させていただきます。

Class#newをスピードアップする

Rubyではオブジェクト指向プログラミングの基本として、Class#newメソッドを使ってインスタンスを作成します。この基本的かつ頻繁に使用されるメソッドの最適化は、Ruby全体のパフォーマンス向上に直結します。この記事ではRuby 3.5(4.0)にてClass#newがどのようにして高速化されるかについて解説します。

なぜClass#newの最適化が重要なのか?

Class#newメソッドが最適化のターゲットとなる理由は主に二つあります。

  1. C言語実装で書かれている: 現在の実装はC言語で書かれており、YJITによる最適化の恩恵を受けられていない
  2. 使用頻度: Rubyが利用されるプロジェクトではnewメソッドによって多くのインスタンスが作成されるため、最適化の効果が大きい

Class#newの実装を理解する

実装を想像する

Aaron Pattersonさんは、どのような観点でコードを確認するべきかヒントを得るために、実装コードの確認や書き換えを行う前に、その実装を想像するようにしているそうです。それではClass#newメソッドの実装を想像してみましょう。

Class#newでは以下のような処理を行っていると想像できます。

  1. インスタンスのメモリへの割り当て
  2. initializeメソッドへの引数の受け渡し
  3. 初期化されたインスタンスを戻す

これをRubyコードで表現すると次のようになります:

class Class
  def new(...)
    obj = allocate
    
    obj.initialize(...)
    
    obj
  end
end

class Foo
  def initialize(a, b)
    :hello
  end
end

Foo.new(1, 2) # => #<Foo ...> 

実際のC言語による実装

実際の実装はRubyのobject.cファイル内にあるrb_class_new_instance_pass_kw関数に書かれています:

VALUE
rb_class_new_instance_pass_kw(int argc, const VALUE *argv, VALUE klass)
{
    VALUE obj;

    obj = rb_class_alloc(klass);
    rb_obj_call_init_kw(obj, argc, argv, RB_PASS_CALLED_KEYWORDS);

    return obj;
}

rb_class_new_instance_pass_kwでは以下の処理が行われています。

  1. rb_class_allocでインスタンスのメモリ割り当てを行い
  2. rb_obj_call_init_kwinitializeメソッドを呼び出して
  3. 生成されたオブジェクトを返す

rb_class_new_instance_pass_kw は、C API の
rb_define_method(rb_cClass, "new", rb_class_new_instance_pass_kw, -1) によって組み込みクラス Class のクラスメソッド new に紐づけられます。
これにより Ruby で Class.new を呼び出すと、直接その C 関数が実行され、インスタンスの割り当てと initialize の呼び出しが行われます。

そのため、現状では、Ruby → C → Rubyという呼び出しフローとなっており、この言語間の切り替えがパフォーマンスに悪影響を与えています。

インラインキャッシュと呼び出し規約

インラインキャッシュの重要性

メソッド呼び出しを高速化するため、Rubyはインラインキャッシュという仕組みを使っています。これはメソッドへの参照をバイトコードにキャッシュすることで、メソッド探索の時間を短縮する技術です。

例えば以下のような多重継承が行われている場合:

class A: def baz; end end
class B < A; end
class C < B; end
class D < C; end
class E < D; end
class F < E; end
class G < F; end
#...

class Foo < X
  def bar
    self.baz
  end
end

この場合、bazメソッドの呼び出しでは継承チェーンを辿る必要があります。Rubyのメソッド探索は現在のクラスから先祖のクラスを順にたどることで行われるため、継承が深くなるほど探索時間が線形的に増加します。インラインキャッシュはこの問題を緩和します。

詳細はAaron Pattersonさんのブログ記事「Inline Caching in MRI」で解説されています。

インラインキャッシュとして保存することのできるメソッドは一つのみであるため、異なるクラスのメソッド呼び出しを交互に行うとキャッシュは無効化されます。

そのため、インラインキャッシュにヒットする場合とヒットしない場合ではパフォーマンスに大きな差が生まれます。インラインキャッシュがどれだけ重要かを示すベンチマークを見てみましょう。
always hit(インラインキャッシュが常にヒットする場合)とnever hit(インラインキャッシュが全くヒットしない場合)の二つのケースを比較します。

require 'benchmark/ips'

class A; def bar; end; end
class B; def bar; end; end

def run_it(obj)
  obj.bar
end

a = A.new
b = B.new

Benchmark.ips do |x|
  x.report('always hit') do
    16.times { run_it(a) }
  end

  x.report('never hit') do
    8.times { run_it(a); run_it(b) }
  end

  x.compare!
end

実行結果:

ruby 3.3.4 (2024-07-09 revision be1089c8ec) [arm64-darwin24]
Warming up --------------------------------------
          always hit   190.400k i/100ms
           never hit   128.839k i/100ms
Calculating -------------------------------------
          always hit      1.898M (± 6.5%) i/s  (526.76 ns/i) -      9.520M in   5.040259s
           never hit      1.327M (± 8.0%) i/s  (753.62 ns/i) -      6.700M in   5.102972s

Comparison:
          always hit:  1898387.9 i/s
           never hit:  1326926.8 i/s - 1.43x  slower

インラインキャッシュにヒットしない場合、約43%のパフォーマンス低下が発生しています。

呼び出し規約の影響

呼び出し規約は、メソッドへの引数と戻り値が格納される場所を定義するものです。Ruby内部ではスタックベースの呼び出し規約が使われています。

キーワード引数を使ったメソッド呼び出し:

def bar(a:, b:, c:)
  a + b + c
end

def foo
  bar(a: 5, b: 7, c: 9)
end

この場合のスタックの操作は以下のようになります:

# 引数をスタックにプッシュ
5 7 9 

# (引数との境目がわかるように|を入れています)
5 7 9 | 

# getlocal -3 で最初の引数を取得
5 7 9 | 5 

# getlocal -2 で2番目の引数を取得
5 7 9 | 5 7

# 加算してスタックに戻す
5 7 9 | 12

# Getlocal -1 で3番目の引数を取得
5 7 9 | 12 9

# 加算
5 7 9 | 21

# を戻り値として返す
5 7 9 | 21

キーワード引数は順序が自由に変更できるため、実際の呼び出しでは引数の並び替えが必要になることがあります:

def foo
  bar(c: 9, a: 5, b: 7)
end

この場合、第一引数がcとなっているため呼び出し規約に合わせるためスタック上での引数の並べ替えが発生します:

# キーワード引数をプッシュした状態
9 5 7 |

# パラメータの順序の並べ替えが発生する
5 7 9 |

# 以降は同様に処理

言語の壁による影響

Ruby/C API を介して C をコールするときにはオーバーヘッドが発生します。これを Aaron Patterson さんの表現では "Language Barrier" (言語の壁)と呼ばれています。 C 言語にはキーワード引数やスプラット展開の仕組みがないため、Ruby 側の引数を C のプリミティブな型や配列、ハッシュに変換してから C API 呼び出しを行う必要があります。

  • Rubyから Cへの引数渡し: スタック上の値をハッシュに変換しCへと渡す
  • Cから Rubyへの呼び出し: Cの戻り値をRubyのスタックに戻す

この変換には時間とメモリ消費が伴い、パフォーマンスに悪影響を与えます。

Class#newのRuby実装への書き換え

「言語の壁」のオーバーヘッドを解決するために、元々C言語で書かれていたClass#newをRuby言語自身で実装する試みが行われました。この過程では複数の課題が明らかになり、段階的に解決されていきました。

最初のアプローチ

最初に提案された実装は非常にシンプルでした。

class Class
  def new(...)
    instance = allocate
    instance.initialize(...)
    instance 
  end
end

しかしこの実装にはいくつか問題があります。

課題と解決策

1. initializeがprivateメソッドであること

initializeはプライベートメソッドであるため直接呼び出せないという最初の問題が発生しました。これを解決するためにsendメソッドを使用する方法が試みられました。

class Class
  def new(...)
    instance = allocate
    instance.send(:initialize, ...)
    instance 
  end
end

2. BasicObjectにsendメソッドが定義されていない

しかし、新たな問題としてBasicObjectにはsendメソッドは存在しないためundefined methodエラーが発生しました。これを解決するために、Rubyコンパイラが特別に処理できるメソッドPrimitive.send_delegate!が導入されました。

class Class
  def new(...)
    obj = allocate    
    Primitive.send_delegate!(
      obj,
      :initialize, 
      ...
    )
    obj
  end
end

3. allocateのモンキーパッチ問題

最後に、allocateメソッドがモンキーパッチされる可能性があるという問題に対処するため、新しいPrimitiveメソッドrb_class_alloc2が導入されました。

class Class
  def new(...)
    obj = Primitive.rb_class_alloc2    
    Primitive.send_delegate!(
      obj,
      :initialize, 
      ...
    )
    obj
  end
end

これらの段階を経て、Class#newメソッドはCからRuby実装へ移行されました。
この Rubyによる実装だけで、元のC実装に比べて約14%のパフォーマンス向上が得られました。
詳細はRuby COMMUNITY CONFERENCEでの発表「[EN] Speeding up Class#new - Aaron Patterson」を視聴してください。

更なる高速化

ここまでの内容はClass#newの実装をRubyに書き換えることによるパフォーマンス向上についての話題でしたが、さらにパフォーマンスを向上させるための方法も紹介されました。

RubyをRubyなしで書き換える

Rubyで書かれたコードはRubyのVM上で実行されるため、YARVの命令列に変換されます。 そのため初めからClass#newの実装をYARV命令にコンパイルすることで高速化を行うことができます。 現在はClass#newの高速化に向けてこれまでに紹介したC言語をRubyで書き換える方法から、直接YARV命令に書き換える方法で実装が行われています。
(この内容はClass#newの実装後にKoichi Sasadaさんの助言により生まれたそうです。)

実際の変更の内容はInline Class#newのプルリクエスト記述されていました。

以下は既存のClass#newのYARV命令です。
Ruby3.4ではClass#newにあらかじめ定義されているrb_class_new_instance_pass_kwをYARV命令opt_send_without_blockで呼び出すことで実行していました。

> ruby --dump=insns -e'Object.new'
== disasm: #<ISeq:<main>@-e:1 (1,0)-(1,10)>
0000 opt_getconstant_path                   <ic:0 Object>             (   1)[Li]
0002 opt_send_without_block                 <calldata!mid:new, argc:0, ARGS_SIMPLE>
0004 leave

Ruby 3.5では、Class#new 呼び出しを、 直接YARV命令に書き換える方法で実装が行われています命令 opt_new に置き換えることで、従来のCのメソッドであるrb_class_new_instance_pass_kwの 呼び出しを省略し、インスタンスのメモリ割り当てと initialize メソッドの呼び出しを高速に実行できるようになりました。
opt_newClass#newの高速化のために新たに追加されるYARV命令であり、「インスタンス割り当て」のみを担当します。
その後の initialize は続く opt_send_without_block :initialize 命令で呼び出されます。
さらに、Class#new がオーバーライドされている場合には、opt_new からフォールバック先の opt_send_without_block :new(通常のメソッド探索経路)へ jump 命令で分岐するようになっています。

> ./miniruby --dump=insns -e'Object.new'
== disasm: #<ISeq:<main>@-e:1 (1,0)-(1,10)>
0000 opt_getconstant_path                   <ic:0 Object>             (   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

YARV命令をインラインで埋め込むことによる高速化の効果

実際にYARV命令をインラインで埋め込むことで、Ruby3.5+inlineによるClass#newの実行速度はRuby3.4と比較しておよそ1.8倍早くなるそうです。

インライン化のデメリット

メモリ使用量が増加する
Ruby3.4では544Byteのメモリを使用していたが、Ruby3.5+inlineでは656Byteのメモリを使用するようになり約121Byte増加したそうです。
しかしこの増加はRubyコード全体を通してはわずかな変化であるとのこと。

スタックトレースが変更される
以下のようなコードを実行した場合、Ruby3.4とRuby3.5+inlineではスタックトレースが異なります。

  class Foo
    def initialize
      puts caller
    end
  end

  def hello
    Foo.new
  end
  hello

スタックトレースにClass#newが表示されなくなります。

  > ruby test.rb  
  test.rb:8:in 'Class#new' <= Ruby3.5+inlineでは表示されなくなる  
  test.rb:8:in 'Object#hello'  
  test.rb:11:in '<main>'

感想

RubyKaigi 2025の予習で読んだ「Rubyのしくみ -Ruby Under a Microscope」の内容がとても理解の役に立つ内容でした。
Rubyのコンパイラにはまだまだ最適化の余地があることを実感でき、もっとRubyの内部実装について学びたいと思えるとても刺激的なセッションでした!!

Aaronさんとのツーショット


Techouseでは、社会課題の解決に一緒に取り組むエンジニアを募集しております。 ご応募お待ちしております。

jp.techouse.com