
2025/12/14 2:41
Building an efficient hash table in Java
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
要約
SwissTable は、Google の Abseil ライブラリから派生したオープンアドレッシングハッシュテーブル設計で、小さな「制御バイト」(メタデータ)をキー/値ストレージと分離しています。まず制御バイトをスキャンし、h1(グループ選択)と h2(制御バイトに格納されたフィンガープリント)の分割を利用することで、SIMD スタイルのフィンガープリントの一括比較が可能になり、最小限のキー比較で高速なプローブ検索が実現します。
Java では SwissMap が JDK の Vector API(
jdk.incubator.vector)を用いてこの概念を実装しています。ByteVector を使用して複数の制御バイトを一度にロードし、各ベクトルを h2 マッチングと EMPTY/DELETED チェックの両方で再利用することでメモリトラフィックを削減します。制御配列の末尾にセントネルパディング領域があるため、境界チェックなしで固定幅ベクトルロードが可能です。テムストーン(DELETED マーカー)は挿入時に再利用され、カウントが閾値を超えると同容量リハッシュがトリガーされてプローブチェーンを短く保ちます。サイズ変更は新しいキャパシティに対して h1/h2 を再計算する単一の線形走査で行われ、重複作業を回避します。
SwissMap のイテレーションはモジュラーステップ置換(奇数ステップサイズ)を使用し、すべてのスロットを正確に一度ずつ訪問しながらループをタイトでキャッシュフレンドリーに保ちます。この手法は従来のハッシュマップでは使われません。
Windows 11 上で Eclipse Temurin JDK 21.0.9 と AMD Ryzen 5 5600 を使用したベンチマークでは、SwissMap は
HashMap、fastput、Object2ObjectOpenHashMap、および UnifiedMap よりも競争力があり、高負荷率でも優れた性能を示し、小さなペイロードシナリオで最大 50 % のヒープ保持量削減を達成しました。
SwissTable はすでに Rust の
hashbrown と Go の map を動かしており、最大 60 % の速度向上を提供しています。Java プロトタイプは Vector API が成熟すれば本番環境へ移行できる見込みで、現在の HashMap を置き換えるか補完する可能性があります。広く採用されれば、エンタープライズアプリケーションのヒープ使用量を削減し、スケーラビリティを向上させ、言語横断的にコレクション実装の新しい標準となるでしょう。本文
1) SwissTable プロジェクト ― 基本的な説明
SwissTable は、Google の研究から生まれたオープンアドレッシング型ハッシュテーブルの設計で、新しい C++ ハッシュテーブルとして発表され(後に Abseil に組み込まれました)。
基本構造は従来と同じで、
hash(key) を算出し、開始スロットを決めて線形探索を行い、キーまたは空スロットを見つけるというものです。
ここでのひねりは、メタデータ(小さな「制御バイト」)と実際のキー/値ストレージを分離し、制御バイトだけでほぼすべてのコストのかかるキー比較を回避する点にあります。
冷たいキャッシュ状態のキー配列(ポインタが多い)は触らずに、まず密度が高くキャッシュフレンドリーな制御バイト配列をスキャンします。
これは小さなブルームフィルターと似ており、「ここに候補があるかもしれない」という低コストの判定を先に行う仕組みです。
探査を高速化するため、SwissTable はハッシュを 2 部分に分割します。
h1 は探索開始位置(どのグループから見るか)を決める役目で、h2 は制御バイトに格納される小さなフィンガープリントです。完全なハッシュではなく、実際のキーにアクセスする前に候補を絞り込むだけのビット数です。
検索時にはまず
(h1, h2) を算出し、h1 によってグループへジャンプします。そのグループ内の制御バイトすべてと
h2 を比較し、一致したらのみキーを触ります。つまり、多くのミス(および多くのヒット)で実際にキーメモリをタッチせず、メタデータが「候補がある」と判断するまで待ちます。
探査コストが低いので、SwissTable は高負荷率(約 87.5 % = 7/8)でもパフォーマンス崩壊に陥りにくく、メモリ効率も向上します。
結果として、キャッシュミスやキー比較回数が減るだけでなく、オーバーフローバケットなどの副構造を少なくできる設計となっています。
2) SwissTable が複数言語で「デフォルト感覚」になるまで
世代を超える設計といえば、それがライブラリのトリックから標準ライブラリへ登場する瞬間です。
-
Rust – Rust 1.36.0 以降、
は SwissTable ベースのstd::collections::HashMap
実装に切り替わりました。hashbrown
二次プロービングと SIMD ラックアップを使用しており、精神的・技術的には SwissTable にほぼ沿っています。 -
Go – Go 1.24 で Swiss Table デザインに基づく新しいビルトインマップ実装が導入されました。
マイクロベンチマークでは Go 1.23 より最大 60 % 高速、全体アプリケーションベンチでは約 1.5 % の CPU 時間改善を報告しています。
この時点で SwissTable は「C++ のクールなトリック」ではなく、現代のベースラインになっていました。
Rust が採用し、Go が ship した…なら Java も同じ設計に挑戦すべきだと直感的に思えました。
最新 CPU と強力 JIT、Vector API の到来で、技術的不可能性ではなく「やらなければならない」という itch が芽生えたのです。
3) SwissTable の秘密兵器と Java Vector API
SwissTable の高速さは、比較を広く(ワイドに)行うことから来ています。
複数の制御バイトを一度にチェックし、ブランチを減らす SIMD ワークロードです。
これは「1 バイトずつループして分岐する」スカラーコードよりもはるかに効率的です。
Java でこれを実装するには、以前は auto‑vectorization に頼ったり、
Unsafe を使ったり、JNI を書いたり、あるいは純粋なスカラー処理を受け入れたりしました。しかし Vector API は「Java がベクトル計算を表現できるように設計された incubator」で、サポート CPU で SIMD 命令へコンパイルされます。
Java 25 の Vector API はまだ incubating ですが、
jdk.incubator.vector にあります。私にとって重要だったのは「最終版かどうか」ではなく、「SwissTable の制御バイトスキャンをクリーンに表現できるほど使えるか」という点でした。
設計の核は次の通りです:
- 制御配列(コンパクト)+別々のキー/値ストレージ
で初期グループを選択、h1
はそのグループ内の制御バイトに格納される小さなフィンガープリントh2- 探索は 2 段階パイプライン:
- ベクトルスキャンで
と一致するビットを探すh2 - 一致したインデックスで実際のキー比較を行う
- ベクトルスキャンで
挿入も同じスキャンを再利用し、既存キーが無い場合は最初に見つかった空スロットへ格納します。
Java ならではのレイアウト課題
-
C++ ではキー/値を密にパックできますが、Java ではオブジェクト参照の配列になるためキーアクセス自体がキャッシュミスを招く可能性があります。
そのため「キーはできるだけ遅れてタッチし、必要なときには最小限にする」設計が重要です。 -
削除は tombstone(削除済みだが空ではない)マーカーを用います。
ただし tombstone が蓄積するとパフォーマンス低下につながるため、クリーンアップ戦略も必要です。 -
リサイズは「全体再ハッシュ」がコスト高ですが、Go のテーブル分割や拡張可能ハッシュのように成長戦略を工夫すれば改善できます。
Vector API は「魔法ではなく最適化ツール」として扱い、ロード・比較・マスク・イテレーションという明確なループ構造で実装しました。
4) SwissMap において本当に重要だった要素
| 要素 | なぜ重要か |
|---|---|
| 制御バイト & レイアウト | 非プリミティブキーの場合、実際のコストは「数バイト読み」ではなく「ポインタ追跡」です。 が冷たいオブジェクトを歩くとキャッシュミスが発生します。SwissMap は制御配列でまず候補を絞り、キー/値へのアクセスを最小限に抑えます。 |
| 負荷率 | 従来のオープンアドレッシングは負荷率上昇でプローブチェーンが急増しますが、SwissTable は制御バイトスキャンが主コストなので「1 つ多いグループ」は単なるベクトルロード+比較にすぎません。最大負荷率 ~0.875 を目指し、混雑時でも高速を保ちます。 |
| セントネルパディング | SIMD は固定幅(16/32 バイト)ロードを好むため、配列末尾のグループでオーバーリードを防ぐ小さなパディングが必要です。これにより JIT が予測しやすくなります。 |
| H1/H2 分離 | は開始グループ選択、 は「ここに候補があるかもしれない」低コストフィルタです。制御バイトは 7 ビットで を保持し、残りのビットを EMPTY/DELETED の状態管理に使用します。 |
| ロード済みベクトル再利用 | 同じ ロードを 1 回だけ行い、 マスクと EMPTY/DELETED 判定の両方で使います。これが実際のパフォーマンス差を生む重要なポイントです。 |
| トゥームストーン | 削除時に DELETED マーカーを置き、再利用可能な空スロットを記録します。探査チェーンを短く保ち、リサイズ圧力を低減します。 |
| 同容量リハッシュでのクリーンアップ | トゥームストーンが一定割合を超えると、DELETED を EMPTY に戻す同容量リハッシュを実行し、論理内容は変えずにチェーン長を短くします。 |
| リサイズ時の冗長チェック回避 | リサイズは がキャパシティに依存するため再ハッシュが必須です。旧テーブルを走査しつつ、同じ制御バイトプローブロジックで新テーブルへ挿入します。 |
| イテレーション | モジュラーステップ(奇数ステップ)で全インデックスを 1 回ずつ訪問し、キャッシュ分布とループ密度を保ちます。 |
制御バイト中心の検索ループ(簡略版)
protected int findIndex(Object key) { if (size == 0) return -1; int h = hash(key); int h1 = h1(h); byte h2 = h2(h); int nGroups = numGroups(); int visitedGroups = 0; int mask = nGroups - 1; int g = h1 & mask; // 効率的な modulo for (;;) { int base = g * DEFAULT_GROUP_SIZE; ByteVector v = loadCtrlVector(base); long eqMask = v.eq(h2).toLong(); while (eqMask != 0) { int bit = Long.numberOfTrailingZeros(eqMask); int idx = base + bit; if (Objects.equals(keys[idx], key)) { // 見つかった return idx; } eqMask &= eqMask - 1; // LSB をクリア } long emptyMask = v.eq(EMPTY).toLong(); // 同じベクトルを再利用 if (emptyMask != 0) { // 空があれば確定ミス return -1; } if (++visitedGroups >= nGroups) { return -1; // 無限ループ防止(全 tombstone のケース) } g = (g + 1) & mask; } }
5) ベンチマーク
以下は高負荷率でのプロービングとポインタ多いキーを重視した JMH 設定です。
実行環境:Windows 11 x64、Eclipse Temurin JDK 21.0.9、AMD Ryzen 5 5600(6C/12T)。
| シナリオ | SwissMap hit | SwissMap miss | HashMap hit | HashMap miss |
|---|---|---|---|---|
| put | * | * | * | * |
| get | * | * | * | * |
主な結果
- 高負荷率で SwissMap は他のオープンアドレッシングテーブルと競争力があり、JDK
と同等かそれ以上のスループットを実現。HashMap - メモリ使用量はフラットレイアウト(バケット/オーバーフロー無し)+最大負荷率 0.875 により、小さいペイロードでは
よりも 50 % 以上メモリ節約。HashMap
注意:Vector API はまだ incubating、テーブルは高負荷・参照キー向けに最適化されているため、プリミティブ専用マップや低負荷構成では結果が異なる可能性があります。
6) 簡単な備考
ベンチマークセクションで「SWAR‑style SwissTable」が突然登場するのは、後続調整で同じ制御バイトワークフローを SWAR(SIMD Within a Register)で実装し、Vector API を使わずに高速化したためです。
SWAR は「レジスタ内で SIMD」を意味し、64 ビット単位で複数の制御バイトを比較する手法です。
結果として同じ高速パスが、よりポータブルかつ JDK バージョンに依存せず実装できます。
この投稿は 3 本に分けるべき内容をまとめたもので、次回で SWAR の詳細(設計・実装)を公開予定です。ぜひご期待ください。
P.S. コードが欲しい方へ
この記事は HashSmith と呼ばれる JVM 向け高速メモリ効率のハッシュテーブル集を公表している試作プロジェクトの物語版です。
SwissMap(SwissTable 風、Vector API を用いた SIMD 探索)と SwissSet の両方が含まれています。
実験的で本番向けではありませんが、JMH ベンチマークやドキュメントを付属しているため、数値再現や実装詳細の検証が可能です。
ベンチマークを走らせたい、エッジケースを確認したい、あるいはより良い探査/リハッシュ戦略を提案したい場合はぜひ issue / PR をお寄せください。
P.P.S. 見逃せない講演
Google のエンジニア Matt Kulukundis らが CppCon で行った SwissTable に関するトークは非常に分かりやすく、設計の核心を掴める内容です。
ぜひご覧ください:https://www.youtube.com/watch?v=ZzQ0j3pRkJ4