メモリ配置:同じアルゴリズムが十倍遅い原因はたいていキャッシュ

主記憶への一回のアクセスは数百サイクル、キャッシュ行の取得は一回です。逐次アクセスとランダムアクセスの差は次数を上回ることが多いです。

同じ処理を二通りに書くと十倍違うことがあります。原因は命令数ではなくメモリアクセスの型です。CPU は主記憶から 64 バイト(キャッシュ行一つ)を取るので、そのうち 8 バイトしか使わなければ残りは捨てた帯域になります。

逐次アクセスの価値

// 遅い:外側が列
let sum = 0;
for (let c = 0; c < N; c++)
  for (let r = 0; r < N; r++)
    sum += matrix[r][c];

二次元配列は行単位で格納されます。上の書き方は毎回一行分飛ぶので、ほぼ毎歩が新しいキャッシュ行です。ループを入れ替えます。

// 速い:外側が行
for (let r = 0; r < N; r++)
  for (let c = 0; c < N; c++)
    sum += matrix[r][c];

加算回数は同じですが、逐次版はキャッシュ行一つで 8 要素(8 バイト要素なら)を賄えます。N が大きいと 5 倍から 10 倍の差になります。

データ構造の大きさ

// 要素あたり 32 バイト
type Node = { value: number; a: number; b: number; c: number };

// 要素あたり 8 バイト
const values = new Float64Array(n);

100 万個の数値を型付き配列で持てば 8 MB、オブジェクト配列なら 32 MB 以上になり、走査のたびに 100 万回ポインタを追います。間接参照を減らすこと自体が最適化です。

AoS と SoA

同じデータを、オブジェクト配列(AoS)ではなくフィールドごとの平行配列(SoA)にする方が通常速くなります。

配置 向く場面
AoS 一つのオブジェクトの全フィールドを一度に読む
SoA 毎回少数のフィールドだけ読む、またはベクトル化する

合計を求めるだけなら、SoA は不要なフィールドをキャッシュに引き込みません。

最適化しなくてよい場合

  • データが数 KB でキャッシュに収まる
  • アクセスが頻繁でない(リクエストあたり数十行)
  • ボトルネックがネットワークやデータベース

まず計測してください。 プロファイル無しに「キャッシュに優しくない」と言うのは推測です。実際の性能問題では、メモリ配置の利益が一回の往復の削減に劣ることもよくあります。

現代の CPU は計算が極端に速く、メモリ待ちが極端に遅い。一次的な問いは「データをどう並べるか」で、「何手か」ではありません。

← 記事一覧に戻る

コメント

…