`_map_collection` の中間配列アロケーションを削減する最適化
_map_collection の実装を map + 配列減算から map + delete に変更することで、中間オブジェクトのアロケーション数を3から1に削減し、スループットを約1.14倍向上させました。
背景
#610 で filter_map を使ったパフォーマンス改善が提案されたことが、本PRのきっかけです。filter_map 案の検証を通じて、_map_collection のアロケーション特性を詳しく分析する契機が生まれました。
既存実装では collection.map { ... } - [BLANK] という形で [BLANK] という一時配列を毎回生成し、さらに減算の結果として新たな配列を生成していました。この処理ではアロケーション数が3オブジェクト(120バイト)に上り、コレクション処理のホットパスで不要なGCプレッシャーとなっていました。
PR作者がまず検証した filter_map 案は、コードの意図は明確になるものの、ベンチマーク上では元実装より 1.61倍遅い という予想外の結果となりました。- [BLANK] を定数 BLANKS = [BLANK].freeze に置き換える案も試みられましたが、アロケーション数は2オブジェクト(80バイト)にとどまり、速度改善にはつながりませんでした。
技術的な変更
_map_collection の実装を、配列の減算から破壊的な delete 呼び出しに変更しました。
変更前:
def _map_collection(collection)
collection.map do |element|
_scope{ yield element }
end - [BLANK]
end
変更後:
def _map_collection(collection)
collection = collection.map do |element|
_scope{ yield element }
end
collection.delete(BLANK)
collection
end
変更の核心は2点です。まず -[BLANK] という配列減算を廃止することで、一時配列オブジェクト([BLANK])と減算結果の新配列という2つのアロケーションが消えます。次に Array#delete を使うことで map が返した配列をそのまま破壊的に編集するため、最終的なアロケーション数は1オブジェクト(40バイト)となり、元実装の3分の1になります。
PR内のベンチマーク結果によると、_map_collection 単体では 1.14倍のスループット向上(178.86 ns/i → 156.86 ns/i)が確認されています。array! を使ったエンドツーエンドの計測でも同様に 1.14倍の改善 と、メモリアロケーションの 半減(160バイト → 80バイト)が示されています。
設計判断
Array#delete による破壊的変更を採用したことが、本PRの中心的な設計判断です。
map が返した配列は _map_collection 内のローカル変数としてのみ参照されており、呼び出し元には map 完了後に渡されます。そのため、破壊的な delete を適用しても呼び出し元への副作用は発生しません。filter_map が遅かった理由についてPR内で明示的な言及はありませんが、delete は既存配列を直接編集するだけであり、filter_map のような新配列の逐次構築と条件評価のオーバーヘッドを回避できます。
また、PR本文では「複雑なテンプレートでは恩恵は薄まるが、変更が小さく局所的なため実施する価値がある」と述べられています。変更行数はわずか4行の追加・2行の削除であり、テスト変更も不要なほど動作仕様に変化はありません。
まとめ
本PRは _map_collection における不要な中間配列の生成を排除することで、コードの意味論を変えずにメモリアロケーションを削減した局所的な最適化です。filter_map という直感的な代替案のベンチマークが予想外の劣化を示したという経緯から、Ruby内部における配列操作のコスト特性を改めて確認できる変更といえます。