非STIモデルの`new`メソッドを最適化してRuby 4.0の高速化パスを有効化
ActiveRecord::Base.newが最適化され、STI(単一テーブル継承)を使用していないモデルで不要なSTIチェックが条件付きで除去されるようになりました。この変更により、Ruby 4.0の「fast path allocation」が有効化され、非STIモデルのオブジェクト生成が15~17%高速化されます。
背景
ActiveRecord::Baseは.newメソッドをオーバーライドして、STIモデル用のランタイムチェックを実装していました。このメソッドは「type」カラムを検査し、適切なサブクラスのコンストラクタを呼び出します。しかし、STIを使用していないモデルにとって、このチェックは不要なオーバーヘッドでした。さらに、Ruby 4.0では、オーバーライドされたメソッドが「fast path allocation」最適化の恩恵を受けられないという問題がありました。
ruby/ruby#13080で導入されたRuby 4.0の最適化は、Class#newがデフォルトのアロケータメソッドであることを検出すると、opt_new命令を使用してオブジェクトを直接スタックにプッシュします。しかし、Active Recordが.newをオーバーライドしているため、この最適化が適用されませんでした。
技術的な変更
activerecord/lib/active_record/inheritance.rb のnewメソッドが再構成され、STIを使用しないモデルでは不要なチェックが回避されるようになりました。
変更前:
def new(attributes = nil, &block)
if abstract_class? || self == Base
raise NotImplementedError, "#{self} is an abstract class and cannot be instantiated."
end
if has_attribute?(inheritance_column)
subclass = subclass_from_attributes(attributes)
if subclass.nil? && base_class?
subclass = subclass_from_attributes(column_defaults)
end
end
if subclass && subclass != self
subclass.new(attributes, &block)
else
super
end
end
変更後:
def new(attributes = nil, &block)
if abstract_class? || self == Base
raise NotImplementedError, "#{self} is an abstract class and cannot be instantiated."
end
if has_attribute?(inheritance_column)
subclass = subclass_from_attributes(attributes)
if subclass.nil? && base_class?
subclass = subclass_from_attributes(column_defaults)
end
if subclass && subclass != self
subclass.new(attributes, &block)
else
super
end
else
super
end
end
変更前のコードでは、if has_attribute?(inheritance_column)ブロックでサブクラスを決定した後、そのブロックの外側でif subclass && subclass != selfによる分岐が行われていました。変更後は、この分岐をif has_attribute?(inheritance_column)ブロック内に移動することで、STIを使用しないモデルではsubclass変数の判定すら行われなくなりました。これにより、STIを使用しないモデルでは、不要なsubclass_from_attributesの呼び出しだけでなく、その後の条件判定も完全に回避されます。
スキーマロード時の最適化
load_schema! と reload_schema_from_cache メソッドが追加され、スキーマロード時に.newメソッドの最適化が行われるようになりました。
def load_schema!
super
optimize_new_allocation
end
def reload_schema_from_cache(*)
if @_new_optimized
singleton_class.remove_method(:new)
@_new_optimized = false
end
super
end
optimize_new_allocation メソッドは、モデルがSTIを使用せず、.newが再定義されていない場合に以下の処理を実行します:
def optimize_new_allocation
return if _has_attribute?(inheritance_column)
return if singleton_class.method_defined?(:new)
return if @_new_optimized
define_singleton_method :new, Class.instance_method(:new)
@_new_optimized = true
end
この処理により、Class.newが直接呼び出されるようになり、Ruby 4.0の「fast path allocation」が有効化されます。スキーマのリロード時には、最適化が一旦解除され、再度optimize_new_allocationが実行される設計です。
テストカバレッジの拡充
activerecord/test/cases/fast_path_allocation_test.rb が新規追加され、以下のシナリオがテストされています:
- 非STIクラスが最適化されること
- 抽象スーパークラスを持つ非STIクラスが最適化されること
- STIクラスとそのサブクラスが最適化されないこと
-
.newを再定義したクラスが最適化されないこと - 動的に
.newを再定義した場合に最適化が解除されること
テストでは、Rubyがデバッグビルドの場合にopt_new_hitとopt_new_missのデバッグカウンタをチェックし、fast path allocationが正しく動作していることを検証します。
パフォーマンスへの影響
ベンチマーク結果によると、STIを使用せず.newを再定義していないモデルでは、以下の性能向上が確認されました:
Ruby 4.0.1:
- YJITなし: 1.15倍高速(68.7k vs 59.6k i/s)
- YJITあり: 1.17倍高速(197.2k vs 168.4k i/s)
Ruby 3.4.8:
- YJITなし: 1.13倍高速(77.5k vs 68.8k i/s)
- YJITあり: 1.06倍高速(218.3k vs 206.7k i/s)
Ruby 3.3.10:
- YJITなし: 1.10倍高速(68.8k vs 62.4k i/s)
- YJITあり: 1.09倍高速(173.6k vs 159.1k i/s)
STIモデルや.newを再定義したモデルでは、パフォーマンスの変化はほぼ誤差範囲内であり、後方互換性が維持されています。
設計判断
スキーマロード時の最適化 という設計が採用されました。load_schema!とreload_schema_from_cacheのフックポイントで最適化の適用と解除を制御することで、モデルの状態変化に応じた動的な最適化を実現しています。
@_new_optimizedフラグ により、最適化が既に適用されているかを追跡します。スキーマリロード時にはこのフラグをチェックし、最適化が適用されていた場合はsingleton_class.remove_method(:new)で一旦解除してから、再度最適化の適用可否を判断します。
条件分岐の最小化 も重要な判断です。optimize_new_allocationメソッドは3つの条件(STIの使用、.newの再定義、既に最適化済み)をチェックするだけで、最適化の適用可否を判断します。これにより、ランタイムでの不要な処理を排除しています。
まとめ
本PRは、STIを使用しないActive Recordモデルのオブジェクト生成を最適化し、Ruby 4.0の「fast path allocation」を活用可能にした変更です。スキーマロード時に.newメソッドを条件付きでClass.newに置き換えることで、不要なSTIチェックを除去し、15~17%の性能向上を実現しています。STIモデルや.newを再定義したモデルへの影響はなく、後方互換性を維持しながら、大多数の非STIモデルに最適化の恩恵をもたらす実装といえます。