データ構造の実性能
この章でわかること:
HashMapの内部実装(SwissTable)と、ハッシュ関数の差し替えで 2.6倍変わる実測BTreeMapが「木」なのにハッシュ表の3倍以上遅い理由と、それでも選ぶ場面- 要素数が小さいときは線形探索のほうが速い、という逆転の実測
- ビット演算という「最小のデータ構造」(実測31倍)
- なぜ体系に必須か: 8章の優先順位の最上位は 「アルゴリズムとデータ構造」でした。その選択を計算量の表だけでなく 実装と定数の知識で行えるようにするのが本章です
計算量(O記法)は「nを大きくしたときの増え方」しか表しません。 本書をここまで読んだ読者なら、同じO(1)でもキャッシュミス1回と 10回では10倍違うことを知っています。この章では、標準的な構造の 定数部分を、ハードウェアの動作から説明します。
HashMapの内部構造: SwissTable
RustのHashMapの実装(hashbrown)は、Googleが設計した
SwissTable系のオープンアドレス方式です。要点は2つあります。
1つ目は制御バイト配列です。表の本体(キーと値の配列)とは別に、 各スロットの状態(空/削除済み/ハッシュ値の上位7ビット)を 1バイトずつ並べた配列を持ちます。探索ではまずこの制御バイトを グループ単位でまとめてSIMD命令(4章)で比較し (グループ幅はx86では16、ARM64のNEONでは8)、 候補を絞ってから本体のキーと比較します。キー本体を読む回数が 減るため、キャッシュライン(2章)の 消費が最小で済みます。
2つ目は負荷率とリハッシュです。埋まっているスロットの割合が
上限を超えると、表を2倍の大きさで作り直します。
このリハッシュは7章のVecの伸長と同じ
償却コスト構造で、「平均は速いが、ときどき大きな遅延が発生する」性質を
持ちます。サイズの見当がつくならwith_capacityで事前確保する方法が、
ここでも有効です。
ハッシュ関数という選択
見落とされがちな要素がハッシュ関数です。Rustの既定は SipHashです。これは、攻撃者が衝突だらけのキーを作って表をO(n)に 退化させるHashDoS攻撃への耐性を持つ、暗号学的に設計された 関数です。安全ですが、整数キー1個に対しては過剰な計算量です。
実験します。100万件のu64キーに対する100万回の検索を、
(1)既定のHashMap、(2)ハッシュ関数だけ軽量なものに差し替えた
HashMap、(3)BTreeMap、(4)ソート済みVecの二分探索で比べます。
差し替えは標準のHasherトレイトを15行実装するだけです
(実務では同じ発想のrustc-hash
(FxHashMap)やahashを使います)。
use std::collections::{BTreeMap, HashMap};
use std::hash::{BuildHasherDefault, Hasher};
use std::hint::black_box;
use std::time::Instant;
// 整数キー向けの軽量ハッシュ(FxHash系の考え方)。
// 既定のSipHashと違い、HashDoS耐性を捨てて速度を取る
#[derive(Default)]
struct FastHasher(u64);
impl Hasher for FastHasher {
fn write(&mut self, bytes: &[u8]) {
for &b in bytes {
self.0 = (self.0 ^ b as u64).wrapping_mul(0x100_0000_01b3);
}
}
fn write_u64(&mut self, x: u64) {
self.0 = (self.0 ^ x).wrapping_mul(0x9E37_79B9_7F4A_7C15);
}
fn finish(&self) -> u64 {
self.0
}
}
fn xorshift(state: &mut u64) -> u64 {
*state ^= *state << 13;
*state ^= *state >> 7;
*state ^= *state << 17;
*state
}
fn main() {
let n = 1_000_000usize;
let mut state = 0x2545_F491_4F6C_DD1Du64;
let keys: Vec<u64> = (0..n as u64).map(|i| i * 2).collect(); // 偶数がキー
let queries: Vec<u64> = (0..n)
.map(|_| xorshift(&mut state) % (2 * n as u64))
.collect();
let hash: HashMap<u64, u64> = keys.iter().map(|&k| (k, k + 1)).collect();
let fast: HashMap<u64, u64, BuildHasherDefault<FastHasher>> =
keys.iter().map(|&k| (k, k + 1)).collect();
let btree: BTreeMap<u64, u64> = keys.iter().map(|&k| (k, k + 1)).collect();
let sorted = keys.clone(); // すでに昇順
let start = Instant::now();
let mut found = 0u64;
for &q in &queries {
if let Some(&v) = hash.get(&q) { found = found.wrapping_add(v); }
}
println!("HashMap(SipHash) : {:>9.3?} (found={found})", start.elapsed());
let start = Instant::now();
let mut found = 0u64;
for &q in &queries {
if let Some(&v) = fast.get(&q) { found = found.wrapping_add(v); }
}
println!("HashMap(自作Fx風): {:>9.3?} (found={found})", start.elapsed());
let start = Instant::now();
let mut found = 0u64;
for &q in &queries {
if let Some(&v) = btree.get(&q) { found = found.wrapping_add(v); }
}
println!("BTreeMap : {:>9.3?} (found={found})", start.elapsed());
let start = Instant::now();
let mut found = 0u64;
for &q in &queries {
if let Ok(i) = sorted.binary_search(&q) { found = found.wrapping_add(sorted[i] + 1); }
}
println!("Vec二分探索 : {:>9.3?} (found={found})", start.elapsed());
black_box(found);
}
筆者の実測(Playground)です。
| 構造 | 時間 | 1回あたり |
|---|---|---|
| HashMap (既定のSipHash) | 65ms | 65ns |
| HashMap (軽量ハッシュ) | 25ms | 25ns |
| BTreeMap | 210ms | 210ns |
| ソート済みVecの二分探索 | 93ms | 93ns |
ハッシュ関数を替えただけで2.6倍です。構造もアルゴリズムも 同一で、変わったのはキー1個をハッシュする数ナノ秒だけです。 O(1)の定数部分がこれだけ大きく変わりうる例です。
使い分けの指針は次のとおりです。外部から来るキー(HTTPヘッダ名、
ユーザー入力)にはHashDoS耐性のある既定のハッシュ関数を使い、
内部で完結する整数や短いキーにはFx系を使います。
rustcやFirefoxが実際にそうしています。
なおコード中のBuildHasherDefault<FastHasher>は、HashMapの
第3型引数(ハッシュ器の生成器)に渡すための標準のアダプタで、
「Defaultで作れるHasherを生成器に変換する」役割を持ちます。
BTreeMapの実像
BTreeMapはB木、つまり各ノードがキーを十数個持つ多分岐の木です。
ノード内はただの配列なので、設計としてはキャッシュライン
(2章)を意識した現代的な構造です。それでも実測は210ns/回、
ハッシュ表の3〜8倍かかりました。理由は、木である以上避けられない
ポインタチェイシング(2章)です。
100万件で深さは6段前後(1ノード最大11キーの
現行実装の場合。充填率に依存します)。各段が別の
キャッシュラインへのアクセス(しばしばミス)になります。
ハッシュ表が「ハッシュ計算+ほぼ1回のメモリアクセス」で
探索を終えるのとは、メモリ階層の使い方が根本的に違います。
BTreeMapを選ぶのは、順序が必要なときです。
範囲検索(「この日時からこの日時まで」)、最小・最大の取り出し、
決定的な列挙順は、ハッシュ表には原理的にできません。
表の4行目はもう1つの選択肢を示しています。データが静的なら、
ソート済みVec+二分探索です。構造体のオーバーヘッドがなく、
メモリは最小で、順序も持てます。93nsとBTreeMapの半分以下で、
「作ってから変更しない索引」の第一候補です(挿入・削除が
頻繁ならVecのO(n)挿入が支配的になるので、そこが分岐点です)。
小さいnの場合: 線形探索の逆転
計算量からの予測が最も外れるのは、nが小さい場合です。
32要素の表を1000万回引きます。HashMapと、
Vecを先頭から順に比較する線形探索を比べます。
use std::collections::HashMap;
use std::hint::black_box;
use std::time::Instant;
fn main() {
// 要素32個の小さな表を1000万回引く
let n = 32u64;
let lookups = 10_000_000;
let pairs: Vec<(u64, u64)> = (0..n).map(|i| (i * 7 % 64, i)).collect();
let map: HashMap<u64, u64> = pairs.iter().copied().collect();
let start = Instant::now();
let mut sum = 0u64;
for i in 0..lookups {
let q = black_box(i as u64 % 64);
if let Some(&v) = map.get(&q) { sum = sum.wrapping_add(v); }
}
println!("HashMap : {:>9.3?} (sum={sum})", start.elapsed());
let start = Instant::now();
let mut sum = 0u64;
for i in 0..lookups {
let q = black_box(i as u64 % 64);
if let Some(&(_, v)) = pairs.iter().find(|&&(k, _)| k == q) {
sum = sum.wrapping_add(v);
}
}
println!("Vec線形探索: {:>9.3?} (sum={sum})", start.elapsed());
}
筆者の実測(Playground)では、HashMapが約95ミリ秒、
線形探索が約79ミリ秒で上回りました。
線形探索が速い理由は、連続メモリの先読み(2章)が有効なことと、 ハッシュ計算の固定費がないことです(分岐予測やSIMDの 寄与もありえますが、そこはコードと分布次第です)。 損益分岐はキーの型や分布・ヒット率にもよりますが、 数十要素までは単純な構造のほうが速いことがあると覚えておく 価値があります。境目は計測で確かめます。
同じ考え方に基づく構造がインライン格納コンテナです。
smallvecやArrayVecは
「要素が少ないうちはコンテナ本体の中に直接格納
(18章のヒープ確保の回避)、
あふれたらヒープ」というハイブリッドで、
「ほとんどの場合は2〜3要素」という現実のデータ分布に対して効果があります。
注意点は型サイズです。SmallVec<[T; 16]>は常に16要素分の
大きさを持つため、それを含む構造体やenum
(7章)のサイズを増やします。インライン容量は
分布の中央値に合わせ、計測で決めます。
ビットの直接操作: 最小のデータ構造
最後に、データ構造ですらない最も単純な表現であるビットを扱います。
多くの現代CPUは、1になっているビットを数える(count_ones)、先頭の
ゼロを数える(leading_zeros)といった操作の専用命令を持ちます
(popcntなど。正確な命令数はISAと演算によります)。
これを集合に応用したのがビットセット(bitset)です。
「0〜1000万の整数の集合」を、HashSet<u32>と、
Vec<u64>の各ビットで表す方式で比べます。判定2000万回です。
use std::collections::HashSet;
use std::hint::black_box;
use std::time::Instant;
fn main() {
// 0..1000万の範囲の整数200万個の「所属判定」を2000万回
let range = 10_000_000u32;
let members: Vec<u32> = (0..range).filter(|x| x % 5 == 0).collect();
let queries = 20_000_000u32;
let set: HashSet<u32> = members.iter().copied().collect();
let mut bits = vec![0u64; (range as usize + 63) / 64];
for &m in &members {
bits[(m / 64) as usize] |= 1 << (m % 64);
}
let start = Instant::now();
let mut count = 0u32;
for i in 0..queries {
let q = i % range;
if set.contains(&q) { count += 1; }
}
println!("HashSet : {:>9.3?} (count={count})", start.elapsed());
let start = Instant::now();
let mut count = 0u32;
for i in 0..queries {
let q = i % range;
if bits[(q / 64) as usize] & (1 << (q % 64)) != 0 { count += 1; }
}
println!("ビット列: {:>9.3?} (count={count})", start.elapsed());
black_box(count);
}
筆者の実測(Playground)では672ミリ秒対約22ミリ秒、31倍の差です。 ビットセットの判定はシフトとANDだけで、分岐もハッシュもありません。 1000万要素の集合が約1.2MB(要素あたり1ビット)に収まるため、 ほぼキャッシュに収まります。要素が整数で範囲が有界なら、 これより速い集合は基本的にありません (範囲が疎で巨大な場合は、圧縮ビットマップの roaringが定番です)。
文字列への応用
文字列にも同型の技法があります。Stringは24バイト+ヒープ
(7章)なので、短い文字列はインライン格納型(compact_str等)で
ヒープを回避できます。さらに、同じ文字列を何度も比較・保持する
場面(識別子、タグ)ではインターニング(interning)が有効です。
文字列を1か所に登録して整数IDで扱う技法で、
文字列比較がu32の比較(1命令)になります。この章で述べた考え方の集約です。
まとめ
HashMapはSwissTable(SIMDによるグループ探査)です。 ハッシュ関数の差し替えだけで2.6倍でした。内部の整数キーにはFx系、 外部入力には既定のSipHashを使いますBTreeMapは順序が必要なときの構造です(実測はハッシュの3倍超)。 静的データならソート済みVec+二分探索が有効です- 数十要素までは線形探索のほうが速いことがあります(実測では32要素で 逆転しました)。SmallVec系は「要素が少ないうちはインライン格納」を自動化します
- 整数集合はビットセットで31倍速くなりました。ビット演算の専用命令を 直接利用できる構造です
Part Vはここまでです。Part VIでは再びGPUを扱い、 基礎編では概要だけ見たカーネル最適化の技法体系 (reduction、共有メモリの実践、行列エンジン、GPUの計測)を説明します。