Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 7 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,20 @@ jobs:
cxx: clang++-20
cxx-flags: "-fsanitize=address,undefined"

- name: linux-x64-gcc-13
- name: linux-x64-gcc-14
os: ubuntu-24.04
cxx: g++-13
- name: linux-x64-gcc-14-coverage
cxx: g++-14
cxx-flags: -Werror
- name: linux-x64-gcc-14-sanitize
os: ubuntu-24.04
cxx: g++-14
cxx-flags: -Werror --coverage
gcov-tool: gcov-10
cxx-flags: "-fsanitize=address,undefined"

name: ${{matrix.config.name}}
runs-on: ${{matrix.config.os}}
steps:
# setup env
- name: Add repos for for gcc-13 and clang-16
- name: Add repos for gcc and clang
uses: dice-group/cpp-conan-release-reusable-workflow/.github/actions/setup_apt@v2.1.1

- name: Install tools
Expand Down Expand Up @@ -75,7 +75,7 @@ jobs:
CC: ${{ steps.install_cc.outputs.cc }}
CXX: ${{ steps.install_cc.outputs.cxx }}
CXXFLAGS: ${{ matrix.config.cxx-flags }}
run: cmake -B build_dir -DCMAKE_BUILD_TYPE=Debug -DBUILD_TESTING=ON -DCMAKE_PROJECT_TOP_LEVEL_INCLUDES=conan_provider.cmake
run: cmake -B build_dir -DCMAKE_BUILD_TYPE=Debug -DBUILD_TESTING=ON -DBUILD_BENCHMARKS=ON -DCMAKE_PROJECT_TOP_LEVEL_INCLUDES=conan_provider.cmake

- name: Build
working-directory: build_dir
Expand All @@ -85,9 +85,3 @@ jobs:
working-directory: build_dir
run: ctest --parallel 2 --verbose

#- name: Coverage
# run: |
# sudo apt-get install -y lcov
# lcov -c -b ${{github.workspace}}/include -d ${{github.workspace}}/build -o ${{github.workspace}}/coverage.info --no-external --gcov-tool ${{matrix.config.gcov-tool}}
# bash <(curl -s https://codecov.io/bash) -f ${{github.workspace}}/coverage.info
# if: ${{matrix.config.name == 'linux-x64-gcc-13-coverage'}}
1 change: 0 additions & 1 deletion .github/workflows/detect-pobr-diff.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ jobs:
cxx: clang++-19
base-branch: ${{ github.base_ref }}
search-path: >
include/dice/sparse-map/sparse_growth_policy.hpp
include/dice/sparse-map/sparse_hash.hpp
include/dice/sparse-map/sparse_map.hpp
include/dice/sparse-map/sparse_set.hpp
Expand Down
18 changes: 11 additions & 7 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
cmake_minimum_required(VERSION 3.24)
project(dice-sparse-map
VERSION 0.3.0
VERSION 0.4.0
DESCRIPTION "C++ implementation of a memory efficient hash map and hash set based on [tsl::sparse_map](https://github.com/Tessil/sparse-map). We added support for fancy pointers.")
set(POBR_VERSION 2) # Persisted Object Binary Representation Version
set(POBR_VERSION 3) # Persisted Object Binary Representation Version

include(cmake/boilerplate_init.cmake)
boilerplate_init()
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/cmake/version.hpp.in ${CMAKE_CURRENT_SOURCE_DIR}/include/dice/sparse-map/version.hpp)

option(BUILD_TESTING "Build tests" OFF)
option(BUILD_BENCHMARKS "Build benchmarks" OFF)

if (PROJECT_IS_TOP_LEVEL)
if (BUILD_TESTING)
if (BUILD_TESTING OR BUILD_BENCHMARKS)
set(CONAN_INSTALL_ARGS "${CONAN_INSTALL_ARGS};-o=&:with_test_deps=True")
endif ()
# only boost headers are needed, never the compiled libraries
set(CONAN_INSTALL_ARGS "${CONAN_INSTALL_ARGS};-o=boost/*:header_only=True")
endif ()

