Repository navigation
Conversation
Tests build one executable per tests/tests_*.cpp with doctest. Benchmarks build one nanobench binary from benchmarks/bench_*.cpp. The test dependencies are doctest, nanobench, metall, unordered_dense and the boost headers. The project builds as C++23.
counter counts what a container does with its elements. allocators.hpp has an allocator that throws on request, an allocator with an id and configurable propagation, and a counting allocator. TEST_CASE_MAP and TEST_CASE_SET run a test against the three sparsity levels, the prime growth policy, the strong exception guarantee and an offset_ptr allocator. The counter and allocator fixtures are ported from ankerl::unordered_dense.
The tests of the map, the set, the growth policies, a custom allocator, the fancy pointer support and the scoped allocator support are doctest executables tests_<name>. They have the same test cases, checks, type lists and data as the Boost.Test suites. The shared key and value types and the serializer are in tests/fixtures/utils.hpp. The fancy pointer tests use offset_ptr_allocator from the fixtures.
The quick overall score, find_all, the string hash, set workloads, find_random, memory per element and many small maps, for sparse_map with each sparsity level and with unordered_dense and std::unordered_map as baselines. bench_metall runs the map benchmarks with metall's allocator and with offset_ptr on the heap. The workloads check their results with checksums that depend only on the contents of the map. DICE_SPARSE_MAP_BENCH_QUICK=1 runs every benchmark once with small sizes, as a smoke test.
Ten test executables tests_ud_* hold the unit tests of ankerl::unordered_dense for construction, insert, erase, lookup, iterators, capacity, assignment, element types, multiple APIs, transparent lookup and precalculated hashes. The expectations follow the behaviour of sparse_map: forward iterators, the max load factor in [0.1, 0.8], bucket counts from the growth policy, deleted markers on erase, and emplace that builds the value before the lookup.
Eight test executables: metall persistence, standard layout, pmr, allocator propagation, exception safety, noexcept contract, lazy bucket allocation, and a randomized differential test against std::unordered_map and std::unordered_set. A counting std::pmr::memory_resource fixture. Tests that fail on the current headers are marked skip or should_fail with the reason.
The table keeps its layout: groups of 64 buckets with an occupancy bitmap and dense values arrays, quadratic probing and deleted markers. Map iterators return std::pair<Key const &, T &> and model std::forward_iterator. iterator::value() and iterator::key() are gone. Iterators hold raw pointers, also with fancy pointers, and keep the end of the elements of their group. The GrowthPolicy parameter is gone, the bucket count is a power of two, and a hash that is not marked as avalanching is mixed. Map elements are stored in a standard layout struct, so the containers are standard layout with the metall allocator. New members: insert_range and the from_range constructors, allocator-extended copy and move constructors, merge, erase_if, heterogeneous try_emplace, insert_or_assign, operator[], erase and set insert, and lookup. Deduction guides. All members are constexpr. The noexcept of move assignment and swap follows the standard containers. Deserialization checks the size and the bitmaps of each group, the counts and the max load factor in the header. It reserves memory for at most 2^20 elements before it reads them, and the group array grows with the stream. Version 0.4.0, POBR version 3, serialization protocol version 2. The tests and benchmarks follow the new interface.
tests_modern_api checks the iterator concepts, changes through the proxy reference, views, lookup, ranges, deduction guides, erase_if and merge, heterogeneous operations, allocator-extended constructors, hash mixing, standard layout, noexcept, constant evaluation, and inserts whose arguments refer to elements of the same map.
The README describes the features, the requirements, the differences to std::unordered_map, hash functions, serialization and the migration from 0.3. tests_readme_examples runs the examples of the README.
CI builds with gcc 14 (-Werror), with gcc 14 and clang 20 with ASan and UBSan, and with clang 19, and it builds the benchmarks. The POBR check looks at sparse_hash.hpp, sparse_map.hpp and sparse_set.hpp.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This pull request raises the minimum C++ version to C++23, rewrites
sparse_map,sparse_setandsparse_hashon that base, and adds tests and benchmarks. The table layout stays as it is: groups of 64 buckets, an occupancy bitmap per group, dense values arrays, quadratic probing and deleted markers.Breaking changes
The README has a section "Migration from 0.3". In short:
iterator::value()anditerator::key()are gone.it->secondis a mutable reference to the mapped value. A map iterator returns the proxy referencestd::pair<Key const &, T &>, likestd::flat_map. Bind elements withauto &&orauto const &, not withauto &.GrowthPolicytemplate parameter and the growth policy classes are gone, and with themsparse_pg_mapandsparse_pg_set. The bucket count is always a power of two.using is_avalanching = void;or a specialization ofhash_is_avalanching) is mixed with one 64 bit multiplication. This is the convention ofankerl::unordered_denseandboost::unordered.std::hash<std::uint64_t>is the identity and needs the mixing with power-of-two bucket counts.pobr_versionis 3. Containers persisted with 0.3 cannot be opened. The serialization protocol version is 2. Data serialized with 0.3 can be deserialized only withouthash_compatible.std::pair<Key const &, T const &>instead ofstd::pair<Key, T>.boost_offset_pointer.hppandsparse_growth_policy.hppare gone.Interface
std::pair. With a standard layout hash, key equality and allocator,sparse_mapandsparse_setare standard layout, also with the metall allocator. A test checks this forstd::allocator, anoffset_ptrallocator and metall.std::forward_iterator. Value-initialized iterators compare equal.std::views::keysandstd::views::valueswork.insert_rangeand thestd::from_rangeconstructors, deduction guides, allocator-extended copy and move constructors,merge(if an exception is thrown, the element that was being merged stays in the source),erase_if, heterogeneoustry_emplace,insert_or_assign,operator[],eraseand setinsert,lookup(returnsoptional_ref<T>, which isstd::optional<T &>when the standard library has it).constexpr. Astatic_asserttest inserts, erases, looks up, iterates, copies, moves and rehashes a map in a constant expression.noexceptfollows the standard containers: move assignment isnoexceptif the allocator propagates or is always equal and the functors do not throw,swapisnoexceptif the functors swap without throwing. Both handle the functors first, so that an exception of a functor leaves the elements and the allocators where they were.sizeof(sparse_map<int, int>)is 64 bytes (was 88).Defects of the old code that are fixed
Each has a test.
try_emplace(k, map.at(other))read a moved-from value whenotherwas in the same group, and freed memory (heap-use-after-free under ASan) when the insert grew the table.exception_safety::basic, a rehash that threw left a size that counted elements whichfindand iteration no longer reached.findon the target looked in its old, empty groups.noexceptwithout a condition, also when it allocated.swaphad nonoexcept.exception_safety::strongwith a move-only type failed to compile deep inside the implementation. It is astatic_assertwith a message now.max_size()calledallocator_traits::max_size()without an allocator.std::runtime_error, and so is a header whose counts do not match the groups or whose max load factor is not in (0, 1].hash_compatiblefor all elements (8 GiB for a header that claims 2^33 elements), withhash_compatiblefor all groups. Now the reservation is limited to 2^20 elements, and the group array grows with the stream.insert(first, last)failed an assertion in debug builds aftermax_load_factorwas lowered below the current load.Tests
ctest runs 29 doctest executables. They pass with clang 20 and gcc 14 (RelWithDebInfo), with clang 20 ASan and UBSan, and like the CI jobs with gcc 14 (Debug,
-Werror, no warnings) and clang 19 (Debug).ankerl::unordered_dense, adapted to the behaviour ofsparse_map(tests_ud_*).std::pmr, allocator propagation, exception safety with an allocator and elements that throw on request, thenoexceptcontract, lazy allocation, and a randomized differential test againststd::unordered_mapandstd::unordered_set.tests_modern_api) and the README examples.Benchmarks
-DBUILD_BENCHMARKS=ONbuilds one nanobench binary with the benchmarks ofankerl::unordered_dense: the quick overall score, find_all, find_random, the set workloads, memory per element and many small maps. They run for the three sparsity levels, withstd::allocator, with anoffset_ptrallocator, and with the metall allocator, and withunordered_denseandstd::unordered_mapas baselines.benchmarks/README.mdexplains them.A/B on one pinned P-core of an i9-14900K, clang 20, Release,
-march=x86-64-v3, two rounds of each binary, medians. "old" is this branch with the headers ofdevelop, "new" is the rewrite. The measured build is older than the review fixes inmerge, move assignment,swapand deserialization, which the benchmarks do not run. The table shows new/old, lower is faster. The rows ofunordered_denseandstd::unordered_maprun the same code in both binaries. Their ratios lie between 0.990 and 1.016 (10th to 90th percentile), which is the noise band. No row ofsparse_maporsparse_setis slower than 1.021.std::allocatoroffset_ptrallocatorThe iterators and the probing work on raw pointers, so fancy pointers cost much less. The iterator keeps the end of the elements of its group, which makes iteration faster with every allocator. The memory per element is the same as before: for
uint64_t -> uint64_twith 100000 elements 17.6, 18.3 and 19.6 bytes for the sparsity levels high, medium and low, against 28.2 bytes forunordered_denseand 37.8 bytes forstd::unordered_map.Changes needed downstream
src/rdf4cpp/Dataset.cpp: four calls ofit.value()becomeit->second.internal/container/SparseMap.hppandSparseSet.hpp: drop the argumentpower_of_two_growth_policy<2>.RawHashDiagonal.hpp(two places):auto &[...] = *itbecomesauto &&[...] = *it.tests_NodeLayout.cpp: thestatic_asserts that the nodes withoffset_ptrand metall allocators are not standard layout fail now and must be inverted.derefinderef_map_iterator.hpptakes theiter->secondbranch.hypertrie-template-instantiation.hpp:map_typeandset_typedrop the growth policy argument.DiceHashis not marked as avalanching, sosparse_mapmixes its result once more (one multiplication per hash). If the policies give avalanching hashes for all types,using is_avalanching = void;inDiceHashavoids that.Opportunities
Changes that are out of scope here, ordered by the work they need.
Without a change of the binary format:
unordered_dense.exception_safety::strongcould accept move-only types.emplaceandinsertlook up the key before they build the element, when the arguments carry the key.memcpyfor trivially copyable slots when a group shifts or grows.offset_to_indexwithpdep(BMI2).With one change of the binary format (one POBR bump):
unordered_dense5. A miss in a group without overflow needs only the header, an erase frees its bucket at once, and there are no clean-up rehashes.SIMD (Highway): the bitmap work is scalar by nature (
popcount,countr_zero,pdep). After the format change, the fingerprints of the candidates of a key are one contiguous range, which one 16-lane compare checks. Static dispatch fits a header-only template library, and constant evaluation needs a scalar path. The large lookup gains come from the layout, not from SIMD.Found outside this repository
ankerl::unordered_dense5.2 maps do not compile with the metall allocator (offset_ptris converted to a raw pointer instl_iterator.h).segment_storage::priv_deallocate_segment_headerreads afterdestroy_at(gcc-Wmaybe-uninitialized).(prepared with claude)