From 103d9c4662a0c927f8f9c4db49e1c3836fc54d2d Mon Sep 17 00:00:00 2001 From: Avi Kivity Date: Mon, 24 Aug 2026 19:46:19 +0300 Subject: [PATCH] Look up sparse enum identifiers in a hash table instead of a linear search 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) --- include/fmt/enum.h | 59 +++++++++++++++++++++++++------- test/enum-test.cc | 85 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 131 insertions(+), 13 deletions(-) diff --git a/include/fmt/enum.h b/include/fmt/enum.h index 841f8daef3af..bbe1a18ff4eb 100644 --- a/include/fmt/enum.h +++ b/include/fmt/enum.h @@ -124,17 +124,50 @@ consteval auto make_identifier_table() -> std::array { template inline constexpr auto identifier_table = make_identifier_table(); -template ()> -consteval auto make_identifiers() -> std::array, N> { - auto ids = std::array, N>(); - auto i = size_t(); - for (std::meta::info e : std::meta::enumerators_of(^^E)) - ids[i++] = {std::meta::extract(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 consteval auto identifier_map_size() -> size_t { + auto size = size_t(1); + while (size < count_enumerators() * 2) size *= 2; + return size; +} + +// 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 ()> +constexpr auto identifier_slot(E value) -> size_t { + auto h = to_uint64(value); + h ^= h >> 33; + h *= 0xff51afd7ed558ccd; + h ^= h >> 33; + return static_cast(h & (N - 1)); +} + +template ()> +consteval auto make_identifier_map() + -> std::array, N> { + auto map = std::array, N>(); + for (std::meta::info e : std::meta::enumerators_of(^^E)) { + auto value = std::meta::extract(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 the order of declaration. -template inline constexpr auto identifiers = make_identifiers(); +// Identifiers of enumerators of E in an open-addressed hash table with linear +// probing and empty string views in the free slots. +template +inline constexpr auto identifier_map = make_identifier_map(); // 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 constexpr auto identifier_of(E value) -> string_view { return i < table_size ? identifier_table[static_cast(i)] : string_view(); } else { - for (const auto& id : identifiers) { - if (id.first == value) return id.second; + constexpr size_t map_size = identifier_map_size(); + // 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[i]; + if (entry.second.size() == 0 || entry.first == value) return entry.second; } - return {}; } } diff --git a/test/enum-test.cc b/test/enum-test.cc index 662399e3e5c3..b424ef782f1b 100644 --- a/test/enum-test.cc +++ b/test/enum-test.cc @@ -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() == 0); + // 7 enumerators need 16 slots to keep the load factor at or below 0.5. + static_assert(fmt::detail::identifier_map_size() == 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(2)), "2"); } +TEST(enum_test, format_scattered_enum) { + static_assert(fmt::detail::identifier_table_size() == 0); + static_assert(fmt::detail::identifier_map_size() == 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(0)), "0"); + EXPECT_EQ(fmt::format("{}", static_cast(-1)), "-1"); + EXPECT_EQ(fmt::format("{}", static_cast(999)), "999"); +} + +TEST(enum_test, format_enum_with_hash_collision) { + static_assert(fmt::detail::identifier_table_size() == 0); + static_assert(fmt::detail::identifier_map_size() == 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(23)) == + fmt::detail::identifier_slot(collision::c0)); + EXPECT_EQ(fmt::format("{}", static_cast(23)), "23"); +} + +TEST(enum_test, format_enum_with_wrapping_hash_collision) { + static_assert(fmt::detail::identifier_table_size() == 0); + static_assert(fmt::detail::identifier_map_size() == 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(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() == 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() == 0);