`ActiveRecord::Relation`のextensionsマージ処理を最適化
ActiveRecord::Relation::Merger#merge_multi_valuesにおける拡張(extensions)のマージ処理に早期リターン条件を追加し、ほぼ常にno-opとなっていた差集合演算のコストを排除しました。
背景
プロファイリングによって、merge_multi_valuesがテストスイート全体のCPU時間の約4.9%を占めるホットスポットであることが判明しました。フレームグラフを詳細に見ると、消費サンプルのほぼ全量(8730サンプル中8693サンプル)が164行目の extensions = other.extensions - relation.extensions という単一の式に集中していました。
このコストの原因は、Relationsに拡張(extension)が付与されることが実際には稀であるにもかかわらず、マージのたびに必ず配列の差集合演算 (Array#-) が実行されていた点にあります。Array#- は新しい配列オブジェクトを生成するため、拡張が存在しない大多数のケースでも毎回アロケーションと比較が発生していました。
技術的な変更
activerecord/lib/active_record/relation/merger.rb の merge_multi_values メソッドに、other.extensions が空でない場合のみ差集合演算を実行するガード節が追加されました。
変更前:
extensions = other.extensions - relation.extensions
relation.extending!(*extensions) if extensions.any?
変更後:
unless other.extensions.empty?
extensions = other.extensions - relation.extensions
relation.extending!(*extensions) if extensions.any?
end
other.extensions.empty? のチェックは Array#empty? による単純な長さチェックであり、アロケーションを伴いません。extensionsを持たない通常のRelationでは差集合演算がスキップされるため、その後の extensions.any? チェックも不要になります。
PRに添付されたベンチマークによれば、この変更に相当するパターンで約2.41倍のスループット改善が確認されています。テストスイートでは、プロファイルから該当行が消え、継続的な高速化が観測されています。
設計判断
変更の影響を最小限に抑えた保守的なアプローチが採用されています。other.extensions.empty? を先行チェックすることで既存の処理フローを変えず、拡張が存在する場合のパスには一切手を加えていません。これにより、動作の等価性を保ちながらホットパスのみを最適化できます。
コードの変更量も4行追加・2行削除と最小限です。PRでは8-1-stableへのバックポートが提案されており、変更の安全性と影響範囲の小ささが設計判断に反映されています。
まとめ
「ほとんどのケースでは発生しない処理を毎回実行しない」という原則の適用例です。Array#- のような一見安価に見える演算も、高頻度の呼び出しパスに存在すると無視できないコストになることを、プロファイリング主導の改善として端的に示しています。