
2026/11/01 3:38
遅延こそが問題の正体だ
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
改良された要約
現代的な CPU は、処理速度の限界のために停止するのではなく、内部的な演算 (~4-8 指令/ns) に比べて遅い RAM アクセス (50–100ns) を過度に待っているために停止することが頻繁にあります。L1、L2、および L3 キャッシュが利用可能であっても、単純なデータ読み込みパターンの場合、CPU がこれらの高速キャッシュをミスし、冷たい DRAM にアクセスしてしまうことがよくあります。AMD EPYC 9124 プロセッサの単一コアでのテストにおいて、標準的な
GROUP BY クエリは、その時間の 50% 以上をメモリの待ち時間で行っており、DRAM から約 4.5GB を読み込んでいました。レディックスパーティショニングを実装することで(データを約 4,000 のパーティションに分割し、アクティブな HashMap が高速キャッシュ内に完全に収まるようにする)、このボトルネックは大幅に減少します。最適化アプローチにより、停止時間は半分以上削減されました (~50% から ~23% に低下) 、合計 DRAM リード量は 4.5GB から 1.5GB に減少し、冷たいメモリアクセスを高速なローカル演算に変換しました。この戦略により、ハードウェアアップグレードなしで単一コアにおいて複雑な集計クエリを 30–40% まで高速に実行することが可能になります;16 コアすべてにスケーリングされた場合、システムは標準的な Parquet 処理を超えるスーパースピードを実現できます。本文
CPU 高負荷時の最適化:キャッシュ効率とラジックスパージテーションによるアプローチ
はじめに:CPU 100% の真の意味
データベースで CPU 使用率が 100% を示しても、必ずしも全負荷で処理を行っているわけではありません。現代の CPU は複雑な機構を持ち、命令を同時並行実行することもあればアイドル状態になることもあります。
なぜ高負荷でも遅いのか?ボトルネックは「メモリー」
CPU の動作速度とメモリーアクセス速度には巨大な差があります。CPU がメモリー待ちをしている時間は「待機」ではなく、「潜在的な処理能力が未活用されている状態」です。
| メモリー階層 | 動作時間 (ns) | キャパシティ | 1 アクセスあたりの指令数 |
|---|---|---|---|
| L1 Cache | ~1 ns | ~64 KB | 4~8 指令 |
| L2 Cache | ~3–4 ns | ~1 MB | 15–30 指令 |
| L3 Cache | ~10–15 ns | 数百 MB 程度 | 50–100 指令 |
| DRAM | ~50–100 ns | GBs 規模 | 400–800 指令 |
- CPU は約 1 ナノ秒で数命令を処理できます。
- DRAM アクセスには 50〜100 ナノ秒かかり、CPU は数百もの命令完了できる時間を無駄にします。
問題:素朴なハッシュテーブルの非効率性
データベースでの
GROUP BY 演算に単純なハッシュテーブル(HashMap)を使う方法は一般的ですが、キャッシュ効率が極めて悪化するリスクを孕んでいます。
非効率な実装による挙動
- ハッシュテーブルを動的にリサイズします。
- レコードごとにキーチェックと更新を行います。
- ユニークグループが数百万ある場合、ハッシュテーブルは メインメモリー(RAM)全体 に膨張します。
- CPU は常時 メモリーアクセス待ち(Cold Miss) の状態になります。
デモンストレーション:素朴な実装の結果
- 環境: CherryServers (AMD EPYC 9124 Genoa, 単一コア利用)
- クエリ:
(約 9,900 万行)SELECT count(*), UserID FROM hits GROUP BY UserID - データ特性: 約 1,700 万のユニーク UserID が存在するため、ハッシュテーブルが巨大化します。
テスト実行コマンド
# 単一コアでの実行設定 taskset -c 3 pivot server --config config.yaml & # config.yaml (例): # workers: 1 # auth: trust # クエリの実行と計測 taskset -c 7 psql -h 127.0.0.1 -p 5440 -U pivot -c '\timing on' \ -c "SELECT count(*) FROM (SELECT COUNT(*), UserID FROM hits GROUP BY UserID)"
測定結果(素朴実装)
- 実行時間: 初回約 3.9 秒、2 回目約 3.6 秒
- DRAM リード量: 約 4.5 GB
CPU 内部状態 (perf stat
)
perf statPerformance counter stats for 'CPU(s) 3': 8364109137 ex_no_retire.load_not_complete # 50.1 % backend_bound_memory 38233214040 de_no_dispatch_per_slot.backend_stalls 8977647849 ex_no_retire.not_complete 11431618219 ex_ret_ops # 16.1 % retiring 4.030148989 seconds time elapsed
分析:
- メモリー待ち: CPU の約 50.1% を占める(アイドル状態に近い)。
- 実際の指令実行: 処理時間のうち僅か 16.1% に過ぎない。
ソリューション:ラジックスパージテーションによる最適化
CPU キャッシュ効果を最大化するには、データを小さなチャンクに分割して処理する必要があります。そのための技術が「ラジックスパージテーション」です。
アルゴリズムの仕組み
グループ分割を 2 つのフェーズに分けて実行します。
フェーズ 1:パーティション分割
全てのキーを 4,096 つ のパーティションに分割します。ハッシュ値の上から 12 ビット($2^{12} = 4096$)を使用してインデックスを決めます。
fn consume(&mut self, batch: RecordBatch, _sender: &mut dyn Sender<RecordBatch>) -> unary::Result<()> { let keys = batch.column(self.key_column).as_primitive::<Int64Type>(); for &key in keys.values() { let hash = self.hasher.hash_one(key); // ハッシュ値の上位 12 ビットを使用 (右シフト 52 ビット) let partition = (hash >> 52) as usize; self.partitions[partition].push(&mut self.allocator, (hash, key)); } Ok(()) }
- 1 つの巨大なテーブルではなく、4,096 つの小さな配列(SlabVec)にデータが分割されます。
フェーズ 2:パーティションごとの集計
全てのデータ到着後、各パーティションごとに小型のハッシュテーブルを作成して集計を行います。
fn into_outputter(self) -> unary::Result<Option<Self::Outputter>> { let mut groups = Vec::new(); for rows in &self.partitions { // 各パーティション用に小さな HashMap を作成 let mut table: HashTable<(u64, i64, u64)> = HashTable::new(); rows.for_each(|(hash, key)| { match table.entry(hash, |group| group.1 == key, |group| group.0) { Entry::Occupied(mut group) => group.get_mut().2 += 1, Entry::Vacant(slot) => { slot.insert((hash, key, 1)); } } }); groups.extend(table.into_iter().map(|(_, key, count)| (key, count))); } Ok(Some(Outputter::new(groups))) }
なぜ有効なのか?
- キャッシュヒット率の向上: ハッシュテーブルサイズが小さくなり、L1/L2 キャッシュに収まりやすくなります。
- メモリー待ちの削減: CPU が小さなデータセットをキャッシュ内で処理するため、DRAM アクセス頻度が劇的に減少します。
改善結果の確認
最適化後の実行結果と比較です。
テスト実行コマンド(最適化後)
taskset -c 7 psql -h 127.0.0.1 -p 5440 -U pivot \ -c "SELECT count(*) FROM (SELECT COUNT(*), UserID FROM hits GROUP BY UserID)"
測定結果(最適化後)
- 初回: 3.3 秒
- 2 回目(キャッシュヒット): 2.7 秒
CPU 内部状態の改善 (perf stat
)
perf statPerformance counter stats for 'CPU(s) 3': 3790955223 ex_no_retire.load_not_complete # 23.5 % backend_bound_memory 20376118172 de_no_dispatch_per_slot.backend_stalls 5442424751 ex_no_retire.not_complete 19350459182 ex_ret_ops # 32.2 % retiring 3.575992317 seconds time elapsed
比較分析:
- メモリー待ち: 50.1% から 23.5% に低下(半分以下)。
- 指令実行率: 16.1% から 32.2% に向上(約 2 倍)。
- DRAM リード量: 4.5 GB から 約 1.5 GB に削減。
結論
CPU が 100% を使用している場合でも、メモリーアクセス待ちがボトルネックになっているケースは多いです。素朴な実装では CPU キャッシュを活用せず、高速 RAM への依存度が高まります。
ラジックスパージテーションなどのテクニックでデータをキャッシュ収容サイズに分割することで:
- DRAM アクセスを大幅削減できます。
- CPU の真の処理能力を引き出せます。
- 単一コアでも約 2.7 秒 という高性能を実現可能です。
Pivot ではこの技術をさらに進化させ、16 コアの並列実行でも同様の効率を実現しています。Parquet ファイル以上の高速さを求める場合は、ぜひ試してみてください。