Look up sparse enum identifiers in a hash table instead of a linear search (#4899)

Enums that are too sparse for the index table were formatted by scanning
all enumerators. Replace the scan with an open-addressed hash table with
linear probing, sized to the smallest power of two that keeps the load
factor at or below 0.5. This guarantees a free slot, which terminates the
probe sequence and doubles as the not-found result because identifiers
are never empty.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Avi Kivity 2026-08-29 17:47:28 +03:00 committed by GitHub
parent e76a9520a3
commit e589a16ecb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 131 additions and 13 deletions

View File

@ -124,17 +124,50 @@ consteval auto make_identifier_table() -> std::array<string_view, N> {
template <typename E>
inline constexpr auto identifier_table = make_identifier_table<E>();
template <typename E, size_t N = count_enumerators<E>()>
consteval auto make_identifiers() -> std::array<std::pair<E, string_view>, N> {
auto ids = std::array<std::pair<E, string_view>, N>();
auto i = size_t();
for (std::meta::info e : std::meta::enumerators_of(^^E))
ids[i++] = {std::meta::extract<E>(e), identifier(e)};
return ids;
// Returns the size of the hash table that maps enumerator values of E to
// identifiers. It is the smallest power of two that keeps the load factor at or
// below 0.5, which guarantees a free slot and therefore terminates the search.
template <typename E> consteval auto identifier_map_size() -> size_t {
auto size = size_t(1);
while (size < count_enumerators<E>() * 2) size *= 2;
return size;
}
// Identifiers of enumerators of E in the order of declaration.
template <typename E> inline constexpr auto identifiers = make_identifiers<E>();
// Returns the index of the first slot to probe for `value`. The bits of the
// underlying value are mixed because enumerator values are usually small and
// only the low bits of the hash are used.
template <typename E, size_t N = identifier_map_size<E>()>
constexpr auto identifier_slot(E value) -> size_t {
auto h = to_uint64(value);
h ^= h >> 33;
h *= 0xff51afd7ed558ccd;
h ^= h >> 33;
return static_cast<size_t>(h & (N - 1));
}
template <typename E, size_t N = identifier_map_size<E>()>
consteval auto make_identifier_map()
-> std::array<std::pair<E, string_view>, N> {
auto map = std::array<std::pair<E, string_view>, N>();
for (std::meta::info e : std::meta::enumerators_of(^^E)) {
auto value = std::meta::extract<E>(e);
for (auto i = identifier_slot(value);; i = (i + 1) & (N - 1)) {
if (map[i].second.size() != 0) {
// Keep the identifier of the first enumerator with this value.
if (map[i].first == value) break;
continue; // The slot is taken by another value; probe the next one.
}
map[i] = {value, identifier(e)};
break;
}
}
return map;
}
// Identifiers of enumerators of E in an open-addressed hash table with linear
// probing and empty string views in the free slots.
template <typename E>
inline constexpr auto identifier_map = make_identifier_map<E>();
// Returns the identifier of the first enumerator of E equal to value or an
// empty string view if there is no such enumerator.
@ -146,10 +179,12 @@ template <typename E> constexpr auto identifier_of(E value) -> string_view {
return i < table_size ? identifier_table<E>[static_cast<size_t>(i)]
: string_view();
} else {
for (const auto& id : identifiers<E>) {
if (id.first == value) return id.second;
constexpr size_t map_size = identifier_map_size<E>();
// A free slot terminates the search and its empty identifier is the result.
for (auto i = identifier_slot(value);; i = (i + 1) & (map_size - 1)) {
const auto& entry = identifier_map<E>[i];
if (entry.second.size() == 0 || entry.first == value) return entry.second;
}
return {};
}
}

View File

@ -37,7 +37,7 @@ enum class [[=fmt::as_identifiers]] dense { d0, d1, d2, d3, d4 };
enum class [[=fmt::as_identifiers]] holey {
h0, h1, h2, h3, h4, h5, h6 = 9
};
// 4 holes out of 11: just too sparse, formatted via a linear search.
// 4 holes out of 11: just too sparse, formatted via a hash table.
enum class [[=fmt::as_identifiers]] sparse {
s0, s1, s2, s3, s4, s5, s6 = 10
};
@ -47,6 +47,25 @@ enum class [[=fmt::as_identifiers]] signed_enum {
minus_one = -1,
one = 1
};
// Many scattered values, exercising collisions in the hash table.
enum class [[=fmt::as_identifiers]] scattered {
a = 1, b = 17, c = 33, d = 49, e = 65, f = 81,
g = 97, h = 113, i = 129, j = 145, k = 161, l = 177
};
// Values that collide in the hash table: c0, c7 and c15 share a slot, and c6
// occupies the next one, so probing for c7 and c15 has to step over it.
enum class [[=fmt::as_identifiers]] collision {
c0 = 0, c6 = 6, c7 = 7, c15 = 15
};
// Values that collide in the last slot of the hash table, so the probe
// sequence wraps around to the beginning.
enum class [[=fmt::as_identifiers]] wrapping_collision {
w8 = 8, w16 = 16, w24 = 24
};
// Aliased values in an enum that is too sparse for a lookup table.
enum class [[=fmt::as_identifiers]] sparse_alias {
one = 1, dup = 1, far = 1000
};
enum class [[=fmt::as_identifiers]] extremes : int {
lowest = INT_MIN,
highest = INT_MAX
@ -97,6 +116,8 @@ TEST(enum_test, format_holey_enum) {
TEST(enum_test, format_sparse_enum) {
// One more hole than holey, which is too many for a lookup table.
static_assert(fmt::detail::identifier_table_size<sparse>() == 0);
// 7 enumerators need 16 slots to keep the load factor at or below 0.5.
static_assert(fmt::detail::identifier_map_size<sparse>() == 16);
EXPECT_EQ(fmt::format("{}", sparse::s0), "s0");
EXPECT_EQ(fmt::format("{}", sparse::s5), "s5");
EXPECT_EQ(fmt::format("{}", sparse::s6), "s6");
@ -115,6 +136,68 @@ TEST(enum_test, format_enum_with_negative_values) {
EXPECT_EQ(fmt::format("{}", static_cast<signed_enum>(2)), "2");
}
TEST(enum_test, format_scattered_enum) {
static_assert(fmt::detail::identifier_table_size<scattered>() == 0);
static_assert(fmt::detail::identifier_map_size<scattered>() == 32);
EXPECT_EQ(fmt::format("{}", scattered::a), "a");
EXPECT_EQ(fmt::format("{}", scattered::b), "b");
EXPECT_EQ(fmt::format("{}", scattered::c), "c");
EXPECT_EQ(fmt::format("{}", scattered::d), "d");
EXPECT_EQ(fmt::format("{}", scattered::e), "e");
EXPECT_EQ(fmt::format("{}", scattered::f), "f");
EXPECT_EQ(fmt::format("{}", scattered::g), "g");
EXPECT_EQ(fmt::format("{}", scattered::h), "h");
EXPECT_EQ(fmt::format("{}", scattered::i), "i");
EXPECT_EQ(fmt::format("{}", scattered::j), "j");
EXPECT_EQ(fmt::format("{}", scattered::k), "k");
EXPECT_EQ(fmt::format("{}", scattered::l), "l");
EXPECT_EQ(fmt::format("{}", static_cast<scattered>(0)), "0");
EXPECT_EQ(fmt::format("{}", static_cast<scattered>(-1)), "-1");
EXPECT_EQ(fmt::format("{}", static_cast<scattered>(999)), "999");
}
TEST(enum_test, format_enum_with_hash_collision) {
static_assert(fmt::detail::identifier_table_size<collision>() == 0);
static_assert(fmt::detail::identifier_map_size<collision>() == 8);
// Three of the four values want the same slot and the fourth takes the slot
// next to it, filling the table to its maximum load factor of 0.5.
static_assert(fmt::detail::identifier_slot(collision::c0) ==
fmt::detail::identifier_slot(collision::c7));
static_assert(fmt::detail::identifier_slot(collision::c0) ==
fmt::detail::identifier_slot(collision::c15));
static_assert(fmt::detail::identifier_slot(collision::c6) !=
fmt::detail::identifier_slot(collision::c0));
EXPECT_EQ(fmt::format("{}", collision::c0), "c0");
EXPECT_EQ(fmt::format("{}", collision::c6), "c6");
EXPECT_EQ(fmt::format("{}", collision::c7), "c7");
EXPECT_EQ(fmt::format("{}", collision::c15), "c15");
// A value that collides with the enumerators but doesn't match any of them
// is rejected after probing the whole chain.
static_assert(fmt::detail::identifier_slot(static_cast<collision>(23)) ==
fmt::detail::identifier_slot(collision::c0));
EXPECT_EQ(fmt::format("{}", static_cast<collision>(23)), "23");
}
TEST(enum_test, format_enum_with_wrapping_hash_collision) {
static_assert(fmt::detail::identifier_table_size<wrapping_collision>() == 0);
static_assert(fmt::detail::identifier_map_size<wrapping_collision>() == 8);
// All the values want the last slot, so the probe sequence wraps around.
static_assert(fmt::detail::identifier_slot(wrapping_collision::w8) == 7);
static_assert(fmt::detail::identifier_slot(wrapping_collision::w16) == 7);
static_assert(fmt::detail::identifier_slot(wrapping_collision::w24) == 7);
EXPECT_EQ(fmt::format("{}", wrapping_collision::w8), "w8");
EXPECT_EQ(fmt::format("{}", wrapping_collision::w16), "w16");
EXPECT_EQ(fmt::format("{}", wrapping_collision::w24), "w24");
EXPECT_EQ(fmt::format("{}", static_cast<wrapping_collision>(33)), "33");
}
TEST(enum_test, format_sparse_enum_alias) {
// The first enumerator with a matching value is used in the hash table too.
static_assert(fmt::detail::identifier_table_size<sparse_alias>() == 0);
EXPECT_EQ(fmt::format("{}", sparse_alias::dup), "one");
EXPECT_EQ(fmt::format("{}", sparse_alias::far), "far");
}
TEST(enum_test, format_enum_with_extreme_values) {
// The span of the values overflows the underlying type, so no table is used.
static_assert(fmt::detail::identifier_table_size<extremes>() == 0);