`set!` と `array!` のメモリ割り当てとホットスポットを削減する最適化
set! と array! の呼び出しパスを見直し、メモリ割り当て数の削減と処理速度の改善を実現しました。JbuilderTemplate の DSL は頻繁に呼ばれるコードパスであるため、1回の呼び出しあたり数十バイトの削減でも積み重なると大きな効果をもたらします。
背景
本PRは、プロファイリングで特定された3つのホットスポットを解消するための変更です。いずれも個々の影響は小さいものの、JSON生成が頻繁に行われるアプリケーションでは累積的なコストになります。
先行する #598 では、_extract の内部呼び出しにおいて *args のスプラットを1回に抑えるプライベートメソッドを追加することでメモリ割り当てを削減しました。本PRはこのアプローチを _array と _set にも適用し、さらに2つの追加改善を組み合わせています。
特筆すべき点として、Jbuilder が BasicObject を継承していることが一部の問題の根本原因でした。通常のRubyオブジェクトであれば block_given? のオーバーヘッドは無視できますが、BasicObject ではモジュール解決のコストが発生するため、プロファイリングで顕在化しました。
技術的な変更
プライベートメソッド _set / _array によるスプラット削減
JbuilderTemplate#array! と JbuilderTemplate#set! が super を呼び出す際、*args のスプラットが二重に発生していました。この変更では、super の呼び出しを廃止し、Jbuilder 本体のロジックをプライベートメソッド _set / _array に切り出すことでスプラットを1回に削減しています。
変更前(jbuilder_template.rb):
def array!(collection = [], *args)
options = args.first
if args.one? && _partial_options?(options)
options = options.dup
options[:collection] = collection
_render_partial_with_options options
else
super
end
end
def set!(name, object = BLANK, *args)
options = args.first
if args.one? && _partial_options?(options)
_set_inline_partial name, object, options.dup
else
super
end
end
変更後(jbuilder_template.rb):
def array!(collection = EMPTY_ARRAY, *args, &block)
options = args.first
if _partial_options?(options)
options = options.dup
options[:collection] = collection
_render_partial_with_options options
else
_array collection, args, &block
end
end
def set!(name, object = BLANK, *args, &block)
options = args.first
if _partial_options?(options)
_set_inline_partial name, object, options.dup
else
_set name, object, args, &block
end
end
あわせて、array! のデフォルト引数がミュータブルな [] から EMPTY_ARRAY([].freeze)に変更されています。これにより、呼び出しのたびに空配列が新規割り当てされることを防いでいます。EMPTY_ARRAY は jbuilder.rb で定数として定義され、private_constant で外部から隠蔽されています。
::Kernel.block_given? から if block への置き換え
Jbuilder は BasicObject を継承しているため、block_given? を直接呼び出せず、::Kernel.block_given? という完全修飾形式が必要でした。PRのプロファイリングによると、この余分なモジュール解決がホットスポットとして観測されていました。
改善後は set! / array! に &block を明示的に受け取り、if block で存在を確認する方式に統一しました。BasicObject 環境における ::Kernel.block_given? の呼び出しコストを回避しつつ、ブロックの有無を直接判定できます。
args.one? ガードの削除
プロファイリングで one? の呼び出しもホットスポットとして現れました。one? はEnumerableの実装上O(n)の操作であり、if args.one? && _partial_options?(options) のような箇所では事前コストが発生していました。
PRの分析によると、args.one? の本来の意図は引数が1つの場合のショートサーキットでしたが、実際には「truthy な要素が1つ」を確認するセマンティクスであり、「引数が1個であること」の確認にはなっていませんでした。_partial_options? による判定で十分に代替できるため、args.one? の呼び出しは全て削除されています。partial! の args.one? ガードも同様に削除されました。
ベンチマーク結果
PRに添付されたベンチマーク結果(Ruby 3.4.5 + YJIT)から、主な改善数値は以下のとおりです:
-
json.set! :foo, :bar: スループット約1.23倍向上、メモリ割り当て 80 → 40 bytes(2倍削減) -
json.foo :bar(method_missing経由): スループット約1.54倍向上、メモリ割り当て 80 → 40 bytes(2倍削減) -
コレクション付き
set!: スループット約1.28倍向上、メモリ割り当て 1000 → 760 bytes -
属性リスト付き
set!: メモリ割り当て 440 → 240 bytes(約1.83倍削減) -
array!(単純): メモリ割り当て 80 → 40 bytes(2倍削減)
スループット改善が最も顕著なのは method_missing 経由のケースで、PRではその理由として set! 直接呼び出しとの違いを明確に説明していません。一方、args.one? 削除の効果はIPS上は誤差範囲内でしたが、メモリ割り当て削減の観点では効果が確認されています。
設計判断
プライベートメソッドによる内部処理の分離 というアプローチが、#598 で確立されたパターンとして一貫して適用されています。super を呼び出すと JbuilderTemplate → Jbuilder のコールスタックをたどる際に再度スプラットが発生するため、内部実装をプライベートメソッドに切り出して直接呼び出す設計が選択されました。公開インターフェース(set! / array!)のシグネチャは変更されていないため、既存の呼び出し側への影響はありません。
args.one? の削除については、意味論的な正確性の観点でも判断されています。args.one? は「引数が正確に1つ」ではなく「truthy な要素が正確に1つ」を返すため、引数チェックとしての役割を正確に果たしていませんでした。削除によって正確性とパフォーマンスの両方が改善されています。
まとめ
本PRは、プロファイリングデータに基づいて Jbuilder の内部実装を3点改善した変更です。スプラット回数の削減・BasicObject 環境固有の ::Kernel.block_given? コストの回避・誤ったセマンティクスを持つ one? ガードの削除という各改善が組み合わさることで、特に method_missing 経由のDSL呼び出しで顕著なスループット向上とメモリ割り当て削減を実現しています。