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/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/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..c285db51 --- /dev/null +++ b/include/boost/openmethod/policies/minimal_cover_hash.hpp @@ -0,0 +1,501 @@ +// 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 + +// 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. A program that offers the +//! policy as an option guards the declaration with it: +//! +//! @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 +#endif + +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 +// 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 `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( + 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."); + + 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..e4eec94d --- /dev/null +++ b/include/boost/openmethod/policies/minimal_perfect_hash.hpp @@ -0,0 +1,484 @@ +// 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 +#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. +//! +//! @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 +//! `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 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 = 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); + + 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( + std::uint64_t(reinterpret_cast(*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()); + + // 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) { + 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)]; + st().control[place(h, pilot, slots)] = + reinterpret_cast(id); + } + } +} + +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..ed0b1206 --- /dev/null +++ b/include/boost/openmethod/policies/two_level_hash.hpp @@ -0,0 +1,447 @@ +// 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 +#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. +//! +//! @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. +//! +//! @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 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 = 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); + + 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( + std::uint64_t(reinterpret_cast(*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()); + + // 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) { + 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); + st().control[index] = reinterpret_cast(id); + } + } +} + +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 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..5e8a8a03 --- /dev/null +++ b/test/test_hash_policies.cpp @@ -0,0 +1,354 @@ +// 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::uintptr_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, 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 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, std::size_t modules, std::size_t spread) + -> std::vector { + 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); + + 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 { + 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(at)); + at += 16; + } + + return ids; +} + +auto ids_diluted(std::size_t n) -> std::vector { + return ids_over(n, 1, 8); +} + +auto ids_multi_module(std::size_t n) -> std::vector { + return ids_over(n, 4, 8); +} + +auto ids_dlopened(std::size_t n) -> std::vector { + return ids_over(n, 4, 3); +} + +// 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);