Skip to content

C++23 rewrite of sparse_map and sparse_set, with tests and benchmarks - #56

Closed
bigerl wants to merge 11 commits into
developfrom
feature/modernize-cxx23
Closed

bigerl wants to merge 11 commits into
developfrom
feature/modernize-cxx23

Conversation

@bigerl

@bigerl bigerl commented Oct 1, 2026

Copy link
Copy Markdown
Member

This pull request raises the minimum C++ version to C++23, rewrites sparse_map, sparse_set and sparse_hash on 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() and iterator::key() are gone. it->second is a mutable reference to the mapped value. A map iterator returns the proxy reference std::pair<Key const &, T &>, like std::flat_map. Bind elements with auto && or auto const &, not with auto &.
  • The GrowthPolicy template parameter and the growth policy classes are gone, and with them sparse_pg_map and sparse_pg_set. The bucket count is always a power of two.
  • A hash function that is not marked as avalanching (using is_avalanching = void; or a specialization of hash_is_avalanching) is mixed with one 64 bit multiplication. This is the convention of ankerl::unordered_dense and boost::unordered. std::hash<std::uint64_t> is the identity and needs the mixing with power-of-two bucket counts.
  • pobr_version is 3. Containers persisted with 0.3 cannot be opened. The serialization protocol version is 2. Data serialized with 0.3 can be deserialized only without hash_compatible.
  • The serializer gets std::pair<Key const &, T const &> instead of std::pair<Key, T>.
  • The headers boost_offset_pointer.hpp and sparse_growth_policy.hpp are gone.
  • Version 0.4.0.

Interface

  • The elements of a map are stored in a standard layout struct, not in std::pair. With a standard layout hash, key equality and allocator, sparse_map and sparse_set are standard layout, also with the metall allocator. A test checks this for std::allocator, an offset_ptr allocator and metall.
  • The iterators model std::forward_iterator. Value-initialized iterators compare equal. std::views::keys and std::views::values work.
  • New: insert_range and the std::from_range constructors, 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, heterogeneous try_emplace, insert_or_assign, operator[], erase and set insert, lookup (returns optional_ref<T>, which is std::optional<T &> when the standard library has it).
  • All member functions are constexpr. A static_assert test inserts, erases, looks up, iterates, copies, moves and rehashes a map in a constant expression.
  • noexcept follows the standard containers: move assignment is noexcept if the allocator propagates or is always equal and the functors do not throw, swap is noexcept if 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 when other was in the same group, and freed memory (heap-use-after-free under ASan) when the insert grew the table.
  • With exception_safety::basic, a rehash that threw left a size that counted elements which find and iteration no longer reached.
  • A copy assignment that threw left a table whose bucket pointer pointed into freed memory.
  • A copy assignment with a propagating allocator gave memory back to the wrong allocator.
  • A move assignment with unequal allocators leaked the memory and the moved-from elements of the source, and find on the target looked in its old, empty groups.
  • Move assignment was noexcept without a condition, also when it allocated. swap had no noexcept.
  • exception_safety::strong with a move-only type failed to compile deep inside the implementation. It is a static_assert with a message now.
  • A rehash swapped the hash and the key equality, so it needed swappable functors.
  • Value-initialized iterators were not equal.
  • max_size() called allocator_traits::max_size() without an allocator.
  • Hash-compatible deserialization trusted the size and the bitmaps of each group and the counts in the header. A corrupted stream led to reads out of bounds or to lookups that never end. Now a group whose size, bitmaps and bucket count do not fit together is rejected with 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].
  • Deserialization allocated for the counts in the header before it read the data: without hash_compatible for all elements (8 GiB for a header that claims 2^33 elements), with hash_compatible for all groups. Now the reservation is limited to 2^20 elements, and the group array grows with the stream.
  • Hash-compatible deserialization rejected the valid stream of a table whose max load factor was lowered below its load.
  • insert(first, last) failed an assertion in debug builds after max_load_factor was 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).

  • The Boost.Test suites, converted to doctest, with the same test cases.
  • The unit tests of ankerl::unordered_dense, adapted to the behaviour of sparse_map (tests_ud_*).
  • Allocators: metall persistence (close, reopen at another address, check every element), standard layout, std::pmr, allocator propagation, exception safety with an allocator and elements that throw on request, the noexcept contract, lazy allocation, and a randomized differential test against std::unordered_map and std::unordered_set.
  • The new interface (tests_modern_api) and the README examples.

