[absl::flat_hash_mapの概要](/eGNxQ5z2QpO473FEJfk1vQ)の続編。
今度は実装をみてみる。
## Swiss Tablesの構成
テーブルには`ctrl`と`slots`の2つがある。
`slots`には本体のデータが格納されており、`ctrl`にはH2の7bitsもしくはempty/deletedを表す特殊値が格納されている。([absl::flat_hash_mapの概要](/eGNxQ5z2QpO473FEJfk1vQ)で触れたとおり、flat_hash_mapにおけるハッシュ値は前57bitsからなるH1と後ろ7bitsからなるH2に分けて使用される。)
両者とも各インデックスに対応するのは同じデータになっている。
`ctrl`が`slots`と別々の配列に入っているのはパフォーマンス的にも良い。`slots`がキャッシュラインにもっと入るので。
このテーブルはGroupごとに分割されており、これはSSEでの最適化に活用されている。
## ハッシュと特殊値
[`H1`](https://source.chromium.org/chromium/chromium/src/+/refs/heads/main:third_party/abseil-cpp/absl/container/internal/raw_hash_set.h;l=525;drc=df097ef9b722789243248f807ad66c7085a14099)は以下のような実装。
```cpp=
inline size_t PerTableSalt(const ctrl_t* ctrl) {
return reinterpret_cast<uintptr_t>(ctrl) >> 12;
}
inline size_t H1(size_t hash, const ctrl_t* ctrl) {
return (hash >> 7) ^ PerTableSalt(ctrl);
}
```
ハッシュ値をそのまま使用するのでなくソルトをかけている。
PerTableSaltはイテレーションをランダムにするために使用される。
`ctrl_t`は以下のように定義されているenum型。
```cpp=
enum class ctrl_t : int8_t {
kEmpty = -128, // 0b10000000
kDeleted = -2, // 0b11111110
kSentinel = -1, // 0b11111111
};
```
この特殊値が最適化において重要な役割を占めている。
1byteからなるコントロール用の値。
kEmptyはスロットが空の時に使われ、kDeletedはスロットが削除されたときに使われる。
kSentinelはイテレーションをストップする場所を表す。イテレーション時にはempty,deletedのスロットはスキップし、sentinelで止まるようになっている。
値が入っているときは先頭に1bitをたてて、残りの7bits分がハッシュ値になる。これがH2として使われるやつ。
つまりこんな感じで詰め込まれている:
```
empty: 1 0 0 0 0 0 0 0
deleted: 1 1 1 1 1 1 1 0
full: 0 h h h h h h h // h represents the hash bits.
sentinel: 1 1 1 1 1 1 1 1
```
各特殊な値はSSE-flavered SIMDにチューニングされた値らしい。詳しくは後ろのセクションにて。
## keyの検索・挿入
アイテムをハッシュテーブルから検索したり挿入するのに[find_or_prepare_insert](https://source.chromium.org/chromium/chromium/src/+/refs/heads/main:third_party/abseil-cpp/absl/container/internal/raw_hash_set.h;l=2642;drc=df097ef9b722789243248f807ad66c7085a14099)が使われる。
ここでは[probe](https://source.chromium.org/chromium/chromium/src/+/refs/heads/main:third_party/abseil-cpp/absl/container/internal/raw_hash_set.h;l=1297;drc=df097ef9b722789243248f807ad66c7085a14099)で各スロットを変な順番でめぐる。
まずH2(hash(x))によってGroup内をスクリーニングして候補を探す。これは[Group::Match](https://source.chromium.org/chromium/chromium/src/+/refs/heads/main:third_party/abseil-cpp/absl/container/internal/raw_hash_set.h;l=593;drc=df097ef9b722789243248f807ad66c7085a14099)で行われる。
もしその中にempty slotを見つけたらエラーを返す。
もし値が同じだったら検索成功としそれを返す。
それ以外の場合はprobe_seqの次へ移動する。
probe_seqが一様でないことから[absl::flat_hash_mapの概要](/eGNxQ5z2QpO473FEJfk1vQ)で言及した線形走査法ではない賢い感じのイテレーションをしている。
```cpp=
probe_seq(size_t hash, size_t mask) {
assert(((mask + 1) & mask) == 0 && "not a mask");
mask_ = mask;
offset_ = hash & mask_;
}
void next() {
index_ += Width;
offset_ += index_;
offset_ &= mask_;
}
```
`Width`は各グループの統一サイズ。Widthを`index_`に足すことで同じグループ内を探してしまわないようにしている。
上記の式をまとめると
```
p(i) := Width * (i^2 + i)/2 + hash (mod mask + 1)
```
こんな感じでnextを決定し、次に探索するグループを返している。
## イテレーション
このハッシュテーブルにおけるiterationについて。
```cpp=
void skip_empty_or_deleted() {
while (IsEmptyOrDeleted(*ctrl_)) {
uint32_t shift = Group{ctrl_}.CountLeadingEmptyOrDeleted();
ctrl_ += shift;
slot_ += shift;
}
if (ABSL_PREDICT_FALSE(*ctrl_ == ctrl_t::kSentinel)) ctrl_ = nullptr;
}
```
IsEmptyOrDeletedで`ctrl_`にある値がempty/deleteかチェック。
empty/deleteの場合はスキップしたいので1回イテレートする。
[CountLeadingEmptyOrDeleted](https://source.chromium.org/chromium/chromium/src/+/refs/heads/main:third_party/abseil-cpp/absl/container/internal/raw_hash_set.h;l=620;drc=df097ef9b722789243248f807ad66c7085a14099)はkSentinentalと`ctrl`の">"をcmpgtで計算し、+1しておくことで0x00 or 0x01にしている。計算しやすそう。
これを_mm_movemask_epi8でi8のレーンごとに計算しbitmaskを生成する。
このbitmaskに対し[std::countr_zero](https://cpprefjp.github.io/reference/bit/countr_zero.html)で連続した0のビットを数える。連続した0とはつまりempty/deleteである要素の位置なので、この数分だけスキップすればいい。
スキップする値が`shift`として返ってきているので、`ctrl_`, `slot_`の配列をshift分だけいてレートするとfilledまたはSentinelの値になるはず。
TODO(elkurin): なぜ1発シフトで終了でなく、while文になっているの?
## SSE への最適化
SSE2以上のサポートがある場合、[GroupSse2Impl](https://source.chromium.org/chromium/chromium/src/+/refs/heads/main:third_party/abseil-cpp/absl/container/internal/raw_hash_set.h;l=585;drc=df097ef9b722789243248f807ad66c7085a14099)を[Group](https://source.chromium.org/chromium/chromium/src/+/refs/heads/main:third_party/abseil-cpp/absl/container/internal/raw_hash_set.h;l=762;drc=df097ef9b722789243248f807ad66c7085a14099)の実装として使用する。
kEmpty, kDelete, kSentinelの値はこの最適化のために定められている。
面白そうなので読んでみる。
`kWidth=16`を各グループに割り振るスロットとしている。この16個分を
まずkEmpty, kDeleted, kSentinelはどれも0x80とのマスク(つまり最上位bit a.k.a. MSB)は1であるべき。各要素のMSBのみを使用するSSEの命令である[blendvpd](https://www.felixcloutier.com/x86/blendvpd)があるからだと思う。
kEmpty, kDeletedよりkSentinelが大きくないといけない。これによりIsEmptyOrDeletedが効率化。多分MSBチェック+">"演算(_mm_cmpgt_epi8)をしている。
kSentinelは-1にすることでメモリからSIMDレジスタへの読み込みをはしょっているっぽい。[pcmpeqd xmm](https://www.felixcloutier.com/x86/pcmpeqb:pcmpeqw:pcmpeqd)を使って効率化。これは`=`を計算する命令。
kEemptyは-128にすることでSIMDでのIsEmptyチェックを効率化している。これは否定を高速に計算する[psignb xmm](https://www.felixcloutier.com/x86/psignb:psignw:psignd)を使うため。psignbのイメージは[こんな感じ](https://www.officedaytime.com/simd512e/simdimg/binop.php?f=psignb)。もちろん最初から11..111にしたほうが速そうだが、それだとkEmptyがkSentinelより小さい条件を満たせないので00..にして否定を使用することにしたのだと思う。
kEmptyとkDeletedは、kSentinelは持たないbitを両者とも持っておくとMaskEmptyOrDeletedを効率化。まあ確かに。
kDeletedは-2とすることでConvertSpecialToEmptyAndFullToDeletedを効率化。
```cpp=
void ConvertSpecialToEmptyAndFullToDeleted(ctrl_t* dst) const {
// 各レーンで同じi8 vectorをつくる。
auto msbs = _mm_set1_epi8(static_cast<char>(-128));
auto x126 = _mm_set1_epi8(126);
// zero vector
auto zero = _mm_setzero_si128();
// 2つのi8 vectorの">"を計算して0xff/0x00を返す。
auto special_mask = _mm_cmpgt_epi8_fixed(zero, ctrl);
// 2つのi128のOR
auto res = _mm_or_si128(msbs, _mm_andnot_si128(special_mask, x126));
// STORE
_mm_storeu_si128(reinterpret_cast<__m128i*>(dst), res);
}
```
むずい。