set(style_files
Expand Down Expand Up @@ -48,10 +51,11 @@ if (PROJECT_IS_TOP_LEVEL)
include(CTest)
enable_testing()
add_subdirectory(tests)
else ()
# in top-level builds without tests we only need boost headers but not libraries
# so avoid building the unecessesarily
set(CONAN_INSTALL_ARGS "${CONAN_INSTALL_ARGS};-o=boost/*:header_only=True")
endif ()

if (BUILD_BENCHMARKS)
message("Benchmarks are configured to be build.")
add_subdirectory(benchmarks)
endif ()

include(cmake/install_interface_library.cmake)
Expand Down
482 changes: 154 additions & 328 deletions README.md

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions benchmarks/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
find_package(doctest REQUIRED)
find_package(Boost REQUIRED)
find_package(Metall REQUIRED)
find_package(nanobench REQUIRED)
find_package(unordered_dense REQUIRED)

# One binary with every benchmark. Each bench_<name>.cpp holds doctest test cases, so a run picks
# benchmarks with doctest's filters, e.g. -tc="*sparsity*".
file(GLOB benchmark_sources CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/bench_*.cpp")
add_executable(dice_sparse_map_benchmarks benchmark_main.cpp ${benchmark_sources})
# the tests directory for the allocator fixtures of the tests, e.g. fixtures/offset_ptr_allocator.hpp
target_include_directories(dice_sparse_map_benchmarks PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}"
"${PROJECT_SOURCE_DIR}/tests"
)
target_link_libraries(dice_sparse_map_benchmarks PRIVATE
dice-sparse-map::dice-sparse-map
doctest::doctest
nanobench::nanobench
Boost::headers
Metall::Metall
unordered_dense::unordered_dense
)
90 changes: 90 additions & 0 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Benchmarks

One binary, `dice_sparse_map_benchmarks`, holds every benchmark. Each `bench_<name>.cpp` holds doctest
test cases, and each test case runs one benchmark with [nanobench](https://github.com/martinus/nanobench)
(or with its own clock, see below). Most workloads are ported from the benchmarks of
[ankerl::unordered_dense](https://github.com/martinus/unordered_dense) (MIT license).
`workloads.hpp` keeps the reasons upstream gives for the shape of each workload.

## Build

The benchmarks are built when the project is the top level project and `BUILD_BENCHMARKS` is on.
The dependencies (nanobench, doctest, metall, the boost headers and unordered_dense) come from conan,
as for the tests. `conan_provider.cmake` is the dependency provider of
[cmake-conan](https://github.com/conan-io/cmake-conan); it is not part of the repository.

```sh
cmake -G Ninja -B build-release -DCMAKE_BUILD_TYPE=Release -DBUILD_BENCHMARKS=ON \
-DCMAKE_PROJECT_TOP_LEVEL_INCLUDES=conan_provider.cmake
cmake --build build-release --target dice_sparse_map_benchmarks
```

## Run

```sh
build-release/benchmarks/dice_sparse_map_benchmarks # everything
build-release/benchmarks/dice_sparse_map_benchmarks -ltc # list the benchmarks
build-release/benchmarks/dice_sparse_map_benchmarks -tc="quick_overall*" # all quick overall scores
build-release/benchmarks/dice_sparse_map_benchmarks -tc="*sparse_map high" -tce="*metall*"
build-release/benchmarks/dice_sparse_map_benchmarks -d # print the time of each test case
```

`-tc` selects test cases by name, `-tce` excludes them. Both take wildcards and comma separated lists.
nanobench prints a markdown table for every benchmark.

For measurements, use a release build on a quiet machine and pin the process to one core, for example
with `taskset -c 2`. The numbers of different runs are only comparable on the same machine.

### Environment variables

| variable | meaning |
|------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `DICE_SPARSE_MAP_BENCH_QUICK=1` | Quick mode for smoke tests: smaller sizes, and every nanobench benchmark runs exactly once. The whole binary takes well under a minute. The checks run with checksums for the small sizes. The timings of the quick mode mean nothing. |
| `DICE_SPARSE_MAP_BENCH_METALL_DIR` | Directory for the metall datastores, default `/tmp/dice-sparse-map-bench-metall`. Every metall test case creates a fresh datastore in the subdirectory `datastore` and removes it at the end. Nothing else in the directory is touched. Use a local disk. |

## Containers

Every benchmark runs `dice::sparse_map::sparse_map` (or `sparse_set`) with each sparsity level
(`sh::sparsity::high`, `medium`, `low`) and two baselines: `ankerl::unordered_dense::map` (or `set`)
5.2.0 and `std::unordered_map` (or `std::unordered_set`). The sparsity sets by how many slots a group
of 64 buckets grows when it is full (2, 4 or 8), so it trades memory for insert speed.

`sparse_map` and `unordered_dense` both use `ankerl::unordered_dense::hash`, so that a comparison of the
two open addressing tables shows the tables and not the hashes. `std::unordered_map` uses `std::hash`.

## What each benchmark measures

The workloads check their results. A checksum depends only on the contents of the map, so every correct
map gives the same one, and a wrong result fails the test case. Two results are not checked: the hits
of `find_all`, whose sum changes from run to run, and `hash_strings`, which measures no map.

| test case | file | what it measures |
|---|---|---|
| `quick_overall <container>` | `bench_quick_overall.cpp` | The quick overall score of upstream: the workloads `iterate` (iterate over the whole map after every insert and after every erase), `insert_erase` (random insert and erase, about 10000 entries live), `build` (200000 inserts into an empty map), `churn` (erase one, insert one at a fixed size of 50000, with lookups in between) and `find_50` (lookups with 50% hits in a map that grows to 50000) for the map types `uint64_t -> size_t`, `std::string -> size_t` and `uint64_t -> big_value` (a 64 byte value). Prints the geometric mean of the median times of all 15 runs. Lower is better. |
| `find_all <container>` | `bench_quick_overall.cpp` | A million lookups in a map of 50000 entries that all hit or all miss, for `uint64_t` and `std::string` keys. The two ends of what the hit rate does to the probe. Not part of the score. |
| `hash_strings` | `bench_quick_overall.cpp` | The string hashes alone, `ankerl::unordered_dense::hash` and `std::hash`, over the string keys of the workloads. |
| `quick_overall metall sparse_map <sparsity>` | `bench_metall.cpp` | The quick overall score with metall's allocator: the maps live in a metall datastore. |
| `quick_overall offset_ptr sparse_map <sparsity>` | `bench_metall.cpp` | The quick overall score with `offset_ptr_allocator` of the tests: fancy pointers (`boost::interprocess::offset_ptr`) with memory from the heap. The difference to `quick_overall sparse_map <sparsity>` is the cost of the fancy pointer, the difference to `quick_overall metall sparse_map <sparsity>` is the cost of metall's allocator. |
| `find_all metall sparse_map <sparsity>`, `find_all offset_ptr sparse_map <sparsity>` | `bench_metall.cpp` | `find_all` with metall's allocator and with `offset_ptr_allocator`. |
| `set <container>` | `bench_set.cpp` | `build`, `find_50` and `churn` for a set of `uint64_t`, with the geometric mean. |
| `find_random <container>` | `bench_find_random.cpp` | Random finds with 0%, 25%, 50%, 75% and 100% success in a `size_t -> size_t` map that grows to 200000 entries, 100 million finds per success rate. Timed with `std::chrono::steady_clock`, one run per success rate. |
| `memory` | `bench_memory.cpp` | Memory per element of maps with 1000, 100000 and 1000000 elements, `uint64_t -> uint64_t` and `std::string -> uint64_t`. A tracking allocator counts what the container requests: bytes per element after the build, peak bytes per element during the build, live allocations (`live allocs`), bucket count, load factor and `sizeof` of the map. The heap memory of `std::string` keys and the overhead of malloc are not counted. |
| `small_maps` | `bench_small_maps.cpp` | 100000 maps with 1 to 16 `uint64_t -> uint64_t` entries each (fixed seed), the pattern of the edge maps of a hypertrie in tentris. Times the build, the lookups that hit and that miss (maps in a random order), the iteration over all maps and the destruction, with the median over 11 rounds. Also the heap bytes and allocations per map from a tracking allocator, and `sizeof` of the map. |

`<container>` is one of `sparse_map high`, `sparse_map medium`, `sparse_map low`, `unordered_dense` and
`std::unordered_map` (for sets: `sparse_set ...`, `unordered_dense` and `std::unordered_set`).

There is no metall baseline with `ankerl::unordered_dense::map`. Version 5.2.0 does not compile with
metall's allocator: its `operator[]` calls `operator->` on the iterator of its value vector, a
`std::vector` with metall's allocator, and with libstdc++ that iterator cannot return an `offset_ptr`
as a raw pointer.

## Files

| file | content |
|---|---|
| `benchmark_main.cpp` | doctest's `main` and the nanobench implementation |
| `workloads.hpp` | the workloads, ported from upstream's `workloads.h`, and set versions of three of them |
| `common.hpp` | quick mode, nanobench settings, geometric mean, the container configurations |
| `map_benchmarks.hpp` | sizes and checksums of the scored workloads, the quick overall score and `find_all` of one configuration |
| `tracking_allocator.hpp` | allocator that counts current and peak bytes and allocations |
132 changes: 132 additions & 0 deletions benchmarks/bench_find_random.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
#include "common.hpp"

#include <doctest/doctest.h>
#include <nanobench.h>

#include <array>
#include <chrono>
#include <cstddef>
#include <format>
#include <iostream>
#include <string_view>

using namespace dice::sparse_map::bench;

/*
* Random finds with a success rate of 0%, 25%, 50%, 75% and 100% in a map that grows while it is
* searched. Every iteration inserts four entries, some of them random and some from a second rng,
* then does 2000 finds that replay the second rng from its start. Which of the four inserts are
* random is shuffled for every iteration.
*
* Ported from ankerl::unordered_dense (test/bench/find_random.cpp, MIT license).
*/

namespace {

/**
* The sizes of upstream. Upstream's checksums for 25% and 50% are one lower: they come from
* nanobench 4.3.7, whose `Rng::shuffle` draws other positions than the one of nanobench 4.3.11.
*/
struct find_random_full {
static constexpr std::size_t num_inserts = 200000;
static constexpr std::size_t num_finds_per_insert = 500;
static constexpr std::array<std::size_t, 5> checksums{200000, 25198621, 50197241, 75195862, 100194482};
};

struct find_random_quick {
static constexpr std::size_t num_inserts = 20000;
static constexpr std::size_t num_finds_per_insert = 50;
static constexpr std::array<std::size_t, 5> checksums{20000, 269892, 519782, 769675, 1019566};
};

template<typename Map, typename Sizes>
void bench_find(std::string_view container) {
static constexpr std::size_t num_total = 4;
static constexpr std::size_t num_finds_per_iter = Sizes::num_finds_per_insert * num_total;

auto total = std::chrono::steady_clock::duration();
for (std::size_t num_found = 0; num_found < 5; ++num_found) {
auto rng = ankerl::nanobench::Rng(123);
std::size_t checksum = 0;

auto insert_random = std::array<bool, num_total>();
insert_random.fill(true);
for (std::size_t i = 0; i < num_found; ++i) {
insert_random[i] = false;
}

auto another_unrelated_rng = ankerl::nanobench::Rng(987654321);
auto const another_unrelated_rng_initial_state = another_unrelated_rng.state();
auto find_rng = ankerl::nanobench::Rng(another_unrelated_rng_initial_state);

{
Map map;
std::size_t i = 0;
std::size_t find_count = 0;
auto const before = std::chrono::steady_clock::now();
do {
// insert num_total entries: some random, some from another_unrelated_rng
rng.shuffle(insert_random);
for (bool const is_random_to_insert : insert_random) {
auto const val = another_unrelated_rng();
if (is_random_to_insert) {
map[static_cast<std::size_t>(rng())] = static_cast<std::size_t>(1);
} else {
map[static_cast<std::size_t>(val)] = static_cast<std::size_t>(1);
}
++i;
}

// the actual benchmark code which should be as fast as possible
for (std::size_t j = 0; j < num_finds_per_iter; ++j) {
if (++find_count > i) {
find_count = 0;
find_rng = ankerl::nanobench::Rng(another_unrelated_rng_initial_state);
}
auto it = map.find(static_cast<std::size_t>(find_rng()));
if (it != map.end()) {
checksum += it->second;
}
}
} while (i < Sizes::num_inserts);
checksum += map.size();
auto const after = std::chrono::steady_clock::now();
total += after - before;
std::cout << std::format("{:.6f}s random find {}% success {}\n",
std::chrono::duration<double>(after - before).count(),
num_found * 100 / num_total,
container);
}
REQUIRE(checksum == Sizes::checksums[num_found]);
}
std::cout << std::format("{:.6f}s total {}\n", std::chrono::duration<double>(total).count(), container);
}

template<typename Family>
void find_random(std::string_view container) {
with_sizes<find_random_full, find_random_quick>([&]<typename Sizes>() {
bench_find<typename Family::template map<std::size_t, std::size_t>, Sizes>(container);
});
}

} // namespace

TEST_CASE("find_random sparse_map high") {
find_random<sparse_high>("sparse_map high");
}

TEST_CASE("find_random sparse_map medium") {
find_random<sparse_medium>("sparse_map medium");
}

TEST_CASE("find_random sparse_map low") {
find_random<sparse_low>("sparse_map low");
}

TEST_CASE("find_random unordered_dense") {
find_random<unordered_dense_family<>>("unordered_dense");
}

TEST_CASE("find_random std::unordered_map") {
find_random<std_family<>>("std::unordered_map");
}
Loading