Benchmarks

-DBUILD_BENCHMARKS=ON builds one nanobench binary with the benchmarks of ankerl::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, with std::allocator, with an offset_ptr allocator, and with the metall allocator, and with unordered_dense and std::unordered_map as baselines. benchmarks/README.md explains 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 of develop, "new" is the rewrite. The measured build is older than the review fixes in merge, move assignment, swap and deserialization, which the benchmarks do not run. The table shows new/old, lower is faster. The rows of unordered_dense and std::unordered_map run 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 of sparse_map or sparse_set is slower than 1.021.

workload std::allocator offset_ptr allocator metall allocator
iterate 0.73 0.35 0.59
find (hits, misses, mixed) 0.99 0.67 0.67
build from empty 0.98 0.80 0.77
random insert erase 0.98 0.78 0.69
churn at a fixed size 0.98 0.71 0.66
all rows, geometric mean 0.95 0.64 0.67

The 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_t with 100000 elements 17.6, 18.3 and 19.6 bytes for the sparsity levels high, medium and low, against 28.2 bytes for unordered_dense and 37.8 bytes for std::unordered_map.

Changes needed downstream

  • rdf4cpp, src/rdf4cpp/Dataset.cpp: four calls of it.value() become it->second.
  • hypertrie, internal/container/SparseMap.hpp and SparseSet.hpp: drop the argument power_of_two_growth_policy<2>.
  • hypertrie, RawHashDiagonal.hpp (two places): auto &[...] = *it becomes auto &&[...] = *it.
  • hypertrie, tests_NodeLayout.cpp: the static_asserts that the nodes with offset_ptr and metall allocators are not standard layout fail now and must be inverted. deref in deref_map_iterator.hpp takes the iter->second branch.
  • tentris-lib, hypertrie-template-instantiation.hpp: map_type and set_type drop the growth policy argument.
  • dice-hash: DiceHash is not marked as avalanching, so sparse_map mixes its result once more (one multiplication per hash). If the policies give avalanching hashes for all types, using is_avalanching = void; in DiceHash avoids that.

Opportunities

Changes that are out of scope here, ordered by the work they need.

Without a change of the binary format:

  • Start with one full group of 64 buckets. Today the first insert creates 2 buckets, and the table rehashes 5 times inside one group until it has 64 buckets. Building 100000 maps with 1 to 16 elements takes 310 to 350 ns per map, against about 100 ns for unordered_dense.
  • A sized rehash in two passes: count the elements per target group, allocate every values array once, place the elements in bucket order without shifting, and hash ahead with prefetch. After the allocations only moves remain, so exception_safety::strong could accept move-only types.
  • emplace and insert look up the key before they build the element, when the arguments carry the key.
  • memcpy for trivially copyable slots when a group shifts or grows.
  • offset_to_index with pdep (BMI2).
  • A range of groups (each group a span of slots), so that a loop over all elements is a tight loop over contiguous arrays.
  • A batch lookup that prefetches the group headers and then the slots of the next keys.

With one change of the binary format (one POBR bump):

  • One fingerprint byte per element, stored with the values. Most key compares of a miss go away.
  • Probing inside the 64 buckets of the home group with overflow counters instead of deleted markers, like unordered_dense 5. 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.
  • A group header of 24 instead of 32 bytes (the element count is a popcount, the capacity follows from it).
  • Then a higher maximum load factor.

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_dense 5.2 maps do not compile with the metall allocator (offset_ptr is converted to a raw pointer in stl_iterator.h).
  • metall: segment_storage::priv_deallocate_segment_header reads after destroy_at (gcc -Wmaybe-uninitialized).
  • The checksums of the upstream find_random benchmark do not match with nanobench 4.3.11.

(prepared with claude)

bigerl added 11 commits October 1, 2026 17:38
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant