はじめに
クラウドハウス労務で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
メソッドが最適化のターゲットとなる理由は主に二つあります。
- C言語実装で書かれている: 現在の実装はC言語で書かれており、YJITによる最適化の恩恵を受けられていない
- 使用頻度: Rubyが利用されるプロジェクトでは
new
メソッドによって多くのインスタンスが作成されるため、最適化の効果が大きい
Class#newの実装を理解する
実装を想像する
Aaron Pattersonさんは、どのような観点でコードを確認するべきかヒントを得るために、実装コードの確認や書き換えを行う前に、その実装を想像するようにしているそうです。それではClass#new
メソッドの実装を想像してみましょう。
Class#new
では以下のような処理を行っていると想像できます。
- インスタンスのメモリへの割り当て
initialize
メソッドへの引数の受け渡し- 初期化されたインスタンスを戻す
これを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
では以下の処理が行われています。
rb_class_alloc
でインスタンスのメモリ割り当てを行いrb_obj_call_init_kw
でinitialize
メソッドを呼び出して- 生成されたオブジェクトを返す
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_new
はClass#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の内部実装について学びたいと思えるとても刺激的なセッションでした!!
Techouseでは、社会課題の解決に一緒に取り組むエンジニアを募集しております。 ご応募お待ちしております。