Skip to content

Add a perfect hash table FrozenStringMap - #1552

Open
yukawa wants to merge 1 commit into
google:masterfrom
ciceroaware:frozen_string_map
Open

Add a perfect hash table FrozenStringMap#1552
yukawa wants to merge 1 commit into
google:masterfrom
ciceroaware:frozen_string_map

Conversation

@yukawa

@yukawa yukawa commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Description

We have recently switched from a binary-search-based POS-map to absl::flat_hash_map for the POS conversion map used in user_dictionary_importer.cc at the cost of runtime heap-allocation, which will not be reclaimed until the process exits (8c190e5).

This commit introduces a new compile-time constructible and trivially-destructible string map, FrozenStringMap, which is backed by a perfect hash table (CHD: compress, hash and displace) that is built entirely at compile time by a consteval constructor.

A lookup computes one hash, reads one displacement, and compares the key with exactly one slot; there is no probing. The hash function can be much cheaper than general-purpose ones because it only needs to be collision-free over the fixed key set, which the consteval builder verifies, retrying with different multipliers on collisions. The multiplier participates in the length seed and in every mixing round, so a retry re-randomizes every remaining collision class. Keys of at most 3 bytes take a dedicated hash path with a single mixing round, and can never collide with each other under any multiplier.

All key bytes are copied at compile time into a length-prefixed pool owned by the map, whose length prefix width is adapted to the longest key, so the map is fully self-contained and the compiler does not even have to emit the source string literals. Slots refer to the pool by 32-bit references laid out as [tag][fingerprint][offset]:

  • Keys of at most 3 bytes are stored inline in the reference as the same packed value the hash consumes, so a short-key lookup compares one integer and performs no pool access at all. 82 of the 268 entries of the POS conversion map are such keys.
  • Reference bits not required by the pool offset hold a hash fingerprint of the stored key, so nearly all lookups of missing keys are also rejected without a pool access.
  • When the value type is as wide as a reference, the values are packed beside the references, so a hit finds its value on the cache line that held the reference it just compared.

The entry list is passed to CreateFrozenStringMap as a captureless lambda in a template argument, because the sizes of the internal arrays depend on the entry values and consteval function parameters are not constant expressions. As a side effect, duplicate keys in the entry list fail the build at compile time. This does not change anything for third_party_pos_map.def, whose duplicates are already resolved by gen_pos_map.py before the compiler runs; the check guards hand-authored maps and generator regressions.

A microbenchmark that looks up the 268 entries of the POS conversion map (x64, clang-cl, opt build, mean over 15 repetitions, standard deviation below ~3.5% in every row) shows the following costs per lookup, where the hot key is a 2-byte numeric POS code:

FrozenStringMap absl::flat_hash_map FlatMap
uniformly random keys 8.7 ns 11.5 ns 49.0 ns
single hot key (2-byte) 1.7 ns 4.2 ns 25.9 ns
missing key 2.6 ns 2.7 ns 48.9 ns

On a synthetic 4096-entry map (~100 KB, exceeding the L1 data cache) FrozenStringMap also measures faster than absl::flat_hash_map in all the three patterns: 4.7 vs 5.9 ns for random keys, 4.1 vs 4.5 ns for the hot key, and 2.3 vs 2.9 ns for missing keys.

As for the memory impact, the old hash map occupied 12,839 bytes of per-process heap in addition to the key string literals in the read-only data section, and was built unconditionally during process startup, as it was a namespace-scope static object. The new FrozenStringMap is a self-contained 7,976-byte constant in the read-only data section, whose pages are file-backed and shared across processes, and the key string literals are no longer emitted.

This is a pure performance and memory usage optimization. No user-observable behavior change is intended.

Issue IDs

N/A

Steps to test new behaviors

  • OS: Windows 11 25H2
  • Steps:
    1. bazelisk test //base/container:frozen_string_map_test

We have recently switched from a binary-search-based POS-map to
absl::flat_hash_map for the POS conversion map used in
user_dictionary_importer.cc at the cost of runtime heap-allocation,
which will not be reclaimed until the process exits [1].

This commit introduces a new compile-time constructible and
trivially-destructible string map, FrozenStringMap, which is backed
by a perfect hash table (CHD: compress, hash and displace) that is built
entirely at compile time by a consteval constructor.

A lookup computes one hash, reads one displacement, and compares the key
with exactly one slot; there is no probing. The hash function can be
much cheaper than general-purpose ones because it only needs to be
collision-free over the fixed key set, which the consteval builder
verifies, retrying with different multipliers on collisions. The
multiplier participates in the length seed and in every mixing round, so
a retry re-randomizes every remaining collision class. Keys of at most
3 bytes take a dedicated hash path with a single mixing round, and can
never collide with each other under any multiplier.

All key bytes are copied at compile time into a length-prefixed pool
owned by the map, whose length prefix width is adapted to the longest
key, so the map is fully self-contained and the compiler does not even
have to emit the source string literals. Slots refer to the pool by
32-bit references laid out as [tag][fingerprint][offset]:

 - Keys of at most 3 bytes are stored inline in the reference as the
   same packed value the hash consumes, so a short-key lookup compares
   one integer and performs no pool access at all. 82 of the 268 entries
   of the POS conversion map are such keys.
 - Reference bits not required by the pool offset hold a hash
   fingerprint of the stored key, so nearly all lookups of missing keys
   are also rejected without a pool access.
 - When the value type is as wide as a reference, the values are packed
   beside the references, so a hit finds its value on the cache line
   that held the reference it just compared.

The entry list is passed to CreateFrozenStringMap as a captureless
lambda in a template argument, because the sizes of the internal arrays
depend on the entry values and consteval function parameters are not
constant expressions. As a side effect, duplicate keys in the entry list
fail the build at compile time. This does not change anything for
third_party_pos_map.def, whose duplicates are already resolved by
gen_pos_map.py before the compiler runs; the check guards hand-authored
maps and generator regressions.

A microbenchmark that looks up the 268 entries of the POS conversion map
(x64, clang-cl, opt build, mean over 15 repetitions, standard deviation
below ~3.5% in every row) shows the following costs per lookup, where
the hot key is a 2-byte numeric POS code:

                         FrozenStringMap  absl::flat_hash_map  FlatMap
  uniformly random keys  8.7 ns           11.5 ns              49.0 ns
  single hot key         1.7 ns           4.2 ns               25.9 ns
  missing key            2.6 ns           2.7 ns               48.9 ns

On a synthetic 4096-entry map (~100 KB, exceeding the L1 data cache)
FrozenStringMap also measures faster than absl::flat_hash_map in all
the three patterns: 4.7 vs 5.9 ns for random keys, 4.1 vs 4.5 ns for
the hot key, and 2.3 vs 2.9 ns for missing keys.

As for the memory impact, the old hash map occupied 12,839 bytes of
per-process heap in addition to the key string literals in the read-only
data section, and was built unconditionally during process startup, as
it was a namespace-scope static object. The new FrozenStringMap is a
self-contained 7,976-byte constant in the read-only data section, whose
pages are file-backed and shared across processes, and the key string
literals are no longer emitted.

This is a pure performance and memory usage optimization.
No user-observable behavior change is intended.

 [1]: 8c190e5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant