From fc4feebcc33c0b1c3b97631569ab40248c52acac Mon Sep 17 00:00:00 2001 From: Jean-Louis Leroy Date: Thu, 10 Sep 2026 20:39:27 -0400 Subject: [PATCH 1/5] policies: add minimal_perfect_hash, two_level_hash and minimal_cover_hash Three new type_hash policies, from the research for #57. fast_perfect_hash stays the default: none of these beats it as a general v-table lookup. What they offer is a way out of its one real failure mode - its randomized search costs tens to hundreds of milliseconds on a sparse type id set and gives up outright on a large one, and the table it finds is sized by where the addresses are rather than by how many there are. minimal_perfect_hash (#56) is hash-and-displace: one slot per type id whatever the addresses are, a search whose cost depends only on the class count, and no instruction-set requirement. It pays with a second dependent load on the dispatch path. It is the one to reach for in a program that dlopens modules registering classes of their own. two_level_hash is the same family with the final reduction replaced by a shift into a power-of-two table: cheaper per dispatch where the compiler hoists the shift amount, at the cost of a table that is a sawtooth between 1.0 and 2.0 slots per type id rather than a flat figure. minimal_cover_hash picks the smallest set of bit positions that still separates the type ids and extracts them with pext. It dispatches as fast as the default and finds its table deterministically, but BMI2 is not a portable requirement, so the header always compiles and naming the policy in a registry is what fails when the instruction is unavailable - with a diagnostic that names both the flag and the portable alternative. Four changes from the prototypes, beyond namespace and naming: - detail::uintptr moves from fast_perfect_hash.hpp to preamble.hpp. Every type_hash policy needs it; only one of them had it. - minimal_perfect_hash and two_level_hash hash `x + 1`. Zero is a fixed point of a multiply, so a type id of 0 would be pinned to slot 0 for every seed and every pilot and the search could fail spuriously. One increment on the dispatch path buys a policy that works for any type id, including the small integers a custom rtti policy may hand out. - two_level_hash drops the M1Candidates knob: scoring first-level multipliers for even buckets was measured and does not pay, because the placement cost is set by the tail, where every remaining bucket faces an almost full table. Its table cap is now relative to the class count rather than an absolute 2^26, which could ask for half a gigabyte. - aux_bytes() is gone from both. It is not part of the TypeHashFn contract; it existed so a benchmark could report the pilot array size. finalize() now releases that array, which the prototypes leaked until the registry died. Docs and tests follow. --- .../openmethod/policies/fast_perfect_hash.hpp | 12 +- .../policies/minimal_cover_hash.hpp | 470 +++++++++++++++++ .../policies/minimal_perfect_hash.hpp | 474 ++++++++++++++++++ .../openmethod/policies/two_level_hash.hpp | 437 ++++++++++++++++ include/boost/openmethod/preamble.hpp | 21 + 5 files changed, 1404 insertions(+), 10 deletions(-) create mode 100644 include/boost/openmethod/policies/minimal_cover_hash.hpp create mode 100644 include/boost/openmethod/policies/minimal_perfect_hash.hpp create mode 100644 include/boost/openmethod/policies/two_level_hash.hpp diff --git a/include/boost/openmethod/policies/fast_perfect_hash.hpp b/include/boost/openmethod/policies/fast_perfect_hash.hpp index 3abd355f..066964ce 100644 --- a/include/boost/openmethod/policies/fast_perfect_hash.hpp +++ b/include/boost/openmethod/policies/fast_perfect_hash.hpp @@ -22,16 +22,8 @@ namespace boost::openmethod { namespace detail { -#if defined(UINTPTR_MAX) -using uintptr = std::uintptr_t; -constexpr uintptr uintptr_max = UINTPTR_MAX; -#else -static_assert( - sizeof(std::size_t) == sizeof(void*), - "This implementation requires that size_t and void* have the same size."); -using uintptr = std::size_t; -constexpr uintptr uintptr_max = (std::numeric_limits::max)(); -#endif +// detail::uintptr and detail::uintptr_max are in preamble.hpp: every +// `type_hash` policy needs them, not just this one. struct hash_fn { std::size_t mult; diff --git a/include/boost/openmethod/policies/minimal_cover_hash.hpp b/include/boost/openmethod/policies/minimal_cover_hash.hpp new file mode 100644 index 00000000..08a2ad30 --- /dev/null +++ b/include/boost/openmethod/policies/minimal_cover_hash.hpp @@ -0,0 +1,470 @@ +// Copyright (c) 2017-2026 Jean-Louis Leroy +// Distributed under the Boost Software License, Version 1.0. +// See accompanying file LICENSE_1_0.txt +// or copy at http://www.boost.org/LICENSE_1_0.txt) + +#ifndef BOOST_OPENMETHOD_POLICY_MINIMAL_COVER_HASH_HPP +#define BOOST_OPENMETHOD_POLICY_MINIMAL_COVER_HASH_HPP + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +//! Whether @ref boost::openmethod::policies::minimal_cover_hash can be used on +//! this target. +//! +//! 1 if the compiler can emit BMI2's parallel bit extract, `pext`, and 0 +//! otherwise. The header always compiles; what fails, with a diagnostic, is +//! naming the policy in a registry when this is 0. +//! +//! GCC and clang define `__BMI2__` when the instruction is enabled, which takes +//! `-mbmi2` or a `-march=` that implies it. MSVC gates nothing on a macro and +//! emits the instruction from the intrinsic, so there the test is only that the +//! target is x86 - and it remains the program's business to run on a CPU that +//! has the instruction. +#if defined(__BMI2__) || \ + (defined(_MSC_VER) && (defined(_M_X64) || defined(_M_IX86))) +#define BOOST_OPENMETHOD_HAS_PEXT 1 +#else +#define BOOST_OPENMETHOD_HAS_PEXT 0 +#endif + +#if BOOST_OPENMETHOD_HAS_PEXT +#include +#endif + +namespace boost::openmethod { + +namespace detail { + +// Cold path only: the cover search counts mask bits, the dispatch path does +// not. Plain C++ rather than an intrinsic, so it carries no instruction-set +// requirement of its own - minimal_cover_hash already has one, and one is +// quite enough. +inline auto popcount64(std::uint64_t bits) -> std::size_t { +#if defined(__GNUC__) || defined(__clang__) + return std::size_t(__builtin_popcountll(bits)); +#else + bits = bits - ((bits >> 1) & 0x5555555555555555ull); + bits = + (bits & 0x3333333333333333ull) + ((bits >> 2) & 0x3333333333333333ull); + bits = (bits + (bits >> 4)) & 0x0f0f0f0f0f0f0f0full; + + return std::size_t((bits * 0x0101010101010101ull) >> 56); +#endif +} + +// The dispatch path's one instruction. Wrapped so that the header parses on a +// target without it: the stub is never reached, because naming the policy in a +// registry static_asserts first. +inline auto pext64(std::uint64_t value, std::uint64_t mask) -> std::uint64_t { +#if BOOST_OPENMETHOD_HAS_PEXT + return _pext_u64(value, mask); +#else + (void)value; + (void)mask; + + return 0; +#endif +} + +} // namespace detail + +namespace policies { + +//! Map type ids to indexes by extracting a minimal cover of their bits. +//! +//! `minimal_cover_hash` implements the @ref type_hash policy as +//! `H(x) = pext(x, mask)`: the bits of `x` selected by `mask`, packed into the +//! low `popcount(mask)` positions by BMI2's parallel bit extract, one +//! instruction. The index range is `[0, 2^popcount(mask))`. +//! +//! `mask` is a *minimal cover*: a smallest-found set of bit positions such that +//! `x & mask` is still injective over the registered type ids. That is exactly +//! the condition for `pext` to be injective, so the search never needs `pext` +//! itself. Unlike @ref fast_perfect_hash's randomized multiplier search it is +//! deterministic, and it finishes in milliseconds on inputs where that search +//! gives up: +//! +//! @li the bits that vary at all are trivially a cover; +//! @li a greedy pass drops bits, lowest entropy first, while injectivity holds; +//! @li a second greedy pass builds a cover bottom-up, adding the bit that +//! resolves the most collisions each time, then trims it the same way; +//! @li the smaller of the two wins. +//! +//! **Choose it when dispatch must not get slower but the default search is a +//! problem.** One `pext` costs about what a multiply and a shift cost, so +//! dispatch is as fast as with @ref fast_perfect_hash; what this buys is a +//! table found deterministically, in bounded time. Its footprint is comparable +//! to `fast_perfect_hash`{empty}'s - both widen when the type ids are sparse, +//! and for the same reason - so it is not the policy to pick for memory. +//! @ref minimal_perfect_hash is. +//! +//! Type ids from different modules differ in many high bits, but those bits are +//! perfectly correlated, since they all encode which module. The cover keeps +//! about `log2(modules)` of them and the rest cost nothing, so a program that +//! `dlopen`{empty}s modules pays one extra bit rather than an unusable table. +//! +//! @warning **BMI2 is required, and that is not a portable requirement.** `pext` +//! is absent on ARM and on x86 before Haswell and Excavator, and is microcoded +//! on AMD Zen 1 and Zen 2 - around 18 cycles rather than 3 - where this policy +//! will be slower than the default rather than faster. Because @ref hash is +//! inlined into every dispatch, `-mbmi2` (or a `-march=` implying it) has to be +//! set for **every** translation unit of the program, and of any module sharing +//! the registry, not just one; a binary built with it executes an illegal +//! instruction on the first dispatch on a CPU that lacks `pext`. Naming this +//! policy in a registry where @ref BOOST_OPENMETHOD_HAS_PEXT is 0 is a compile +//! error. @ref minimal_perfect_hash is the portable alternative, at a cost of a +//! nanosecond or two per call. +//! +//! After "Perfect Hashing in an Imperfect World", Joaquin M. Lopez Munoz. +//! +//! @tparam MaxBits Refuse a cover wider than this; the table is `8 << bits` +//! bytes. +//! +//! @par Example +//! include:policies.cpp#minimal_cover_hash +//! +//! @see [Registries and Policies](xref:ROOT:registries_and_policies.adoc) +template +struct minimal_cover_hash : type_hash { + + //! The minimal cover found is too wide for a table. + struct too_many_bits : openmethod_error { + //! Number of registered type ids. + std::size_t classes; + //! Width of the smallest cover found. + std::size_t bits; + + template + auto write(Stream& os) const -> void; + }; + + using errors = std::variant; + + //! `state` layout when runtime checks are disabled. + struct no_checks { + //! The bit positions to extract. + std::uint64_t mask; + //! The highest index in use. + std::size_t max_value; + }; + + //! `state` layout when runtime checks are enabled: adds the table of + //! registered type ids used to validate hashed types. + struct with_checks : no_checks { + std::vector control; + }; + + //! A TypeHashFn metafunction. + //! + //! @tparam Registry The registry containing this policy + template + class fn { + static_assert( + BOOST_OPENMETHOD_HAS_PEXT, + "minimal_cover_hash needs BMI2: compile every translation unit " + "with -mbmi2 (or a -march= that implies it), or use " + "minimal_perfect_hash, which is portable."); + + public: + using state = std::conditional_t< + Registry::has_runtime_checks, with_checks, no_checks>; + + private: + static auto& st() { + return Registry::template state>(); + } + + static void check(std::size_t index, type_id type); + + static auto injective( + const std::vector& ids, std::uint64_t mask, + std::vector& scratch) -> bool; + static auto collisions( + const std::vector& ids, std::uint64_t mask, + std::vector& scratch) -> std::size_t; + static auto minimal_cover(const std::vector& ids) + -> std::uint64_t; + + public: + //! Finds the cover. + //! + //! @tparam Context An @ref InitializeContext. + //! @param ctx A Context object. + //! @param options A tuple of option objects. + template + static auto initialize( + const Context& ctx, const std::tuple& options) -> void; + + //! Returns the hash range: `[0, max index in use]`. + static auto hash_range() -> std::pair { + return std::pair{std::size_t(0), st().max_value}; + } + + //! Map a type id to an index + //! + //! @param type The type_id to map + //! @return The index + BOOST_FORCEINLINE + static auto hash(type_id type) -> std::size_t { + auto index = std::size_t( + detail::pext64( + static_cast( + reinterpret_cast(type)), + st().mask)); + + if constexpr (Registry::has_runtime_checks) { + check(index, type); + } + + return index; + } + + //! Releases the control table, if there is one. + template + static auto finalize(const std::tuple& options) -> void { + (void)options; + + st().mask = 0; + st().max_value = 0; + + if constexpr (Registry::has_runtime_checks) { + st().control.clear(); + st().control.shrink_to_fit(); + } + } + }; +}; + +template +template +auto minimal_cover_hash::fn::injective( + const std::vector& ids, std::uint64_t mask, + std::vector& scratch) -> bool { + scratch.clear(); + + for (auto id : ids) { + scratch.push_back(id & mask); + } + + std::sort(scratch.begin(), scratch.end()); + + return std::adjacent_find(scratch.begin(), scratch.end()) == scratch.end(); +} + +template +template +auto minimal_cover_hash::fn::collisions( + const std::vector& ids, std::uint64_t mask, + std::vector& scratch) -> std::size_t { + scratch.clear(); + + for (auto id : ids) { + scratch.push_back(id & mask); + } + + std::sort(scratch.begin(), scratch.end()); + std::size_t count = 0; + + for (std::size_t i = 1; i < scratch.size(); ++i) { + count += scratch[i] == scratch[i - 1]; + } + + return count; +} + +template +template +auto minimal_cover_hash::fn::minimal_cover( + const std::vector& ids) -> std::uint64_t { + // The bits that vary at all: trivially a cover, since two distinct ids + // differ in at least one of them. + std::uint64_t universe = 0; + + for (auto id : ids) { + universe |= id ^ ids.front(); + } + + // Entropy of each varying bit: a bit almost every id agrees on separates + // few pairs, so it is the first candidate for dropping. + struct bit_info { + int bit; + double entropy; + }; + + std::vector bits; + + for (int bit = 0; bit < 64; ++bit) { + if (!((universe >> bit) & 1)) { + continue; + } + + std::size_t ones = 0; + + for (auto id : ids) { + ones += (id >> bit) & 1; + } + + auto p = double(ones) / double(ids.size()); + auto entropy = (p <= 0.0 || p >= 1.0) + ? 0.0 + : -(p * std::log2(p) + (1 - p) * std::log2(1 - p)); + bits.push_back({bit, entropy}); + } + + std::stable_sort( + bits.begin(), bits.end(), [](const bit_info& a, const bit_info& b) { + return a.entropy < b.entropy; + }); + + std::vector scratch; + scratch.reserve(ids.size()); + + auto drop = [&](std::uint64_t mask) { + for (const auto& info : bits) { + auto candidate = mask & ~(std::uint64_t(1) << info.bit); + + if (candidate != mask && injective(ids, candidate, scratch)) { + mask = candidate; + } + } + + return mask; + }; + + auto by_drop = drop(universe); + + // Bottom-up: add the bit that resolves the most collisions. + std::uint64_t by_add = 0; + + while (!injective(ids, by_add, scratch)) { + int best_bit = -1; + std::size_t best_count = (std::numeric_limits::max)(); + + for (const auto& info : bits) { + auto bit = std::uint64_t(1) << info.bit; + + if (by_add & bit) { + continue; + } + + auto count = collisions(ids, by_add | bit, scratch); + + if (count < best_count) { + best_count = count; + best_bit = info.bit; + } + } + + by_add |= std::uint64_t(1) << best_bit; + } + + by_add = drop(by_add); + + return detail::popcount64(by_add) < detail::popcount64(by_drop) ? by_add + : by_drop; +} + +template +template +template +auto minimal_cover_hash::fn::initialize( + const Context& ctx, const std::tuple& options) -> void { + (void)options; + + std::vector ids; + + for (auto iter = ctx.classes_begin(); iter != ctx.classes_end(); ++iter) { + for (auto type_iter = iter->type_id_begin(); + type_iter != iter->type_id_end(); ++type_iter) { + ids.push_back( + static_cast( + reinterpret_cast(*type_iter))); + } + } + + // One class may be registered under the same type id by several modules; + // the cover is over *distinct* ids. + std::sort(ids.begin(), ids.end()); + ids.erase(std::unique(ids.begin(), ids.end()), ids.end()); + + if (ids.empty()) { + st().mask = 0; + st().max_value = 0; + + return; + } + + auto mask = ids.size() == 1 ? 0 : minimal_cover(ids); + auto bits = detail::popcount64(mask); + + if (bits > MaxBits) { + too_many_bits error; + error.classes = ids.size(); + error.bits = bits; + + if constexpr (Registry::has_error_handler) { + Registry::error_handler::error(error); + } + + abort(); + } + + st().mask = mask; + st().max_value = 0; + + for (auto id : ids) { + st().max_value = + (std::max)(st().max_value, std::size_t(detail::pext64(id, mask))); + } + + if constexpr (Context::template has_option) { + ctx.tr << " type ids: " << ids.size() << ", cover: " << bits + << " bits, table: " << (st().max_value + 1) << " slots\n"; + } + + if constexpr (Registry::has_runtime_checks) { + st().control.assign(st().max_value + 1, type_id(detail::uintptr_max)); + + for (auto id : ids) { + st().control[std::size_t(detail::pext64(id, mask))] = + reinterpret_cast(id); + } + } +} + +template +template +void minimal_cover_hash::fn::check( + std::size_t index, type_id type) { + if (index > st().max_value || st().control[index] != type) { + if constexpr (Registry::has_error_handler) { + missing_class error; + error.type = type; + Registry::error_handler::error(error); + } + + abort(); + } +} + +template +template +auto minimal_cover_hash::too_many_bits::write(Stream& os) const + -> void { + os << "the smallest bit cover of " << classes << " type ids is " << bits + << " bits wide, more than the " << MaxBits << " allowed\n"; +} + +} // namespace policies +} // namespace boost::openmethod + +#endif diff --git a/include/boost/openmethod/policies/minimal_perfect_hash.hpp b/include/boost/openmethod/policies/minimal_perfect_hash.hpp new file mode 100644 index 00000000..8e3ce29d --- /dev/null +++ b/include/boost/openmethod/policies/minimal_perfect_hash.hpp @@ -0,0 +1,474 @@ +// Copyright (c) 2017-2026 Jean-Louis Leroy +// Distributed under the Boost Software License, Version 1.0. +// See accompanying file LICENSE_1_0.txt +// or copy at http://www.boost.org/LICENSE_1_0.txt) + +#ifndef BOOST_OPENMETHOD_POLICY_MINIMAL_PERFECT_HASH_HPP +#define BOOST_OPENMETHOD_POLICY_MINIMAL_PERFECT_HASH_HPP + +#include + +#include +#include +#include +#include +#include +#include +#include + +#if defined(_MSC_VER) +#include +#endif + +namespace boost::openmethod::policies { + +//! Map type ids to a dense index with a minimal perfect hash. +//! +//! `minimal_perfect_hash` implements the @ref type_hash policy by hash and +//! displace, after Belazzougui, Botelho and Dietzfelbinger, without the +//! compression step: +//! +//! @code +//! h = x * seed; // one multiply +//! p = pilots[h >> bucket_shift]; // this bucket's pilot +//! index = mulhi(h * p, slots); // displace, then reduce +//! @endcode +//! +//! The type ids are split into `n / Lambda` buckets by the top bits of `h`. +//! Buckets are placed largest first; each is given a 32-bit odd *pilot*, found +//! by trial, such that multiplying the key by it sends every id in the bucket +//! to a slot that is still free. The final reduction is a multiply-shift - the +//! top half of a 64x64 product - which maps onto `[0, slots)` for any `slots` +//! without a division. +//! +//! **Choose it when the table size matters more than the last nanosecond.** +//! Unlike @ref fast_perfect_hash, whose table is sized by the *distribution* +//! of the type ids and whose randomized search can fail outright on a large, +//! sparse set, this one spends `8 * n * 100 / LoadPercent` bytes of v-table +//! vector plus `4 * n / Lambda` bytes of pilots **whatever the addresses are**, +//! and its search time depends only on how many type ids there are, not where +//! they sit. That makes it the policy to reach for in a program that `dlopen`s +//! modules registering classes of their own, where type ids from different +//! modules are far apart and in unrelated ranges. +//! +//! The price is on the dispatch path: the pilot must be loaded before the index +//! can be formed, so the v-table lookup becomes two dependent loads instead of +//! one. Expect it to cost a nanosecond or two per call relative to +//! @ref fast_perfect_hash. +//! +//! It needs no instruction-set extension - a 64-bit multiply and a shift exist +//! everywhere - and makes no assumption about the layout of the type ids, so +//! unlike @ref minimal_cover_hash it is available on every target, and unlike +//! a scheme keyed on address arithmetic it does not depend on +//! @ref std_rtti. +//! +//! `LoadPercent = 100` asks for an exactly minimal table. It is reachable, but +//! not in bounded time at a large `Lambda`: the last buckets have to hit the +//! last few free slots, and the expected number of trials for a bucket of size +//! `s` facing a fraction `phi` of free slots grows as `phi^-s`. A few percent +//! of slack removes that tail. Note also that an exactly minimal table is not +//! the smallest *total*: reaching it needs the buckets halved, which doubles +//! the pilot array, and that costs more than the slots it recovers. +//! +//! @tparam Lambda Average bucket size. Larger means a smaller pilot table and +//! a longer search. +//! @tparam LoadPercent Slots per 100 type ids, inverted: 100 is an exactly +//! minimal table, 95 leaves one slot free in twenty. +//! @tparam MaxSeeds How many multipliers to try before reporting +//! @ref search_error. Raising it rarely helps on its own - a set that +//! defeats one multiplier usually defeats them all at that `Lambda` and +//! `LoadPercent`; lower `Lambda` or `LoadPercent` instead. +//! +//! @par Example +//! include:policies.cpp#minimal_perfect_hash +//! +//! @see [Registries and Policies](xref:ROOT:registries_and_policies.adoc) +template< + std::size_t Lambda = 4, std::size_t LoadPercent = 95, + std::size_t MaxSeeds = 16> +struct minimal_perfect_hash : type_hash { + + //! No seed yielded a complete assignment. + struct search_error : openmethod_error { + //! Number of registered type ids. + std::size_t classes; + //! Number of multipliers tried. + std::size_t seeds; + + template + auto write(Stream& os) const -> void; + }; + + using errors = std::variant; + + static_assert(Lambda > 0); + static_assert(LoadPercent > 0 && LoadPercent <= 100); + + //! `state` layout when runtime checks are disabled. + struct no_checks { + //! The multiplier. + std::uint64_t seed; + //! `64 - log2(buckets)`. + std::size_t bucket_shift; + //! The number of slots. + std::uint64_t size; + //! One 32-bit pilot per bucket. + std::vector pilots; + }; + + //! `state` layout when runtime checks are enabled: adds the table of + //! registered type ids used to validate hashed types. + struct with_checks : no_checks { + std::vector control; + }; + + //! A TypeHashFn metafunction. + //! + //! @tparam Registry The registry containing this policy + template + class fn { + public: + using state = std::conditional_t< + Registry::has_runtime_checks, with_checks, no_checks>; + + private: + static auto& st() { + return Registry::template state< + minimal_perfect_hash>(); + } + + static void check(std::size_t index, type_id type); + + // The key a type id hashes as. The `+ 1` keeps zero out of the + // domain: zero is a fixed point of the multiply, so a type id of 0 + // would land in slot 0 for every seed and every pilot, and the search + // would fail whenever another bucket had taken that slot first. One + // increment on the dispatch path buys a policy that works for any type + // id, including the small integers a custom `rtti` policy may hand out. + static auto key(type_id type) -> std::uint64_t { + return std::uint64_t(reinterpret_cast(type)) + 1; + } + + // The pilot tried at step `k`. Multiplying by an odd constant is a + // bijection on 32 bits, so the sequence walks the whole range; the + // low bit is set because an even pilot loses the key's high bits. + static auto pilot_at(std::uint32_t k) -> std::uint32_t { + return (k * 0x9e3779b9u) | 1u; + } + + // The top half of a 64x64 product. + static auto mulhi(std::uint64_t a, std::uint64_t b) -> std::uint64_t { +#if defined(__SIZEOF_INT128__) + return std::uint64_t((static_cast<__uint128_t>(a) * b) >> 64); +#elif defined(_MSC_VER) && defined(_M_X64) + return __umulh(a, b); +#else + auto lo = [](std::uint64_t v) { return v & 0xffffffffull; }; + auto hi = [](std::uint64_t v) { return v >> 32; }; + auto ll = lo(a) * lo(b); + auto lh = lo(a) * hi(b); + auto hl = hi(a) * lo(b); + auto hh = hi(a) * hi(b); + auto mid = hi(ll) + lo(lh) + lo(hl); + + return hh + hi(lh) + hi(hl) + hi(mid); +#endif + } + + // Where a pilot sends a key. + static auto place( + std::uint64_t h, std::uint32_t pilot, std::uint64_t slots) + -> std::size_t { + return std::size_t(mulhi(h * pilot, slots)); + } + + static auto mix_seed(std::uint64_t z) -> std::uint64_t { + z += 0x9e3779b97f4a7c15ull; + z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9ull; + z = (z ^ (z >> 27)) * 0x94d049bb133111ebull; + + return (z ^ (z >> 31)) | 1; + } + + static auto build( + const std::vector& ids, std::uint64_t seed, + std::size_t bucket_shift, std::uint64_t slots, + std::vector& pilots) -> bool; + + public: + //! Finds a seed and the pilots. + //! + //! @tparam Context An @ref InitializeContext. + //! @param ctx A Context object. + //! @param options A tuple of option objects. + template + static auto initialize( + const Context& ctx, const std::tuple& options) -> void; + + //! Returns the hash range: `[0, slots - 1]`. + static auto hash_range() -> std::pair { + return std::pair{ + std::size_t(0), std::size_t(st().size ? st().size - 1 : 0)}; + } + + //! Map a type id to an index + //! + //! @param type The type_id to map + //! @return The index + BOOST_FORCEINLINE + static auto hash(type_id type) -> std::size_t { + auto h = key(type) * st().seed; + auto pilot = st().pilots[std::size_t(h >> st().bucket_shift)]; + auto index = place(h, pilot, st().size); + + if constexpr (Registry::has_runtime_checks) { + check(index, type); + } + + return index; + } + + //! Releases the pilot table, and the control table if there is one. + template + static auto finalize(const std::tuple& options) -> void { + (void)options; + + st().pilots.clear(); + st().pilots.shrink_to_fit(); + st().size = 0; + + if constexpr (Registry::has_runtime_checks) { + st().control.clear(); + st().control.shrink_to_fit(); + } + } + }; +}; + +template +template +auto minimal_perfect_hash::fn::build( + const std::vector& ids, std::uint64_t seed, + std::size_t bucket_shift, std::uint64_t slots, + std::vector& pilots) -> bool { + auto n = ids.size(); + auto buckets = pilots.size(); + + // Bucket each id, and keep the hashed key the pilot will displace. + std::vector keys(n); + std::vector bucket_of(n); + + for (std::size_t i = 0; i != n; ++i) { + auto h = ids[i] * seed; + bucket_of[i] = std::uint32_t(h >> bucket_shift); + keys[i] = h; + } + + // Group by bucket: counting sort into a CSR-style pair of arrays. + std::vector start(buckets + 1, 0); + + for (auto b : bucket_of) { + ++start[b + 1]; + } + + std::partial_sum(start.begin(), start.end(), start.begin()); + std::vector members(n); + auto fill = start; + + for (std::size_t i = 0; i != n; ++i) { + members[fill[bucket_of[i]]++] = std::uint32_t(i); + } + + // Largest buckets first: a big bucket is placeable only while the table + // is still mostly empty. + std::vector order(buckets); + std::iota(order.begin(), order.end(), std::uint32_t(0)); + std::stable_sort( + order.begin(), order.end(), [&](std::uint32_t a, std::uint32_t b) { + return (start[a + 1] - start[a]) > (start[b + 1] - start[b]); + }); + + // The last buckets face a nearly full table. A bucket of size `s` with a + // fraction `phi` of the slots free needs about `phi^-s` trials, so the + // budget has to be generous; it is a diagnostic, not a working limit. + const std::uint32_t max_tries = 1u << 20; + + std::vector occupied(std::size_t(slots), 0); + std::vector placed; + placed.reserve(64); + + for (auto b : order) { + auto first = start[b], last = start[b + 1]; + + if (first == last) { + pilots[b] = 0; + + continue; + } + + bool done = false; + + for (std::uint32_t k = 0; k != max_tries; ++k) { + auto pilot = pilot_at(k); + placed.clear(); + bool ok = true; + + for (auto m = first; m != last; ++m) { + auto index = place(keys[members[m]], pilot, slots); + + if (occupied[index] || + std::find(placed.begin(), placed.end(), index) != + placed.end()) { + ok = false; + + break; + } + + placed.push_back(index); + } + + if (ok) { + for (auto index : placed) { + occupied[index] = 1; + } + + pilots[b] = pilot; + done = true; + + break; + } + } + + if (!done) { + return false; + } + } + + return true; +} + +template +template +template +auto minimal_perfect_hash::fn:: + initialize(const Context& ctx, const std::tuple& options) + -> void { + (void)options; + + std::vector ids; + + for (auto iter = ctx.classes_begin(); iter != ctx.classes_end(); ++iter) { + for (auto type_iter = iter->type_id_begin(); + type_iter != iter->type_id_end(); ++type_iter) { + ids.push_back(key(*type_iter)); + } + } + + // One class may be registered under the same type id by several modules; + // the table is over *distinct* ids. + std::sort(ids.begin(), ids.end()); + ids.erase(std::unique(ids.begin(), ids.end()), ids.end()); + + auto n = ids.size(); + + if (n == 0) { + st().seed = 1; + st().bucket_shift = 63; + st().size = 0; + st().pilots.assign(2, 0); + + return; + } + + // Slots. `LoadPercent = 100` asks for exactly one per type id. + auto slots = (n * 100 + LoadPercent - 1) / LoadPercent; + + // Smallest power of two of at least ceil(n / Lambda) buckets, and never + // fewer than two, so the shift stays below 64. + std::size_t buckets = 2; + std::size_t log_buckets = 1; + + while (buckets * Lambda < n) { + buckets <<= 1; + ++log_buckets; + } + + std::vector pilots(buckets); + std::size_t bucket_shift = 64 - log_buckets; + std::size_t seeds = 0; + bool found = false; + + for (; seeds != MaxSeeds; ++seeds) { + auto seed = mix_seed(seeds); + + if (build(ids, seed, bucket_shift, slots, pilots)) { + st().seed = seed; + found = true; + ++seeds; + + break; + } + } + + if (!found) { + search_error error; + error.classes = n; + error.seeds = seeds; + + if constexpr (Registry::has_error_handler) { + Registry::error_handler::error(error); + } + + abort(); + } + + st().bucket_shift = bucket_shift; + st().size = slots; + st().pilots = std::move(pilots); + + if constexpr (Context::template has_option) { + ctx.tr << " type ids: " << n << ", buckets: " << buckets + << ", seeds tried: " << seeds << ", table: " << slots + << " slots + " << buckets << " pilots\n"; + } + + if constexpr (Registry::has_runtime_checks) { + st().control.assign(std::size_t(slots), type_id(detail::uintptr_max)); + + for (auto id : ids) { + auto h = id * st().seed; + auto pilot = st().pilots[std::size_t(h >> bucket_shift)]; + // `ids` holds keys, so undo the offset to recover the type id + // `check` will compare against. + st().control[place(h, pilot, slots)] = + reinterpret_cast(id - 1); + } + } +} + +template +template +void minimal_perfect_hash::fn::check( + std::size_t index, type_id type) { + if (index >= st().size || st().control[index] != type) { + if constexpr (Registry::has_error_handler) { + missing_class error; + error.type = type; + Registry::error_handler::error(error); + } + + abort(); + } +} + +template +template +auto minimal_perfect_hash::search_error::write( + Stream& os) const -> void { + os << "could not place " << classes << " type ids after trying " << seeds + << " multipliers\n"; +} + +} // namespace boost::openmethod::policies + +#endif diff --git a/include/boost/openmethod/policies/two_level_hash.hpp b/include/boost/openmethod/policies/two_level_hash.hpp new file mode 100644 index 00000000..eabe8c4c --- /dev/null +++ b/include/boost/openmethod/policies/two_level_hash.hpp @@ -0,0 +1,437 @@ +// Copyright (c) 2017-2026 Jean-Louis Leroy +// Distributed under the Boost Software License, Version 1.0. +// See accompanying file LICENSE_1_0.txt +// or copy at http://www.boost.org/LICENSE_1_0.txt) + +#ifndef BOOST_OPENMETHOD_POLICY_TWO_LEVEL_HASH_HPP +#define BOOST_OPENMETHOD_POLICY_TWO_LEVEL_HASH_HPP + +#include + +#include +#include +#include +#include +#include +#include + +namespace boost::openmethod::policies { + +//! Map type ids to an index with two multiply-shifts. +//! +//! `two_level_hash` implements the @ref type_hash policy with a per-bucket +//! multiplier into a shared power-of-two table: +//! +//! @code +//! h = m1 * x; // mix once +//! h1(x) = h >> s1; // which bucket, 2^b of them +//! h2(x) = (m2[h1(x)] * h) >> s2; // the index, into 2^t slots +//! @endcode +//! +//! The first level is one imperfect multiply-shift into a fixed number of +//! buckets; the second is a *per-bucket* multiplier, found by trial, that sends +//! every id in its bucket to a slot that is still free. +//! +//! It is @ref minimal_perfect_hash with one thing changed: where that reduces +//! with the top half of a product, onto a table of any size, this one shifts, +//! onto a table whose size is a power of two. The shift is the cheaper of the +//! two where the compiler hoists the shift amount out of the dispatch loop, +//! which is worth about a nanosecond per call; where it reloads it on every +//! call the two cost the same. Both are slower than @ref fast_perfect_hash. +//! +//! What the power-of-two table costs is a *sawtooth*: it holds +//! `2^ceil(log2(n))` slots, so between 1.0 and 2.0 per type id depending on +//! where `n` falls relative to a power of two, against a flat +//! `100 / LoadPercent` for @ref minimal_perfect_hash. Which of the two is +//! smaller is decided by the class count, which a program does not usually +//! control. Prefer @ref minimal_perfect_hash when the footprint has to be +//! predictable, and this one when the dispatch path matters more. +//! +//! The second multiply has to be applied to `h`, not to `x` itself. Two type +//! ids in one module are a few tens of bytes apart, so `m2 * x` differs between +//! them by about `m2 * 16`; for a 32-bit `m2` that is below `2^(64 - t)`, the +//! shift discards it, and the two land on the same slot for every multiplier +//! the search can try. Multiplying the already-mixed `h` costs nothing, since +//! `h` is ready long before `m2` arrives from the table. +//! +//! Like @ref minimal_perfect_hash this needs no instruction-set extension and +//! makes no assumption about the layout of the type ids. +//! +//! @tparam Lambda Average bucket size. +//! @tparam MaxDoublings How far the table may grow past the smallest power of +//! two that could hold the type ids, before @ref search_error is reported. +//! The cap is relative to the class count deliberately: an absolute one lets +//! a pathological input ask for a table orders of magnitude larger than the +//! program needs. +//! +//! @par Example +//! include:policies.cpp#two_level_hash +//! +//! @see [Registries and Policies](xref:ROOT:registries_and_policies.adoc) +template +struct two_level_hash : type_hash { + + //! No table within `MaxDoublings` doublings admitted a complete + //! assignment. + struct search_error : openmethod_error { + //! Number of registered type ids. + std::size_t classes; + //! Widest table tried. + std::size_t table_bits; + + template + auto write(Stream& os) const -> void; + }; + + using errors = std::variant; + + static_assert(Lambda > 0); + + //! `state` layout when runtime checks are disabled. + struct no_checks { + //! First-level multiplier. + std::uint64_t m1; + //! `64 - b`. + std::size_t s1; + //! `64 - t`. + std::size_t s2; + //! Second-level multiplier, one per bucket. + std::vector m2; + //! `2^t`. + std::size_t slots; + }; + + //! `state` layout when runtime checks are enabled: adds the table of + //! registered type ids used to validate hashed types. + struct with_checks : no_checks { + std::vector control; + }; + + //! A TypeHashFn metafunction. + //! + //! @tparam Registry The registry containing this policy + template + class fn { + public: + using state = std::conditional_t< + Registry::has_runtime_checks, with_checks, no_checks>; + + private: + static auto& st() { + return Registry::template state< + two_level_hash>(); + } + + static void check(std::size_t index, type_id type); + + // The key a type id hashes as. The `+ 1` keeps zero out of the + // domain: zero is a fixed point of both multiplies, so a type id of 0 + // would land in slot 0 whatever `m1` and `m2` are, and the search would + // fail whenever another bucket had taken that slot first. + static auto key(type_id type) -> std::uint64_t { + return std::uint64_t(reinterpret_cast(type)) + 1; + } + + // The second-level multiplier tried at step `k`. Odd, because an even + // multiplier throws away the key's top bits. + static auto m2_at(std::uint32_t k) -> std::uint32_t { + auto z = k * 0x9e3779b9u; + z ^= z >> 15; + z *= 0x85ebca6bu; + z ^= z >> 13; + + return z | 1u; + } + + static auto m1_at(std::uint64_t z) -> std::uint64_t { + z += 0x9e3779b97f4a7c15ull; + z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9ull; + z = (z ^ (z >> 27)) * 0x94d049bb133111ebull; + + return (z ^ (z >> 31)) | 1; + } + + static auto build( + const std::vector& ids, std::uint64_t m1, + std::size_t s1, std::size_t s2, std::size_t slots, + std::vector& m2) -> bool; + + public: + //! Finds `m1` and the per-bucket multipliers. + //! + //! @tparam Context An @ref InitializeContext. + //! @param ctx A Context object. + //! @param options A tuple of option objects. + template + static auto initialize( + const Context& ctx, const std::tuple& options) -> void; + + //! Returns the hash range: `[0, slots - 1]`. + static auto hash_range() -> std::pair { + return std::pair{std::size_t(0), st().slots ? st().slots - 1 : 0}; + } + + //! Map a type id to an index + //! + //! @param type The type_id to map + //! @return The index + BOOST_FORCEINLINE + static auto hash(type_id type) -> std::size_t { + auto h = key(type) * st().m1; + auto bucket = std::size_t(h >> st().s1); + auto index = + std::size_t((std::uint64_t(st().m2[bucket]) * h) >> st().s2); + + if constexpr (Registry::has_runtime_checks) { + check(index, type); + } + + return index; + } + + //! Releases the multiplier table, and the control table if there is + //! one. + template + static auto finalize(const std::tuple& options) -> void { + (void)options; + + st().m2.clear(); + st().m2.shrink_to_fit(); + st().slots = 0; + + if constexpr (Registry::has_runtime_checks) { + st().control.clear(); + st().control.shrink_to_fit(); + } + } + }; +}; + +template +template +auto two_level_hash::fn::build( + const std::vector& ids, std::uint64_t m1, std::size_t s1, + std::size_t s2, std::size_t slots, std::vector& m2) -> bool { + auto n = ids.size(); + auto buckets = m2.size(); + + // Group by bucket: counting sort into a CSR-style pair of arrays. + std::vector keys(n); + std::vector bucket_of(n); + + for (std::size_t i = 0; i != n; ++i) { + keys[i] = m1 * ids[i]; + bucket_of[i] = std::uint32_t(keys[i] >> s1); + } + + std::vector start(buckets + 1, 0); + + for (auto b : bucket_of) { + ++start[b + 1]; + } + + std::partial_sum(start.begin(), start.end(), start.begin()); + std::vector members(n); + auto fill = start; + + for (std::size_t i = 0; i != n; ++i) { + members[fill[bucket_of[i]]++] = std::uint32_t(i); + } + + // Largest buckets first: a big bucket is placeable only while the table + // is still mostly empty. + std::vector order(buckets); + std::iota(order.begin(), order.end(), std::uint32_t(0)); + std::stable_sort( + order.begin(), order.end(), [&](std::uint32_t a, std::uint32_t b) { + return (start[a + 1] - start[a]) > (start[b + 1] - start[b]); + }); + + const std::uint32_t max_tries = 1u << 20; + std::vector occupied(slots, 0); + std::vector placed; + placed.reserve(64); + + for (auto b : order) { + auto first = start[b], last = start[b + 1]; + + if (first == last) { + m2[b] = 1; + + continue; + } + + bool done = false; + + for (std::uint32_t k = 0; k != max_tries; ++k) { + auto candidate = m2_at(k); + placed.clear(); + bool ok = true; + + for (auto m = first; m != last; ++m) { + auto index = std::size_t( + (std::uint64_t(candidate) * keys[members[m]]) >> s2); + + if (occupied[index] || + std::find(placed.begin(), placed.end(), index) != + placed.end()) { + ok = false; + + break; + } + + placed.push_back(index); + } + + if (ok) { + for (auto index : placed) { + occupied[index] = 1; + } + + m2[b] = candidate; + done = true; + + break; + } + } + + if (!done) { + return false; + } + } + + return true; +} + +template +template +template +auto two_level_hash::fn::initialize( + const Context& ctx, const std::tuple& options) -> void { + (void)options; + + std::vector ids; + + for (auto iter = ctx.classes_begin(); iter != ctx.classes_end(); ++iter) { + for (auto type_iter = iter->type_id_begin(); + type_iter != iter->type_id_end(); ++type_iter) { + ids.push_back(key(*type_iter)); + } + } + + // One class may be registered under the same type id by several modules; + // the table is over *distinct* ids. + std::sort(ids.begin(), ids.end()); + ids.erase(std::unique(ids.begin(), ids.end()), ids.end()); + + auto n = ids.size(); + + if (n == 0) { + st().m1 = 1; + st().s1 = 63; + st().s2 = 63; + st().m2.assign(2, 1); + st().slots = 0; + + return; + } + + // Never fewer than two buckets, so the shift stays below 64. + std::size_t buckets = 2, b = 1; + + while (buckets * Lambda < n) { + buckets <<= 1; + ++b; + } + + // The first-level multiplier is taken as it comes. Scoring several and + // keeping the one that spreads the buckets most evenly was measured, and + // does not pay: the placement cost is set by the tail, where every + // remaining bucket faces an almost full table, and evening the bucket + // sizes does not change how full that table is. + auto m1 = m1_at(0); + + // Smallest power-of-two table that can hold them, grown on failure. + std::size_t t = 1; + + while ((std::size_t(1) << t) < n) { + ++t; + } + + std::vector m2(buckets); + bool found = false; + + const std::size_t max_t = t + MaxDoublings; + + for (; t <= max_t; ++t) { + if (build(ids, m1, 64 - b, 64 - t, std::size_t(1) << t, m2)) { + found = true; + + break; + } + } + + if (!found) { + search_error error; + error.classes = n; + error.table_bits = max_t; + + if constexpr (Registry::has_error_handler) { + Registry::error_handler::error(error); + } + + abort(); + } + + st().m1 = m1; + st().s1 = 64 - b; + st().s2 = 64 - t; + st().slots = std::size_t(1) << t; + st().m2 = std::move(m2); + + if constexpr (Context::template has_option) { + ctx.tr << " type ids: " << n << ", buckets: " << buckets + << ", table: " << st().slots << " slots\n"; + } + + if constexpr (Registry::has_runtime_checks) { + st().control.assign(st().slots, type_id(detail::uintptr_max)); + + for (auto id : ids) { + auto h = st().m1 * id; + auto bucket = std::size_t(h >> st().s1); + auto index = + std::size_t((std::uint64_t(st().m2[bucket]) * h) >> st().s2); + // `ids` holds keys, so undo the offset to recover the type id + // `check` will compare against. + st().control[index] = reinterpret_cast(id - 1); + } + } +} + +template +template +void two_level_hash::fn::check( + std::size_t index, type_id type) { + if (index >= st().slots || st().control[index] != type) { + if constexpr (Registry::has_error_handler) { + missing_class error; + error.type = type; + Registry::error_handler::error(error); + } + + abort(); + } +} + +template +template +auto two_level_hash::search_error::write(Stream& os) const + -> void { + os << "could not place " << classes << " type ids in a table of up to 2^" + << table_bits << " slots\n"; +} + +} // namespace boost::openmethod::policies + +#endif diff --git a/include/boost/openmethod/preamble.hpp b/include/boost/openmethod/preamble.hpp index 17943524..ed4138b7 100644 --- a/include/boost/openmethod/preamble.hpp +++ b/include/boost/openmethod/preamble.hpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -26,6 +27,26 @@ namespace boost::openmethod { +// ----------------------------------------------------------------------------- +// uintptr + +namespace detail { + +// The unsigned integer a type_id can be reinterpreted as. Every `type_hash` +// policy needs it, so it lives here rather than in any one of them. +#if defined(UINTPTR_MAX) +using uintptr = std::uintptr_t; +constexpr uintptr uintptr_max = UINTPTR_MAX; +#else +static_assert( + sizeof(std::size_t) == sizeof(void*), + "This implementation requires that size_t and void* have the same size."); +using uintptr = std::size_t; +constexpr uintptr uintptr_max = (std::numeric_limits::max)(); +#endif + +} // namespace detail + // ----------------------------------------------------------------------------- // word From 9feb7bfd7d124637f57385cbd89337bd1bcbc0d8 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leroy Date: Thu, 10 Sep 2026 20:47:23 -0400 Subject: [PATCH 2/5] policies: rule a zero type id out rather than hashing around it minimal_perfect_hash and two_level_hash hashed `x + 1` so that a type id of zero could not be pinned to slot 0. That put an increment on every dispatch to buy something no caller needs: addresses are never zero, so std_rtti and static_rtti could never hit it, and a custom rtti policy handing out small integers can simply not start at zero. So make it a precondition instead. Both policies hash the type id directly again, both document zero as outside their domain, and `initialize` asserts that none of the registered ids is zero when the registry has runtime_checks - one comparison, since the ids are sorted by then, and compiled out entirely otherwise. The dispatch path is back to imul/shr/load/imul/mul/load, with nothing in front of the first multiply. --- .../policies/minimal_perfect_hash.hpp | 40 ++++++++++++------- .../openmethod/policies/two_level_hash.hpp | 37 +++++++++++------ 2 files changed, 49 insertions(+), 28 deletions(-) diff --git a/include/boost/openmethod/policies/minimal_perfect_hash.hpp b/include/boost/openmethod/policies/minimal_perfect_hash.hpp index 8e3ce29d..e4eec94d 100644 --- a/include/boost/openmethod/policies/minimal_perfect_hash.hpp +++ b/include/boost/openmethod/policies/minimal_perfect_hash.hpp @@ -8,6 +8,8 @@ #include +#include + #include #include #include @@ -62,6 +64,15 @@ namespace boost::openmethod::policies { //! a scheme keyed on address arithmetic it does not depend on //! @ref std_rtti. //! +//! @note **A type id of zero is outside this policy's domain.** Zero is a fixed +//! point of a multiply, so it lands in slot 0 for every seed and every pilot; +//! it cannot be displaced, and the search fails whenever another bucket has +//! taken that slot. Addresses are never zero, so @ref std_rtti and +//! @ref static_rtti are unaffected; a custom @ref rtti policy that hands out +//! small integers must not use zero as one of them. When the registry has +//! @ref runtime_checks, @ref initialize asserts that none of the registered +//! type ids is zero. +//! //! `LoadPercent = 100` asks for an exactly minimal table. It is reachable, but //! not in bounded time at a large `Lambda`: the last buckets have to hit the //! last few free slots, and the expected number of trials for a bucket of size @@ -139,16 +150,6 @@ struct minimal_perfect_hash : type_hash { static void check(std::size_t index, type_id type); - // The key a type id hashes as. The `+ 1` keeps zero out of the - // domain: zero is a fixed point of the multiply, so a type id of 0 - // would land in slot 0 for every seed and every pilot, and the search - // would fail whenever another bucket had taken that slot first. One - // increment on the dispatch path buys a policy that works for any type - // id, including the small integers a custom `rtti` policy may hand out. - static auto key(type_id type) -> std::uint64_t { - return std::uint64_t(reinterpret_cast(type)) + 1; - } - // The pilot tried at step `k`. Multiplying by an odd constant is a // bijection on 32 bits, so the sequence walks the whole range; the // low bit is set because an even pilot loses the key's high bits. @@ -217,7 +218,8 @@ struct minimal_perfect_hash : type_hash { //! @return The index BOOST_FORCEINLINE static auto hash(type_id type) -> std::size_t { - auto h = key(type) * st().seed; + auto h = std::uint64_t(reinterpret_cast(type)) * + st().seed; auto pilot = st().pilots[std::size_t(h >> st().bucket_shift)]; auto index = place(h, pilot, st().size); @@ -360,7 +362,8 @@ auto minimal_perfect_hash::fn:: for (auto iter = ctx.classes_begin(); iter != ctx.classes_end(); ++iter) { for (auto type_iter = iter->type_id_begin(); type_iter != iter->type_id_end(); ++type_iter) { - ids.push_back(key(*type_iter)); + ids.push_back( + std::uint64_t(reinterpret_cast(*type_iter))); } } @@ -369,6 +372,15 @@ auto minimal_perfect_hash::fn:: std::sort(ids.begin(), ids.end()); ids.erase(std::unique(ids.begin(), ids.end()), ids.end()); + // Zero is a fixed point of the multiply: it lands in slot 0 for every seed and every pilot, + // so it cannot be displaced and the search fails spuriously whenever + // another bucket has taken that slot. A type id of zero is therefore + // outside this policy's domain - see the class documentation. `ids` is + // sorted, so one comparison settles it. + if constexpr (Registry::has_runtime_checks) { + BOOST_ASSERT(ids.empty() || ids.front() != 0); + } + auto n = ids.size(); if (n == 0) { @@ -438,10 +450,8 @@ auto minimal_perfect_hash::fn:: for (auto id : ids) { auto h = id * st().seed; auto pilot = st().pilots[std::size_t(h >> bucket_shift)]; - // `ids` holds keys, so undo the offset to recover the type id - // `check` will compare against. st().control[place(h, pilot, slots)] = - reinterpret_cast(id - 1); + reinterpret_cast(id); } } } diff --git a/include/boost/openmethod/policies/two_level_hash.hpp b/include/boost/openmethod/policies/two_level_hash.hpp index eabe8c4c..8fc01fc1 100644 --- a/include/boost/openmethod/policies/two_level_hash.hpp +++ b/include/boost/openmethod/policies/two_level_hash.hpp @@ -8,6 +8,8 @@ #include +#include + #include #include #include @@ -57,6 +59,14 @@ namespace boost::openmethod::policies { //! Like @ref minimal_perfect_hash this needs no instruction-set extension and //! makes no assumption about the layout of the type ids. //! +//! @note **A type id of zero is outside this policy's domain**, for the same +//! reason as in @ref minimal_perfect_hash: zero is a fixed point of both +//! multiplies, so it lands in slot 0 whatever `m1` and the per-bucket +//! multiplier are, and the search fails whenever another bucket has taken that +//! slot. Addresses are never zero; a custom @ref rtti policy handing out small +//! integers must not use zero. When the registry has @ref runtime_checks, +//! @ref initialize asserts that none of the registered type ids is zero. +//! //! @tparam Lambda Average bucket size. //! @tparam MaxDoublings How far the table may grow past the smallest power of //! two that could hold the type ids, before @ref search_error is reported. @@ -124,14 +134,6 @@ struct two_level_hash : type_hash { static void check(std::size_t index, type_id type); - // The key a type id hashes as. The `+ 1` keeps zero out of the - // domain: zero is a fixed point of both multiplies, so a type id of 0 - // would land in slot 0 whatever `m1` and `m2` are, and the search would - // fail whenever another bucket had taken that slot first. - static auto key(type_id type) -> std::uint64_t { - return std::uint64_t(reinterpret_cast(type)) + 1; - } - // The second-level multiplier tried at step `k`. Odd, because an even // multiplier throws away the key's top bits. static auto m2_at(std::uint32_t k) -> std::uint32_t { @@ -177,7 +179,8 @@ struct two_level_hash : type_hash { //! @return The index BOOST_FORCEINLINE static auto hash(type_id type) -> std::size_t { - auto h = key(type) * st().m1; + auto h = std::uint64_t(reinterpret_cast(type)) * + st().m1; auto bucket = std::size_t(h >> st().s1); auto index = std::size_t((std::uint64_t(st().m2[bucket]) * h) >> st().s2); @@ -315,7 +318,8 @@ auto two_level_hash::fn::initialize( for (auto iter = ctx.classes_begin(); iter != ctx.classes_end(); ++iter) { for (auto type_iter = iter->type_id_begin(); type_iter != iter->type_id_end(); ++type_iter) { - ids.push_back(key(*type_iter)); + ids.push_back( + std::uint64_t(reinterpret_cast(*type_iter))); } } @@ -324,6 +328,15 @@ auto two_level_hash::fn::initialize( std::sort(ids.begin(), ids.end()); ids.erase(std::unique(ids.begin(), ids.end()), ids.end()); + // Zero is a fixed point of the multiply: it lands in slot 0 whatever `m1` and the per-bucket multiplier are, + // so it cannot be displaced and the search fails spuriously whenever + // another bucket has taken that slot. A type id of zero is therefore + // outside this policy's domain - see the class documentation. `ids` is + // sorted, so one comparison settles it. + if constexpr (Registry::has_runtime_checks) { + BOOST_ASSERT(ids.empty() || ids.front() != 0); + } + auto n = ids.size(); if (n == 0) { @@ -402,9 +415,7 @@ auto two_level_hash::fn::initialize( auto bucket = std::size_t(h >> st().s1); auto index = std::size_t((std::uint64_t(st().m2[bucket]) * h) >> st().s2); - // `ids` holds keys, so undo the offset to recover the type id - // `check` will compare against. - st().control[index] = reinterpret_cast(id - 1); + st().control[index] = reinterpret_cast(id); } } } From e715a670d75a7ff0d720bbc2fba8908550e6ad79 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leroy Date: Thu, 10 Sep 2026 21:51:31 -0400 Subject: [PATCH 3/5] test: cover the three new type_hash policies Four new files, and the first tests in the suite to drive a type_hash policy directly rather than through dispatch. test_hash_policies.cpp feeds each policy a fabricated InitializeContext over chosen type ids, which is the only way to present a distribution deliberately instead of taking whatever this program's own classes happen to get. Four distributions: one packed module, one diluted module (v-tables emitted between the records, which is what a real one looks like), a program with implicitly linked libraries, and a program plus dlopened modules tens of terabytes apart. It asserts injectivity and that hash_range brackets every value on all of them, that the table size is identical for the packed and the dlopened sets - the property this family of policies exists for - that minimal_perfect_hash<2, 100> is exactly minimal and the default is ceil(n / 0.95), that two_level_hash's table is a power of two between n and 2n, that a type id registered by several modules is not a collision, and that initialize works again after finalize. The generators keep a per-module cursor so the ids are distinct by construction, and a test case asserts that much: a repeated id would exercise the policies' deduplication rather than their hashing, and would make every injectivity count come out short for no fault of the policy. Getting that wrong the first time is what caught it. test_dispatch_{minimal_perfect,two_level,minimal_cover}_hash.cpp run each policy end to end - single and multiple dispatch over a five-class hierarchy - with runtime_checks on unconditionally rather than only in a Debug build, so the control table that `hash` consults is exercised in both configurations, and with throw_error_handler so that a call passing an unregistered class is observable as missing_class instead of aborting. The minimal_cover_hash test needs BMI2 for the whole translation unit, which the CMake build adds for that one target on x86, and which b2 gets from a new config//has_bmi2 probe - the same shape as the existing has_reflection one. Probing beats naming an architecture: an x86 conditional does not match every toolset spelling. Where the instruction is absent the test still builds, as one case that records why it did nothing. test_policies.cpp gains static_asserts that each new policy satisfies the TypeHashFn blueprint, and that `with` replaces a type_hash policy in place rather than appending a second - which would leave vptr_vector reading the wrong state. --- config/Jamfile | 7 + config/has_bmi2.cpp | 23 ++ test/CMakeLists.txt | 15 + test/Jamfile | 14 +- test/test_dispatch_minimal_cover_hash.cpp | 155 +++++++++ test/test_dispatch_minimal_perfect_hash.cpp | 129 ++++++++ test/test_dispatch_two_level_hash.cpp | 129 ++++++++ test/test_hash_policies.cpp | 345 ++++++++++++++++++++ test/test_policies.cpp | 34 ++ 9 files changed, 850 insertions(+), 1 deletion(-) create mode 100644 config/has_bmi2.cpp create mode 100644 test/test_dispatch_minimal_cover_hash.cpp create mode 100644 test/test_dispatch_minimal_perfect_hash.cpp create mode 100644 test/test_dispatch_two_level_hash.cpp create mode 100644 test/test_hash_policies.cpp diff --git a/config/Jamfile b/config/Jamfile index a90f641f..d4f562fc 100644 --- a/config/Jamfile +++ b/config/Jamfile @@ -17,3 +17,10 @@ project /boost/openmethod/config ; obj has_reflection : has_reflection.cpp : -freflection ; explicit has_reflection ; + +# The other probe: BMI2's pext, which only policies/minimal_cover_hash.hpp +# needs. Probing beats naming an architecture - a x86 conditional +# does not match every toolset spelling, and a compiler that rejects -mbmi2 +# outright would take the directory down with it. +obj has_bmi2 : has_bmi2.cpp : -mbmi2 ../include ; +explicit has_bmi2 ; diff --git a/config/has_bmi2.cpp b/config/has_bmi2.cpp new file mode 100644 index 00000000..65c0f87a --- /dev/null +++ b/config/has_bmi2.cpp @@ -0,0 +1,23 @@ +// Copyright (c) 2017-2026 Jean-Louis Leroy +// Distributed under the Boost Software License, Version 1.0. +// See accompanying file LICENSE_1_0.txt +// or copy at http://www.boost.org/LICENSE_1_0.txt) + +// Probe for BMI2's parallel bit extract, compiled with -mbmi2. See ../Jamfile, +// and boost/openmethod/policies/minimal_cover_hash.hpp, which is the only part +// of the library that needs the instruction. +// +// It tests the header's own feature macro rather than the intrinsic directly: +// what the test suite needs to know is whether that header will let the policy +// be used, which is a slightly narrower question than whether some spelling of +// pext compiles. + +#include + +#include + +static_assert(BOOST_OPENMETHOD_HAS_PEXT); + +auto probe(std::uint64_t value, std::uint64_t mask) -> std::uint64_t { + return boost::openmethod::detail::pext64(value, mask); +} diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 85954411..6a7e1e30 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -105,6 +105,21 @@ foreach(test_cpp ${test_cpp_files}) target_compile_options(${test_target} PRIVATE -Wa,-mbig-obj) endif() + # minimal_cover_hash dispatches with BMI2's pext, and because `hash` is + # inlined into every call the instruction has to be enabled for the whole + # translation unit. Only this test needs it, and only on x86: elsewhere + # BOOST_OPENMETHOD_HAS_PEXT is 0 and the test compiles to a stub that says + # so. + if (test MATCHES "minimal_cover_hash" AND + CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64|amd64|i[3-6]86)$") + if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC" OR + CMAKE_CXX_COMPILER_FRONTEND_VARIANT MATCHES "MSVC") + target_compile_options(${test_target} PRIVATE /arch:AVX2) + else() + target_compile_options(${test_target} PRIVATE -mbmi2) + endif() + endif() + file(READ ${test_cpp} test_cpp_contents) set(test_cpp_overrides_registry -1) foreach(marker "BOOST_OPENMETHOD_DEFAULT_REGISTRY" "test_capture_errors.hpp" diff --git a/test/Jamfile b/test/Jamfile index f55d73d1..468244c8 100644 --- a/test/Jamfile +++ b/test/Jamfile @@ -78,11 +78,23 @@ alias unit_test_framework /boost/test//boost_unit_test_framework/off ; -for local src in [ glob test_*.cpp ] +for local src in [ glob test_*.cpp : test_dispatch_minimal_cover_hash.cpp ] { run $(src) unit_test_framework ; } +# minimal_cover_hash dispatches with BMI2's pext, and because `hash` is inlined +# into every call the instruction has to be enabled for the whole translation +# unit - so this one source is declared on its own, rather than through the glob +# above. Where the probe fails - ARM, or a compiler that will not take the flag - +# BOOST_OPENMETHOD_HAS_PEXT is 0 and the test body compiles to a stub that says +# so, so nothing here is conditional on the outcome except the flag itself. +run test_dispatch_minimal_cover_hash.cpp unit_test_framework + : : : + [ check-target-builds /boost/openmethod/config//has_bmi2 + "BMI2 pext" : -mbmi2 ] + ; + run mix_release_debug/main.cpp mix_release_debug/lib.cpp unit_test_framework ; diff --git a/test/test_dispatch_minimal_cover_hash.cpp b/test/test_dispatch_minimal_cover_hash.cpp new file mode 100644 index 00000000..5fd79dd4 --- /dev/null +++ b/test/test_dispatch_minimal_cover_hash.cpp @@ -0,0 +1,155 @@ +// Copyright (c) 2017-2026 Jean-Louis Leroy +// Distributed under the Boost Software License, Version 1.0. +// See accompanying file LICENSE_1_0.txt +// or copy at http://www.boost.org/LICENSE_1_0.txt) + +// First, for BOOST_OPENMETHOD_HAS_PEXT. This header pulls in preamble.hpp but +// not core.hpp, so the default-registry override below is still in time. +#include + +// `minimal_cover_hash` dispatches with BMI2's `pext`, which not every target +// has; naming the policy in a registry where it is absent is a compile error, +// by design. So the recipe and the cases are conditional, and on a target +// without the instruction this file still builds - as a single case that +// records why it did nothing. The build files add `-mbmi2` (or `/arch:AVX2`) to +// this translation unit alone, on x86 only. +#if BOOST_OPENMETHOD_HAS_PEXT + +struct test_registry; +#define BOOST_OPENMETHOD_DEFAULT_REGISTRY test_registry + +#include +#include +#include + +// `runtime_checks` unconditionally, rather than only in a Debug build, so that +// the control table `hash` consults is exercised whatever the build type; and +// `throw_error_handler` so that a lookup of an unregistered class is observable +// from a test case instead of aborting. +struct test_registry : + boost::openmethod::default_registry::with< + boost::openmethod::policies::minimal_cover_hash<>, + boost::openmethod::policies::runtime_checks, + boost::openmethod::policies::throw_error_handler> {}; + +#endif + +#define BOOST_TEST_MODULE dispatch_minimal_cover_hash +#include + +#if BOOST_OPENMETHOD_HAS_PEXT + +#include +#include + +using namespace boost::openmethod; + +namespace { + +struct Animal { + virtual ~Animal() = default; +}; + +struct Dog : Animal {}; +struct Cat : Animal {}; +struct Bulldog : Dog {}; +struct Tiger : Cat {}; + +// Registered nowhere below: calling with one of these must be diagnosed. +struct Ghost : Animal {}; + +} // namespace + +// Withholding `Ghost` is the point of one of the cases, so register explicitly +// and do not call BOOST_OPENMETHOD_REGISTER_CLASSES - see test_classes.hpp. +BOOST_OPENMETHOD_CLASSES(Animal, Dog, Cat, Bulldog, Tiger); + +BOOST_OPENMETHOD(name, (virtual_), std::string); +BOOST_OPENMETHOD_OVERRIDE(name, (const Animal&), std::string) { + return "animal"; +} +BOOST_OPENMETHOD_OVERRIDE(name, (const Dog&), std::string) { + return "dog"; +} +BOOST_OPENMETHOD_OVERRIDE(name, (const Cat&), std::string) { + return "cat"; +} +BOOST_OPENMETHOD_OVERRIDE(name, (const Bulldog&), std::string) { + return "bulldog"; +} + +BOOST_OPENMETHOD( + meet, (virtual_, virtual_), std::string); +BOOST_OPENMETHOD_OVERRIDE(meet, (const Animal&, const Animal&), std::string) { + return "ignore"; +} +BOOST_OPENMETHOD_OVERRIDE(meet, (const Dog&, const Cat&), std::string) { + return "chase"; +} +BOOST_OPENMETHOD_OVERRIDE(meet, (const Cat&, const Dog&), std::string) { + return "hiss"; +} + +using type_hash = test_registry::policy; + +BOOST_AUTO_TEST_CASE(single_dispatch) { + initialize(); + + BOOST_TEST(name(Animal()) == "animal"); + BOOST_TEST(name(Dog()) == "dog"); + BOOST_TEST(name(Cat()) == "cat"); + BOOST_TEST(name(Bulldog()) == "bulldog"); + BOOST_TEST(name(Tiger()) == "cat"); +} + +BOOST_AUTO_TEST_CASE(multiple_dispatch) { + initialize(); + + BOOST_TEST(meet(Dog(), Cat()) == "chase"); + BOOST_TEST(meet(Cat(), Dog()) == "hiss"); + BOOST_TEST(meet(Bulldog(), Tiger()) == "chase"); + BOOST_TEST(meet(Dog(), Dog()) == "ignore"); +} + +// The property the policy exists for: a cover of bit positions that still +// separates the registered type ids. +BOOST_AUTO_TEST_CASE(hash_is_injective) { + initialize(); + + auto [low, high] = type_hash::hash_range(); + BOOST_TEST(low == 0u); + + std::set seen; + + for (auto type : + {&typeid(Animal), &typeid(Dog), &typeid(Cat), &typeid(Bulldog), + &typeid(Tiger)}) { + auto index = type_hash::hash(type); + BOOST_TEST(index >= low); + BOOST_TEST(index <= high); + BOOST_TEST(seen.insert(index).second); + } + + // No minimality bound here: the table is `2^popcount(mask)` and the cover + // search minimizes the number of *bits*, not the number of slots, so the + // table can be much larger than the number of type ids. That is the + // trade this policy makes - see its documentation. + BOOST_TEST(high + 1 >= seen.size()); +} + +BOOST_AUTO_TEST_CASE(unregistered_class_is_diagnosed) { + initialize(); + + BOOST_CHECK_THROW(name(Ghost()), missing_class); +} + +#else + +BOOST_AUTO_TEST_CASE(pext_unavailable) { + BOOST_TEST_MESSAGE( + "minimal_cover_hash needs BMI2; BOOST_OPENMETHOD_HAS_PEXT is 0 on this " + "target, so there is nothing to test here"); + BOOST_TEST(BOOST_OPENMETHOD_HAS_PEXT == 0); +} + +#endif diff --git a/test/test_dispatch_minimal_perfect_hash.cpp b/test/test_dispatch_minimal_perfect_hash.cpp new file mode 100644 index 00000000..25cdb92c --- /dev/null +++ b/test/test_dispatch_minimal_perfect_hash.cpp @@ -0,0 +1,129 @@ +// Copyright (c) 2017-2026 Jean-Louis Leroy +// Distributed under the Boost Software License, Version 1.0. +// See accompanying file LICENSE_1_0.txt +// or copy at http://www.boost.org/LICENSE_1_0.txt) + +struct test_registry; +#define BOOST_OPENMETHOD_DEFAULT_REGISTRY test_registry + +#include +#include +#include +#include + +// `runtime_checks` unconditionally, rather than only in a Debug build, so that +// the control table `hash` consults is exercised whatever the build type; and +// `throw_error_handler` so that a lookup of an unregistered class is observable +// from a test case instead of aborting. +struct test_registry : + boost::openmethod::default_registry::with< + boost::openmethod::policies::minimal_perfect_hash<>, + boost::openmethod::policies::runtime_checks, + boost::openmethod::policies::throw_error_handler> {}; + +#define BOOST_TEST_MODULE dispatch_minimal_perfect_hash +#include + +#include +#include + +using namespace boost::openmethod; + +namespace { + +struct Animal { + virtual ~Animal() = default; +}; + +struct Dog : Animal {}; +struct Cat : Animal {}; +struct Bulldog : Dog {}; +struct Tiger : Cat {}; + +// Registered nowhere below: calling with one of these must be diagnosed. +struct Ghost : Animal {}; + +} // namespace + +// Withholding `Ghost` is the point of one of the cases, so register explicitly +// and do not call BOOST_OPENMETHOD_REGISTER_CLASSES - see test_classes.hpp. +BOOST_OPENMETHOD_CLASSES(Animal, Dog, Cat, Bulldog, Tiger); + +BOOST_OPENMETHOD(name, (virtual_), std::string); +BOOST_OPENMETHOD_OVERRIDE(name, (const Animal&), std::string) { + return "animal"; +} +BOOST_OPENMETHOD_OVERRIDE(name, (const Dog&), std::string) { + return "dog"; +} +BOOST_OPENMETHOD_OVERRIDE(name, (const Cat&), std::string) { + return "cat"; +} +BOOST_OPENMETHOD_OVERRIDE(name, (const Bulldog&), std::string) { + return "bulldog"; +} + +BOOST_OPENMETHOD( + meet, (virtual_, virtual_), std::string); +BOOST_OPENMETHOD_OVERRIDE(meet, (const Animal&, const Animal&), std::string) { + return "ignore"; +} +BOOST_OPENMETHOD_OVERRIDE(meet, (const Dog&, const Cat&), std::string) { + return "chase"; +} +BOOST_OPENMETHOD_OVERRIDE(meet, (const Cat&, const Dog&), std::string) { + return "hiss"; +} + +using type_hash = test_registry::policy; + +BOOST_AUTO_TEST_CASE(single_dispatch) { + initialize(); + + BOOST_TEST(name(Animal()) == "animal"); + BOOST_TEST(name(Dog()) == "dog"); + BOOST_TEST(name(Cat()) == "cat"); + BOOST_TEST(name(Bulldog()) == "bulldog"); + BOOST_TEST(name(Tiger()) == "cat"); +} + +BOOST_AUTO_TEST_CASE(multiple_dispatch) { + initialize(); + + BOOST_TEST(meet(Dog(), Cat()) == "chase"); + BOOST_TEST(meet(Cat(), Dog()) == "hiss"); + BOOST_TEST(meet(Bulldog(), Tiger()) == "chase"); + BOOST_TEST(meet(Dog(), Dog()) == "ignore"); +} + +// The property the policy exists for: one slot per type id, and every +// registered id inside the advertised range, distinct from the others. +BOOST_AUTO_TEST_CASE(hash_is_injective_and_minimal) { + initialize(); + + auto [low, high] = type_hash::hash_range(); + BOOST_TEST(low == 0u); + + std::set seen; + + for (auto type : + {&typeid(Animal), &typeid(Dog), &typeid(Cat), &typeid(Bulldog), + &typeid(Tiger)}) { + auto index = type_hash::hash(type); + BOOST_TEST(index >= low); + BOOST_TEST(index <= high); + BOOST_TEST(seen.insert(index).second); + } + + // `LoadPercent` defaults to 95, so the table holds at most one slot in + // twenty more than there are type ids. `void` is registered too, hence the + // floor rather than an exact figure. + BOOST_TEST(high + 1 >= seen.size()); + BOOST_TEST(high + 1 <= seen.size() * 2); +} + +BOOST_AUTO_TEST_CASE(unregistered_class_is_diagnosed) { + initialize(); + + BOOST_CHECK_THROW(name(Ghost()), missing_class); +} diff --git a/test/test_dispatch_two_level_hash.cpp b/test/test_dispatch_two_level_hash.cpp new file mode 100644 index 00000000..5a3343e5 --- /dev/null +++ b/test/test_dispatch_two_level_hash.cpp @@ -0,0 +1,129 @@ +// Copyright (c) 2017-2026 Jean-Louis Leroy +// Distributed under the Boost Software License, Version 1.0. +// See accompanying file LICENSE_1_0.txt +// or copy at http://www.boost.org/LICENSE_1_0.txt) + +struct test_registry; +#define BOOST_OPENMETHOD_DEFAULT_REGISTRY test_registry + +#include +#include +#include +#include + +// `runtime_checks` unconditionally, rather than only in a Debug build, so that +// the control table `hash` consults is exercised whatever the build type; and +// `throw_error_handler` so that a lookup of an unregistered class is observable +// from a test case instead of aborting. +struct test_registry : + boost::openmethod::default_registry::with< + boost::openmethod::policies::two_level_hash<>, + boost::openmethod::policies::runtime_checks, + boost::openmethod::policies::throw_error_handler> {}; + +#define BOOST_TEST_MODULE dispatch_two_level_hash +#include + +#include +#include + +using namespace boost::openmethod; + +namespace { + +struct Animal { + virtual ~Animal() = default; +}; + +struct Dog : Animal {}; +struct Cat : Animal {}; +struct Bulldog : Dog {}; +struct Tiger : Cat {}; + +// Registered nowhere below: calling with one of these must be diagnosed. +struct Ghost : Animal {}; + +} // namespace + +// Withholding `Ghost` is the point of one of the cases, so register explicitly +// and do not call BOOST_OPENMETHOD_REGISTER_CLASSES - see test_classes.hpp. +BOOST_OPENMETHOD_CLASSES(Animal, Dog, Cat, Bulldog, Tiger); + +BOOST_OPENMETHOD(name, (virtual_), std::string); +BOOST_OPENMETHOD_OVERRIDE(name, (const Animal&), std::string) { + return "animal"; +} +BOOST_OPENMETHOD_OVERRIDE(name, (const Dog&), std::string) { + return "dog"; +} +BOOST_OPENMETHOD_OVERRIDE(name, (const Cat&), std::string) { + return "cat"; +} +BOOST_OPENMETHOD_OVERRIDE(name, (const Bulldog&), std::string) { + return "bulldog"; +} + +BOOST_OPENMETHOD( + meet, (virtual_, virtual_), std::string); +BOOST_OPENMETHOD_OVERRIDE(meet, (const Animal&, const Animal&), std::string) { + return "ignore"; +} +BOOST_OPENMETHOD_OVERRIDE(meet, (const Dog&, const Cat&), std::string) { + return "chase"; +} +BOOST_OPENMETHOD_OVERRIDE(meet, (const Cat&, const Dog&), std::string) { + return "hiss"; +} + +using type_hash = test_registry::policy; + +BOOST_AUTO_TEST_CASE(single_dispatch) { + initialize(); + + BOOST_TEST(name(Animal()) == "animal"); + BOOST_TEST(name(Dog()) == "dog"); + BOOST_TEST(name(Cat()) == "cat"); + BOOST_TEST(name(Bulldog()) == "bulldog"); + BOOST_TEST(name(Tiger()) == "cat"); +} + +BOOST_AUTO_TEST_CASE(multiple_dispatch) { + initialize(); + + BOOST_TEST(meet(Dog(), Cat()) == "chase"); + BOOST_TEST(meet(Cat(), Dog()) == "hiss"); + BOOST_TEST(meet(Bulldog(), Tiger()) == "chase"); + BOOST_TEST(meet(Dog(), Dog()) == "ignore"); +} + +// Every registered id inside the advertised range and distinct from the others, +// in a table that is a power of two - so between one and two slots per type id. +BOOST_AUTO_TEST_CASE(hash_is_injective_and_minimal) { + initialize(); + + auto [low, high] = type_hash::hash_range(); + BOOST_TEST(low == 0u); + + std::set seen; + + for (auto type : + {&typeid(Animal), &typeid(Dog), &typeid(Cat), &typeid(Bulldog), + &typeid(Tiger)}) { + auto index = type_hash::hash(type); + BOOST_TEST(index >= low); + BOOST_TEST(index <= high); + BOOST_TEST(seen.insert(index).second); + } + + // The table holds `2^ceil(log2(n))` slots, so between one and two per type + // id. `void` is registered too, hence the floor rather than an exact + // figure. + BOOST_TEST(high + 1 >= seen.size()); + BOOST_TEST(high + 1 <= seen.size() * 2); +} + +BOOST_AUTO_TEST_CASE(unregistered_class_is_diagnosed) { + initialize(); + + BOOST_CHECK_THROW(name(Ghost()), missing_class); +} diff --git a/test/test_hash_policies.cpp b/test/test_hash_policies.cpp new file mode 100644 index 00000000..53223510 --- /dev/null +++ b/test/test_hash_policies.cpp @@ -0,0 +1,345 @@ +// Copyright (c) 2017-2026 Jean-Louis Leroy +// Distributed under the Boost Software License, Version 1.0. +// See accompanying file LICENSE_1_0.txt +// or copy at http://www.boost.org/LICENSE_1_0.txt) + +// Drives `type_hash` policies directly, through a stand-in for the +// InitializeContext blueprint, rather than through `initialize()`. That isolates +// a policy from the rest of the compiler and - the point of the exercise - lets +// the type ids be *chosen*, so that a distribution which is hard to hash can be +// presented deliberately instead of being whatever this program's own classes +// happen to get. +// +// The type ids here are fabricated addresses. No `type_hash` policy +// dereferences a type id - each only casts it to an integer - but the trace +// option would, so this file must never pass one, and the registries it +// declares must never be handed to `boost::openmethod::initialize()`. + +#include +#include +#include +#include + +#define BOOST_TEST_MODULE hash_policies +#include + +#include +#include +#include +#include +#include + +namespace bom = boost::openmethod; +namespace pol = boost::openmethod::policies; + +namespace { + +// A stand-in for InitializeContext. The policies under test use only +// classes_begin/classes_end, the type id range of each class, and has_option. +struct fake_class_view { + const bom::type_id* first; + const bom::type_id* last; + + auto type_id_begin() const { + return first; + } + + auto type_id_end() const { + return last; + } + + auto vptr() const -> bom::vptr_type { + return nullptr; + } + + auto static_vptr() const -> const bom::vptr_type* { + return nullptr; + } +}; + +struct fake_context { + template + static constexpr bool has_option = false; + + std::vector views; + + auto classes_begin() const { + return views.begin(); + } + + auto classes_end() const { + return views.end(); + } +}; + +// One class per type id, which is what augment_classes() produces for a program +// whose classes are each registered once. +auto context_over(const std::vector& ids) -> fake_context { + fake_context ctx; + ctx.views.reserve(ids.size()); + + for (const auto& id : ids) { + ctx.views.push_back(fake_class_view{&id, &id + 1}); + } + + return ctx; +} + +auto as_type_id(std::uint64_t value) -> bom::type_id { + return reinterpret_cast(value); +} + +// The four distributions that matter, all on the 16-byte grid the Itanium ABI +// guarantees for `type_info` records. +// +// `packed` is one module whose records happen to be adjacent. `diluted` is the +// realistic single-module case: v-tables are emitted between the records, so +// they are spread over many times their own size. `multi_module` is a program +// plus implicitly linked libraries. `dlopened` is the case this family of +// policies exists for - a program plus modules the loader placed wherever it +// liked, tens of terabytes apart. +// +// Each module gets a cursor that only ever moves forward, so the ids are +// distinct by construction. They have to be: a policy deduplicates the ids it +// is given, so a generator that repeats one would be testing the dedup rather +// than the hash, and would make an injectivity count come out short. +auto ids_over(std::size_t n, const std::uint64_t* bases, std::size_t modules) + -> std::vector { + std::vector at(bases, bases + modules); + std::vector ids; + ids.reserve(n); + + for (std::size_t i = 0; i != n; ++i) { + auto module = i % modules; + ids.push_back(as_type_id(at[module])); + at[module] += 16 * (1 + (i * 2654435761u) % 24); + } + + return ids; +} + +auto ids_packed(std::size_t n) -> std::vector { + std::vector ids; + ids.reserve(n); + + for (std::size_t i = 0; i != n; ++i) { + ids.push_back(as_type_id(0x7f0000001000ull + i * 16)); + } + + return ids; +} + +auto ids_diluted(std::size_t n) -> std::vector { + const std::uint64_t base = 0x7f0000001000ull; + + return ids_over(n, &base, 1); +} + +auto ids_multi_module(std::size_t n) -> std::vector { + const std::uint64_t bases[] = { + 0x7f1000001000ull, 0x7f2940001000ull, 0x7fa13c001000ull, + 0x55d400001000ull}; + + return ids_over(n, bases, 4); +} + +auto ids_dlopened(std::size_t n) -> std::vector { + // An executable low in the address space and three mappings the loader put + // far above it: the span is tens of terabytes. + const std::uint64_t bases[] = { + 0x000060bc53002000ull, 0x000075e940002000ull, 0x00007c9180002000ull, + 0x00007ffe12002000ull}; + + return ids_over(n, bases, 4); +} + +// What every one of these policies promises: `hash` is injective over the type +// ids it was initialized with, and `hash_range` brackets every value it returns. +template +auto check_injective_over(const std::vector& ids) -> std::size_t { + using fn = typename Policy::template fn; + + auto ctx = context_over(ids); + fn::initialize(ctx, std::tuple<>{}); + + auto [low, high] = fn::hash_range(); + std::set seen; + + for (auto id : ids) { + auto index = fn::hash(id); + BOOST_TEST(index >= low); + BOOST_TEST(index <= high); + BOOST_TEST(seen.insert(index).second); + } + + BOOST_TEST(seen.size() == ids.size()); + fn::finalize(std::tuple<>{}); + + return high - low + 1; +} + +struct mph_registry : + bom::registry< + pol::std_rtti, pol::minimal_perfect_hash<>, pol::vptr_vector, + pol::default_error_handler, pol::stderr_output> {}; + +struct mph_minimal_registry : + bom::registry< + pol::std_rtti, pol::minimal_perfect_hash<2, 100>, pol::vptr_vector, + pol::default_error_handler, pol::stderr_output> {}; + +struct tlh_registry : + bom::registry< + pol::std_rtti, pol::two_level_hash<>, pol::vptr_vector, + pol::default_error_handler, pol::stderr_output> {}; + +#if BOOST_OPENMETHOD_HAS_PEXT +struct mch_registry : + bom::registry< + pol::std_rtti, pol::minimal_cover_hash<>, pol::vptr_vector, + pol::default_error_handler, pol::stderr_output> {}; +#endif + +} // namespace + +// The fixture's own precondition: a generator that repeated an id would make +// every injectivity count below come out short, for no fault of the policies. +BOOST_AUTO_TEST_CASE(generators_produce_distinct_ids) { + for (auto n : {std::size_t(1), std::size_t(17), std::size_t(1000)}) { + for (auto&& named : + {std::pair{"packed", ids_packed(n)}, + std::pair{"diluted", ids_diluted(n)}, + std::pair{"multi_module", ids_multi_module(n)}, + std::pair{"dlopened", ids_dlopened(n)}}) { + BOOST_TEST_CONTEXT(named.first << ", n = " << n) { + std::set distinct( + named.second.begin(), named.second.end()); + BOOST_TEST(distinct.size() == n); + } + } + } +} + +BOOST_AUTO_TEST_CASE(injective_on_every_distribution) { + for (auto n : + {std::size_t(1), std::size_t(2), std::size_t(17), std::size_t(256), + std::size_t(1000)}) { + for (auto&& named : + {std::pair{"packed", ids_packed(n)}, + std::pair{"diluted", ids_diluted(n)}, + std::pair{"multi_module", ids_multi_module(n)}, + std::pair{"dlopened", ids_dlopened(n)}}) { + BOOST_TEST_CONTEXT(named.first << ", n = " << n) { + check_injective_over>( + named.second); + check_injective_over>( + named.second); +#if BOOST_OPENMETHOD_HAS_PEXT + check_injective_over>( + named.second); +#endif + } + } + } +} + +// The property that distinguishes this family: the table is sized by how many +// type ids there are, not by where they sit. The `dlopened` distribution spans +// tens of terabytes, and must cost exactly what the packed one costs. +BOOST_AUTO_TEST_CASE(table_size_is_independent_of_placement) { + const std::size_t n = 1000; + + auto packed = + check_injective_over>( + ids_packed(n)); + auto spread = + check_injective_over>( + ids_dlopened(n)); + BOOST_TEST(packed == spread); + + auto packed_two = check_injective_over>( + ids_packed(n)); + auto spread_two = check_injective_over>( + ids_dlopened(n)); + BOOST_TEST(packed_two == spread_two); +} + +// `LoadPercent = 100` asks for exactly one slot per type id. +BOOST_AUTO_TEST_CASE(minimal_perfect_hash_can_be_exactly_minimal) { + for (auto n : {std::size_t(17), std::size_t(256), std::size_t(1000)}) { + BOOST_TEST_CONTEXT("n = " << n) { + auto slots = check_injective_over< + mph_minimal_registry, pol::minimal_perfect_hash<2, 100>>( + ids_diluted(n)); + BOOST_TEST(slots == n); + } + } +} + +// The default leaves a little slack, and spends it: at most one slot in twenty +// more than there are type ids. +BOOST_AUTO_TEST_CASE(minimal_perfect_hash_is_near_minimal) { + const std::size_t n = 1000; + auto slots = + check_injective_over>( + ids_diluted(n)); + // `slots = ceil(n * 100 / LoadPercent)`, the policy's own formula. + BOOST_TEST(slots == (n * 100 + 94) / 95); +} + +// two_level_hash rounds up to a power of two, so between one and two slots per +// type id - and exactly one when the count is already a power of two. +BOOST_AUTO_TEST_CASE(two_level_hash_table_is_a_power_of_two) { + for (auto n : {std::size_t(17), std::size_t(256), std::size_t(1000)}) { + BOOST_TEST_CONTEXT("n = " << n) { + auto slots = + check_injective_over>( + ids_diluted(n)); + BOOST_TEST((slots & (slots - 1)) == 0u); + BOOST_TEST(slots >= n); + BOOST_TEST(slots < n * 2); + } + } +} + +// A type id may be registered by more than one module, so the same one can +// appear in several class views. The table is over the *distinct* ids. +BOOST_AUTO_TEST_CASE(repeated_type_ids_are_not_collisions) { + auto ids = ids_diluted(64); + auto doubled = ids; + doubled.insert(doubled.end(), ids.begin(), ids.end()); + + fake_context ctx; + + for (const auto& id : doubled) { + ctx.views.push_back(fake_class_view{&id, &id + 1}); + } + + using fn = pol::minimal_perfect_hash<>::fn; + fn::initialize(ctx, std::tuple<>{}); + auto [low, high] = fn::hash_range(); + BOOST_TEST(high - low + 1 <= ids.size() + ids.size() / 20 + 1); + + std::set seen; + + for (auto id : ids) { + BOOST_TEST(seen.insert(fn::hash(id)).second); + } + + fn::finalize(std::tuple<>{}); +} + +// finalize() releases what initialize() allocated. Re-initializing afterwards +// has to work, which is what `initialize()` does on every call. +BOOST_AUTO_TEST_CASE(initialize_after_finalize) { + using fn = pol::minimal_perfect_hash<>::fn; + + for (int round = 0; round != 3; ++round) { + auto ids = ids_diluted(128 + std::size_t(round) * 8); + auto ctx = context_over(ids); + fn::initialize(ctx, std::tuple<>{}); + BOOST_TEST(fn::hash_range().second + 1 >= ids.size()); + fn::finalize(std::tuple<>{}); + BOOST_TEST(fn::hash_range().second == 0u); + } +} diff --git a/test/test_policies.cpp b/test/test_policies.cpp index af35b617..2392de05 100644 --- a/test/test_policies.cpp +++ b/test/test_policies.cpp @@ -10,6 +10,9 @@ #include #include +#include +#include +#include #include "test_util.hpp" @@ -80,3 +83,34 @@ static_assert(!has_initialize< static_assert(has_initialize< fast_perfect_hash::fn, registry1::compiler>, std::tuple<>>); + +// The alternative `type_hash` policies conform to the same blueprint. Each is a +// class template, so name a specialization; the defaults are what a user who +// does not tune them gets. +static_assert(has_initialize< + minimal_perfect_hash<>::fn, + registry1::compiler>, std::tuple<>>); +static_assert(has_initialize< + two_level_hash<>::fn, + registry1::compiler>, std::tuple<>>); +#if BOOST_OPENMETHOD_HAS_PEXT +static_assert(has_initialize< + minimal_cover_hash<>::fn, + registry1::compiler>, std::tuple<>>); +#endif + +// All four are interchangeable: each derives from the `type_hash` category, so +// `with` replaces whichever one a registry already has, in place, rather than +// appending a second - which would leave `vptr_vector` reading the wrong state. +static_assert(std::is_base_of_v); +static_assert(std::is_base_of_v>); +static_assert(std::is_base_of_v>); +static_assert(std::is_base_of_v>); +static_assert(std::is_same_v< + default_registry::with>::policy, + minimal_perfect_hash<>::fn< + default_registry::with>>>); +static_assert( + mp11::mp_size::value == + mp11::mp_size< + default_registry::with>::policy_list>::value); From db74099f00ea48133105c775e7823cf6aacfc9b9 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leroy Date: Thu, 10 Sep 2026 22:01:13 -0400 Subject: [PATCH 4/5] doc: document the three policies, and the dlopen situation they address The tutorial explained dlopen without ever saying what is different about it for type ids, which is the thing that decides whether the default hash policy copes. A new section of shared_libraries.adoc, "Type Ids Across Modules", fills that in: that a type id is `&typeid(X)`; that the Itanium ABI requires pointer identity across modules and the linker delivers it for an implicitly linked library with a copy relocation, so a program and the libraries it links against present one compact set of ids; that dlopen gets none of that, because nothing names a plugin's classes, so its records stay in its own mapping wherever the loader put it; and that RTTI has to keep default visibility for any of it to work, which is why the library's own tests mark their classes BOOST_SYMBOL_VISIBLE. None of that was written down anywhere outside a comment in test/dynamic_loading/classes.hpp. Then what it costs - fast_perfect_hash searching over several far-apart clusters, and vptr_vector sizing its table from the result - and the four ways out, as a table: the three new policies and vptr_map, which sidesteps the question by not hashing at all. Each with the one declaration that selects it, and a note that `with` replaces by category in place, so the ordering rule elsewhere on the page is not something a caller has to think about. Also: three entries in ref_headers.adoc; a paragraph in registries_and_policies.adoc saying there are four type_hash policies and what the other three are for; a forward reference in performance.adoc, whose figures are fast_perfect_hash's specifically; and three tagged snippets in snippets/policies.cpp, which is compiled and run as a test, so the examples in the reference cannot rot. Two fixes the rendered output turned up, neither visible in the source: - `@ref minimal_perfect_hash:` had its colon absorbed into the reference name, so the sentence rendered as "...as in `minimal_perfect_hash` zero is a fixed point...". A colon is valid in a qualified name, so the parser takes it. Reworded to end the sentence with a period instead. Note that five shipped headers have the same construct and lose their colons the same way - initialize.hpp's `@li @ref missing_class:` among them - which is left alone here. - BOOST_OPENMETHOD_HAS_PEXT got no reference page, because its doc comment was separated from both `#define` directives by the `#if` that chooses between them, and a comment on the far side of a directive is not attached. Every @ref to it therefore rendered as plain text. The detection now sets an internal macro and the documented one is a single unconditional `#define` with the comment attached to it. Verified by rendering, not by reading: every @ref in the three new headers resolves to a link, the new section's table and both code blocks render, the three cross-references into its anchor resolve, no page leaks MRDOCS, and no stray backticks survive on any of the four edited pages. --- doc/modules/ROOT/pages/performance.adoc | 7 ++ doc/modules/ROOT/pages/ref_headers.adoc | 18 +++ .../ROOT/pages/registries_and_policies.adoc | 18 +++ doc/modules/ROOT/pages/shared_libraries.adoc | 109 ++++++++++++++++++ doc/modules/ROOT/snippets/policies.cpp | 101 ++++++++++++++++ .../policies/minimal_cover_hash.hpp | 43 ++++--- .../openmethod/policies/two_level_hash.hpp | 9 +- 7 files changed, 287 insertions(+), 18 deletions(-) diff --git a/doc/modules/ROOT/pages/performance.adoc b/doc/modules/ROOT/pages/performance.adoc index 1f14b20a..d044c4e4 100644 --- a/doc/modules/ROOT/pages/performance.adoc +++ b/doc/modules/ROOT/pages/performance.adoc @@ -75,6 +75,13 @@ correct vtable. Then it stores a pointer to it in the `virtual_ptr` object, along with a pointer to the object.footnote:[This is how Go and Rust implement dynamic dispatch.] +The cost of that lookup belongs to the registry's cpp:type_hash[] policy, which +is cpp:fast_perfect_hash[] here as everywhere `default_registry` is used - a +multiply, a shift and a load. The alternatives in +xref:shared_libraries.adoc#type_ids_across_modules[Type Ids Across Modules] buy +a smaller or more predictable table and pay for it on this path, so the figures +below are the best case rather than the only one. + If we already have a `virtual_ptr`: [source,c++] diff --git a/doc/modules/ROOT/pages/ref_headers.adoc b/doc/modules/ROOT/pages/ref_headers.adoc index 3003bb6b..a4473a92 100644 --- a/doc/modules/ROOT/pages/ref_headers.adoc +++ b/doc/modules/ROOT/pages/ref_headers.adoc @@ -166,6 +166,24 @@ exceptions. Provides an implementation of the `vptr` policy that stores the v-table pointers in a map (by default a `std::map`) indexed by type ids. +### link:{headers-url}/boost/openmethod/policies/minimal_perfect_hash.hpp[] + +Provides an implementation of the `type_hash` policy that spends one slot per +type id whatever the type ids are, by hash and displace. + +### link:{headers-url}/boost/openmethod/policies/two_level_hash.hpp[] + +Provides an implementation of the `type_hash` policy that indexes a power-of-two +table with a per-bucket multiplier. + +### link:{headers-url}/boost/openmethod/policies/minimal_cover_hash.hpp[] + +Provides an implementation of the `type_hash` policy that indexes by the +smallest set of bit positions that separates the type ids, extracted with +BMI2{apos}s `pext`. Requires that instruction; see +xref:shared_libraries.adoc#type_ids_across_modules[Type Ids Across Modules] for +when to prefer each of the three. + ## Headers Included by Other Headers These are the library's foundations. Every other header includes them, and a diff --git a/doc/modules/ROOT/pages/registries_and_policies.adoc b/doc/modules/ROOT/pages/registries_and_policies.adoc index db0f9ed0..3b7bb83a 100644 --- a/doc/modules/ROOT/pages/registries_and_policies.adoc +++ b/doc/modules/ROOT/pages/registries_and_policies.adoc @@ -171,6 +171,24 @@ using the cpp:with[] and cpp:without[] nested templates. For example, struct indirect_registry : default_registry::with {}; ---- +cpp:with[] replaces the policy of the same _category_ where it already stands, +and appends only when the registry has no policy of that category yet. That is +what makes a policy swap a one-liner: `default_registry::with< +policies::minimal_perfect_hash<>>` puts the new hash exactly where +`fast_perfect_hash` was, still ahead of `vptr_vector`, so the ordering rule above +is not something a caller has to think about. + +The library ships four `type_hash` policies. `fast_perfect_hash` is the default +and the right choice for almost every program. The others exist for the case it +handles least well - type ids spread over several far-apart address ranges, which +is what a program that `dlopen`{empty}s class-registering modules has: +cpp:minimal_perfect_hash[] spends one slot per type id whatever the addresses +are, cpp:two_level_hash[] trades a sawtooth table size for a shorter dispatch +sequence, and cpp:minimal_cover_hash[] indexes by a minimal cover of the ids' +bits but needs BMI2. Each policy's own page has the details; +xref:shared_libraries.adoc#type_ids_across_modules[Type Ids Across Modules] +explains the situation they address and when to pick which. + Policies are implemented as unary https://www.boost.org/doc/libs/latest/libs/mp11/doc/html/mp11.html[Boost.MP11 quoted metafunctions]. A policy is an ordinary class that contains a nested diff --git a/doc/modules/ROOT/pages/shared_libraries.adoc b/doc/modules/ROOT/pages/shared_libraries.adoc index 716a6fd2..129379d0 100644 --- a/doc/modules/ROOT/pages/shared_libraries.adoc +++ b/doc/modules/ROOT/pages/shared_libraries.adoc @@ -229,6 +229,114 @@ against this by putting the applicable macro in a project header that every translation unit includes, as in the examples, rather than repeating it in individual `.cpp` files. +[#type_ids_across_modules] +## Type Ids Across Modules + +Everything above is about sharing the registry's _state_. There is a second, +quieter question: where the _type ids_ themselves come from, and how far apart +they end up. It decides how well the registry's cpp:type_hash[] policy can do +its job, and it is the one place where `dlopen` behaves differently from +ordinary linking. + +Under cpp:std_rtti[], a type id is `&typeid(X)` - the address of a +`std::type_info` object. The Itanium ABI requires that identity to be _pointer_ +identity across modules, and the linker delivers it for an implicitly linked +shared library with a copy relocation: the record is copied into the +executable's image, and the library's references are redirected to that copy. +So a program and the libraries it links against present one compact set of type +ids, however many modules there are. + +`dlopen` does not get that. A plugin's own classes are not named by the +executable, so nothing unifies them; their records stay in the plugin's own +mapping, which the loader places wherever it likes - and with address-space +randomization, somewhere different on every run. The distance between a +program's type ids and its plugin's is routinely measured in terabytes, and it +moves from run to run. + +NOTE: RTTI has to keep default visibility for ids to unify at all. Under +`-fvisibility=hidden` one class can end up with a different `type_info` object +in each module; cpp:initialize[] copes - it treats them as several ids for the +same class - but they are extra ids for the hash to separate. This is why the +library's own shared-library tests mark their classes `BOOST_SYMBOL_VISIBLE`. + +### What it costs + +cpp:fast_perfect_hash[], the default, searches for a multiplier `M` and a shift +`S` such that `(M * x) >> S` is collision-free over the registered type ids. It +is fast and compact when the ids are evenly spread, and degrades when they are +not - and a program plus a few `dlopen`{empty}ed modules is as uneven as it +gets: several tight clusters, very far apart. Two things follow: + +* the search gets dramatically more expensive, and on a large enough set it + fails - it gives up after half a million attempts and the error handler is + called with a `search_error`, which by default terminates the program; +* cpp:vptr_vector[] sizes its table from the hash's range, so a hash that is + working hard costs memory as well as time. + +A program that loads plugins and registers more than a few hundred classes is +the one most likely to meet both. + +### The alternatives + +Three other cpp:type_hash[] policies trade that away, and a fourth option +removes the hash from the picture entirely. They are all drop-in: `with` +replaces a policy with the one of the same category, in place, so the new hash +still precedes cpp:vptr_vector[] in the list. + +[cols="1,3"] +|=== +| policy | what it does + +a| cpp:minimal_perfect_hash[] +a| One slot per type id, whatever the addresses are, and a search whose cost +depends only on how many classes there are. The table size can be stated before +seeing an address. Costs a second dependent load on every dispatch - a +nanosecond or two per call. **The one to reach for in a plugin host.** + +a| cpp:two_level_hash[] +a| The same idea with the final reduction replaced by a shift. Cheaper per call +than `minimal_perfect_hash` where the compiler hoists the shift amount out of +the dispatch loop, at the price of a table that rounds up to a power of two - +between one and two slots per type id, depending on the class count. + +a| cpp:minimal_cover_hash[] +a| Indexes by the smallest set of bit positions that still separates the type +ids. As fast per call as the default, and it finds its table deterministically +in milliseconds. Needs BMI2, for **every** translation unit of the program - +see its documentation before choosing it. + +a| cpp:vptr_map[] +a| Not a hash at all: a map keyed on the type id, so there is no table to size +and no search to fail. Slower per dispatch than any of the above, and the only +option that asks nothing of the type ids. +|=== + +Switching is one declaration. The registry is then a custom registry, so it +needs the treatment in <> to be shared across modules: + +[source,c++] +---- +struct plugin_registry : + boost::openmethod::default_registry::with< + boost::openmethod::policies::minimal_perfect_hash<>> {}; +---- + +`vptr_map` replaces the `vptr` policy rather than the hash, and the hash is then +dead weight, so drop it: + +[source,c++] +---- +struct plugin_registry : + boost::openmethod::default_registry::with< + boost::openmethod::policies::vptr_map<>>::without< + boost::openmethod::policies::type_hash> {}; +---- + +TIP: none of this arises until a module registers classes of its own. A plugin +that only adds _overriders_ for classes the program already registered +contributes no new type ids, and the default policies are as good there as +anywhere. + ## Indirect Vptrs `initialize` rebuilds the v-tables in the registry. This invalidates all the @@ -272,6 +380,7 @@ The shared library it loads includes the same header, so it uses `indirect_registry` too and imports the state. The complete example is in the `indirect_vptr` directory. +[#custom_registries] ## Custom Registries A custom registry is shared exactly the same way - name it instead of diff --git a/doc/modules/ROOT/snippets/policies.cpp b/doc/modules/ROOT/snippets/policies.cpp index 3a92eb4d..eed827a2 100644 --- a/doc/modules/ROOT/snippets/policies.cpp +++ b/doc/modules/ROOT/snippets/policies.cpp @@ -5,7 +5,10 @@ #include #include +#include +#include #include +#include #include #include @@ -118,6 +121,75 @@ BOOST_OPENMETHOD_OVERRIDE( } // namespace fast_perfect_hash_demo +namespace minimal_perfect_hash_demo { + +// tag::minimal_perfect_hash[] +// One slot per type id, whatever the addresses are. Swapping the hash is all it +// takes: `with` replaces the policy of the same category, in place, so +// `vptr_vector` still comes after it. +struct compact_registry : + default_registry::with> {}; +// end::minimal_perfect_hash[] + +BOOST_OPENMETHOD_CLASSES(Animal, Cat, Dog, compact_registry); + +BOOST_OPENMETHOD( + trick, (virtual_ptr), std::string, + compact_registry); + +BOOST_OPENMETHOD_OVERRIDE( + trick, (virtual_ptr), std::string) { + return "spin"; +} + +} // namespace minimal_perfect_hash_demo + +namespace two_level_hash_demo { + +// tag::two_level_hash[] +struct two_level_registry : + default_registry::with> {}; +// end::two_level_hash[] + +BOOST_OPENMETHOD_CLASSES(Animal, Cat, Dog, two_level_registry); + +BOOST_OPENMETHOD( + trick, (virtual_ptr), std::string, + two_level_registry); + +BOOST_OPENMETHOD_OVERRIDE( + trick, (virtual_ptr), std::string) { + return "spin"; +} + +} // namespace two_level_hash_demo + +#if BOOST_OPENMETHOD_HAS_PEXT + +namespace minimal_cover_hash_demo { + +// tag::minimal_cover_hash[] +// Needs BMI2, for every translation unit of the program - hence the guard. +#if BOOST_OPENMETHOD_HAS_PEXT +struct cover_registry : + default_registry::with> {}; +#endif +// end::minimal_cover_hash[] + +BOOST_OPENMETHOD_CLASSES(Animal, Cat, Dog, cover_registry); + +BOOST_OPENMETHOD( + trick, (virtual_ptr), std::string, cover_registry); + +BOOST_OPENMETHOD_OVERRIDE( + trick, (virtual_ptr), std::string) { + return "spin"; +} + +} // namespace minimal_cover_hash_demo + +#endif + namespace stderr_output_demo { // tag::stderr_output[] @@ -226,6 +298,35 @@ BOOST_AUTO_TEST_CASE(rtti_and_storage) { trick(virtual_ptr(snoopy)) == "spin"); } + { + using namespace minimal_perfect_hash_demo; + initialize(); + + Dog snoopy; + BOOST_TEST( + trick(virtual_ptr(snoopy)) == "spin"); + } + + { + using namespace two_level_hash_demo; + initialize(); + + Dog snoopy; + BOOST_TEST( + trick(virtual_ptr(snoopy)) == "spin"); + } + +#if BOOST_OPENMETHOD_HAS_PEXT + { + using namespace minimal_cover_hash_demo; + initialize(); + + Dog snoopy; + BOOST_TEST( + trick(virtual_ptr(snoopy)) == "spin"); + } +#endif + { using namespace stderr_output_demo; initialize(); diff --git a/include/boost/openmethod/policies/minimal_cover_hash.hpp b/include/boost/openmethod/policies/minimal_cover_hash.hpp index 08a2ad30..630c237a 100644 --- a/include/boost/openmethod/policies/minimal_cover_hash.hpp +++ b/include/boost/openmethod/policies/minimal_cover_hash.hpp @@ -17,24 +17,41 @@ #include #include +// Detect BMI2's parallel bit extract. GCC and clang define __BMI2__ when the +// instruction is enabled, which takes -mbmi2 or a -march= that implies it. MSVC +// gates nothing on a macro and emits the instruction from the intrinsic, so +// there the test is only that the target is x86. +// +// The detection and the documented macro are separate so that the latter is one +// unconditional #define, with its doc comment directly attached. A comment +// separated from its #define by a preprocessor directive is not attached to it, +// and MrDocs then produces no page - which would make every @ref to the macro +// render as plain text. +#if defined(__BMI2__) || \ + (defined(_MSC_VER) && (defined(_M_X64) || defined(_M_IX86))) +#define BOOST_OPENMETHOD_DETAIL_HAS_PEXT 1 +#else +#define BOOST_OPENMETHOD_DETAIL_HAS_PEXT 0 +#endif + //! Whether @ref boost::openmethod::policies::minimal_cover_hash can be used on //! this target. //! //! 1 if the compiler can emit BMI2's parallel bit extract, `pext`, and 0 //! otherwise. The header always compiles; what fails, with a diagnostic, is -//! naming the policy in a registry when this is 0. +//! naming the policy in a registry when this is 0. A program that offers the +//! policy as an option guards the declaration with it: //! -//! GCC and clang define `__BMI2__` when the instruction is enabled, which takes -//! `-mbmi2` or a `-march=` that implies it. MSVC gates nothing on a macro and -//! emits the instruction from the intrinsic, so there the test is only that the -//! target is x86 - and it remains the program's business to run on a CPU that -//! has the instruction. -#if defined(__BMI2__) || \ - (defined(_MSC_VER) && (defined(_M_X64) || defined(_M_IX86))) -#define BOOST_OPENMETHOD_HAS_PEXT 1 -#else -#define BOOST_OPENMETHOD_HAS_PEXT 0 -#endif +//! @code +//! #if BOOST_OPENMETHOD_HAS_PEXT +//! struct my_registry : +//! boost::openmethod::default_registry::with< +//! boost::openmethod::policies::minimal_cover_hash<>> {}; +//! #endif +//! @endcode +//! +//! @see [Registries and Policies](xref:ROOT:registries_and_policies.adoc) +#define BOOST_OPENMETHOD_HAS_PEXT BOOST_OPENMETHOD_DETAIL_HAS_PEXT #if BOOST_OPENMETHOD_HAS_PEXT #include @@ -115,7 +132,7 @@ namespace policies { //! @warning **BMI2 is required, and that is not a portable requirement.** `pext` //! is absent on ARM and on x86 before Haswell and Excavator, and is microcoded //! on AMD Zen 1 and Zen 2 - around 18 cycles rather than 3 - where this policy -//! will be slower than the default rather than faster. Because @ref hash is +//! will be slower than the default rather than faster. Because `hash` is //! inlined into every dispatch, `-mbmi2` (or a `-march=` implying it) has to be //! set for **every** translation unit of the program, and of any module sharing //! the registry, not just one; a binary built with it executes an illegal diff --git a/include/boost/openmethod/policies/two_level_hash.hpp b/include/boost/openmethod/policies/two_level_hash.hpp index 8fc01fc1..ed0b1206 100644 --- a/include/boost/openmethod/policies/two_level_hash.hpp +++ b/include/boost/openmethod/policies/two_level_hash.hpp @@ -59,11 +59,10 @@ namespace boost::openmethod::policies { //! Like @ref minimal_perfect_hash this needs no instruction-set extension and //! makes no assumption about the layout of the type ids. //! -//! @note **A type id of zero is outside this policy's domain**, for the same -//! reason as in @ref minimal_perfect_hash: zero is a fixed point of both -//! multiplies, so it lands in slot 0 whatever `m1` and the per-bucket -//! multiplier are, and the search fails whenever another bucket has taken that -//! slot. Addresses are never zero; a custom @ref rtti policy handing out small +//! @note **A type id of zero is outside this policy's domain**, exactly as it is +//! for @ref minimal_perfect_hash. Zero is a fixed point of both multiplies, so +//! it lands in slot 0 whatever `m1` and the per-bucket multiplier are, and the +//! search fails whenever another bucket has taken that slot. Addresses are never zero; a custom @ref rtti policy handing out small //! integers must not use zero. When the registry has @ref runtime_checks, //! @ref initialize asserts that none of the registered type ids is zero. //! From c766ec4e6f69a594d36bb081331401e9c7d45153 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leroy Date: Fri, 11 Sep 2026 14:57:53 -0400 Subject: [PATCH 5/5] fix: two things CI found that a 64-bit build could not **minimal_cover_hash's static_assert fired at parse time on GCC 11 and 12, and Clang 13 through 15.** A static_assert whose condition does not depend on the enclosing template may be diagnosed as soon as the template is *defined* rather than when it is instantiated - the standard calls such a template ill-formed, no diagnostic required, and compilers differ on when they report it. The condition was BOOST_OPENMETHOD_HAS_PEXT, a plain 0 or 1, so on those compilers merely *including* the header was an error when the instruction was unavailable, which is the one thing the header promises not to do. It now goes through detail::has_pext, a variable template, so the condition is dependent and the check happens on use. Not reproducible here: the oldest local compiler is GCC 13, which defers, and no container runtime is available. Verified instead that including the header is clean on GCC 13, 15 and 16 and Clang 22, that naming the policy in a registry still fails with the same diagnostic, and that it still works under -mbmi2. **test_hash_policies fabricated 64-bit addresses**, so on a 32-bit target reinterpret_cast to type_id truncated them. The multi_module bases differ only in their high bits, so all four collapsed onto one another and the generator emitted duplicates - 988 distinct ids out of 1000. The bases are now derived from sizeof(uintptr_t), with a `spread` parameter saying how far apart the modules sit, so the same layout holds at either width. The fixture's own generators_produce_distinct_ids case caught this first in CI and pointed straight at the cause, which is what it is there for. Reproduced locally with -m32 - the old file fails with exactly CI's [16 != 17] and [988 != 1000], the new one passes at both widths. 164 tests pass in Release and Debug. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JqH1U2Ky7ubqKzqSvgxY9y --- .../policies/minimal_cover_hash.hpp | 16 +++++- test/test_hash_policies.cpp | 51 +++++++++++-------- 2 files changed, 45 insertions(+), 22 deletions(-) diff --git a/include/boost/openmethod/policies/minimal_cover_hash.hpp b/include/boost/openmethod/policies/minimal_cover_hash.hpp index 630c237a..c285db51 100644 --- a/include/boost/openmethod/policies/minimal_cover_hash.hpp +++ b/include/boost/openmethod/policies/minimal_cover_hash.hpp @@ -61,6 +61,20 @@ namespace boost::openmethod { namespace detail { +// BOOST_OPENMETHOD_HAS_PEXT, made dependent on a template parameter. +// +// A static_assert whose condition does not depend on the enclosing template may +// be diagnosed as soon as the template is *defined*, rather than when it is +// instantiated - the standard calls such a template ill-formed, no diagnostic +// required, and compilers differ on when they report it. GCC 11 and 12, and +// Clang 13 through 15, report it immediately; GCC 13 and later, and Clang 18 +// and later, wait for the instantiation. Written the obvious way, the assertion +// in minimal_cover_hash::fn would therefore make *including this header* an +// error on those compilers whenever the instruction is unavailable - which is +// the one thing the header promises not to do. +template +inline constexpr bool has_pext = BOOST_OPENMETHOD_HAS_PEXT != 0; + // Cold path only: the cover search counts mask bits, the dispatch path does // not. Plain C++ rather than an intrinsic, so it carries no instruction-set // requirement of its own - minimal_cover_hash already has one, and one is @@ -186,7 +200,7 @@ struct minimal_cover_hash : type_hash { template class fn { static_assert( - BOOST_OPENMETHOD_HAS_PEXT, + detail::has_pext, "minimal_cover_hash needs BMI2: compile every translation unit " "with -mbmi2 (or a -march= that implies it), or use " "minimal_perfect_hash, which is portable."); diff --git a/test/test_hash_policies.cpp b/test/test_hash_policies.cpp index 53223510..5e8a8a03 100644 --- a/test/test_hash_policies.cpp +++ b/test/test_hash_policies.cpp @@ -85,7 +85,7 @@ auto context_over(const std::vector& ids) -> fake_context { return ctx; } -auto as_type_id(std::uint64_t value) -> bom::type_id { +auto as_type_id(std::uintptr_t value) -> bom::type_id { return reinterpret_cast(value); } @@ -97,15 +97,34 @@ auto as_type_id(std::uint64_t value) -> bom::type_id { // they are spread over many times their own size. `multi_module` is a program // plus implicitly linked libraries. `dlopened` is the case this family of // policies exists for - a program plus modules the loader placed wherever it -// liked, tens of terabytes apart. +// liked, at opposite ends of the address space. // +// The bases are derived from the pointer width rather than written as literals. +// A type id is a pointer, and on a 32-bit target it is four bytes wide, so a +// 64-bit literal would be silently truncated - and bases that differ only in +// their high bits would collapse onto one another, leaving the generators +// producing duplicates. +constexpr auto address_bits = sizeof(std::uintptr_t) * 8; + +// `module` picks a distinct high-bit pattern; `spread` says how far apart the +// modules sit - a smaller value puts them further apart. `spread` must leave +// room for the largest pattern, so it is never less than 3 for four modules. +auto base_of(std::size_t module, std::size_t spread) -> std::uintptr_t { + return (std::uintptr_t(1 + module) << (address_bits - spread)) + 0x1000; +} + // Each module gets a cursor that only ever moves forward, so the ids are // distinct by construction. They have to be: a policy deduplicates the ids it -// is given, so a generator that repeats one would be testing the dedup rather +// is given, so a generator that repeated one would be testing the dedup rather // than the hash, and would make an injectivity count come out short. -auto ids_over(std::size_t n, const std::uint64_t* bases, std::size_t modules) +auto ids_over(std::size_t n, std::size_t modules, std::size_t spread) -> std::vector { - std::vector at(bases, bases + modules); + std::vector at; + + for (std::size_t module = 0; module != modules; ++module) { + at.push_back(base_of(module, spread)); + } + std::vector ids; ids.reserve(n); @@ -119,38 +138,28 @@ auto ids_over(std::size_t n, const std::uint64_t* bases, std::size_t modules) } auto ids_packed(std::size_t n) -> std::vector { + auto at = base_of(0, 8); std::vector ids; ids.reserve(n); for (std::size_t i = 0; i != n; ++i) { - ids.push_back(as_type_id(0x7f0000001000ull + i * 16)); + ids.push_back(as_type_id(at)); + at += 16; } return ids; } auto ids_diluted(std::size_t n) -> std::vector { - const std::uint64_t base = 0x7f0000001000ull; - - return ids_over(n, &base, 1); + return ids_over(n, 1, 8); } auto ids_multi_module(std::size_t n) -> std::vector { - const std::uint64_t bases[] = { - 0x7f1000001000ull, 0x7f2940001000ull, 0x7fa13c001000ull, - 0x55d400001000ull}; - - return ids_over(n, bases, 4); + return ids_over(n, 4, 8); } auto ids_dlopened(std::size_t n) -> std::vector { - // An executable low in the address space and three mappings the loader put - // far above it: the span is tens of terabytes. - const std::uint64_t bases[] = { - 0x000060bc53002000ull, 0x000075e940002000ull, 0x00007c9180002000ull, - 0x00007ffe12002000ull}; - - return ids_over(n, bases, 4); + return ids_over(n, 4, 3); } // What every one of these policies promises: `hash` is injective over the type