From 3ac3b4dbc9dd761ff12200a42d3d600649156370 Mon Sep 17 00:00:00 2001 From: Willem Kaufmann Date: Tue, 23 Jun 2026 11:19:58 -0400 Subject: [PATCH 1/5] build: add unordered_dense dependency Wire ankerl::unordered_dense into the build as a required dependency: find_package in SeastarDependencies, link unordered_dense::unordered_dense PUBLIC, a cooking recipe pinned to the commit Redpanda consumes (v4.4.0), and propagate its include flags to consumers via the pkg-config file. This is a prerequisite for the upcoming chunked_hash_map container. --- CMakeLists.txt | 30 ++++++++++++++++++++++++++++++ cmake/SeastarDependencies.cmake | 4 ++++ cooking_recipe.cmake | 7 +++++++ pkgconfig/seastar.pc.in | 3 ++- 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index da32c419ab1..b825f3b6c29 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -417,6 +417,35 @@ set (Seastar_GEN_BINARY_DIR ${Seastar_BINARY_DIR}/gen) include (SeastarDependencies) seastar_find_dependencies () +# unordered_dense is header-only and not reliably packaged by distributions. +# When seastar_find_dependencies() did not locate an installed copy (e.g. in +# CI), fetch the pinned version so the build is self-contained. This lives in +# the Seastar build only, not in SeastarDependencies, so consumers that use +# find_package(Seastar) never trigger a download. +if (NOT TARGET unordered_dense::unordered_dense) + include (FetchContent) + FetchContent_Declare (unordered_dense + URL https://github.com/martinus/unordered_dense/archive/f30ed41b58af8c79788e8581fe57a6faf856258e.tar.gz + URL_HASH MD5=0370e4a35c1e573aa6639fde97f0c93f) + FetchContent_GetProperties (unordered_dense) + if (NOT unordered_dense_POPULATED) + # Populate (download) only; we deliberately do not add it as a subproject + # (see below). CMP0169 OLD keeps the bare Populate() form available. + if (POLICY CMP0169) + cmake_policy (SET CMP0169 OLD) + endif () + FetchContent_Populate (unordered_dense) + endif () + # Expose the fetched headers as an IMPORTED target rather than building it as + # a subproject, so that install(EXPORT) for Seastar (which PUBLIC-links it) + # does not require exporting a fetched target. Downstream consumers resolve + # unordered_dense themselves via SeastarDependencies. + add_library (unordered_dense_fetched INTERFACE IMPORTED GLOBAL) + set_target_properties (unordered_dense_fetched PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES "${unordered_dense_SOURCE_DIR}/include") + add_library (unordered_dense::unordered_dense ALIAS unordered_dense_fetched) +endif () + # Private build dependencies not visible to consumers find_package (ragel 6.10 REQUIRED) find_package (Threads REQUIRED) @@ -914,6 +943,7 @@ target_link_libraries (seastar c-ares::cares fmt::fmt lz4::lz4 + unordered_dense::unordered_dense PRIVATE ${CMAKE_DL_LIBS} StdAtomic::atomic diff --git a/cmake/SeastarDependencies.cmake b/cmake/SeastarDependencies.cmake index 0f33fd9ebce..d958656ba49 100644 --- a/cmake/SeastarDependencies.cmake +++ b/cmake/SeastarDependencies.cmake @@ -88,6 +88,10 @@ macro (seastar_find_dependencies) endif() seastar_find_dep (fmt 8.1.1 REQUIRED) seastar_find_dep (lz4 1.7.3 REQUIRED) + # Not REQUIRED: unordered_dense is header-only and not reliably packaged by + # distributions. When it is not installed, Seastar's own build fetches it + # (see CMakeLists.txt); consumers that lack it must provide it themselves. + seastar_find_dep (unordered_dense) seastar_find_dep (GnuTLS 3.7.4) seastar_find_dep (OpenSSL 3.0) if (Seastar_IO_URING) diff --git a/cooking_recipe.cmake b/cooking_recipe.cmake index 2ca71997646..c9638108265 100644 --- a/cooking_recipe.cmake +++ b/cooking_recipe.cmake @@ -336,3 +336,10 @@ cooking_ingredient (lz4 CONFIGURE_COMMAND BUILD_COMMAND INSTALL_COMMAND ${make_command} PREFIX= install) + +# Header-only library used by chunked_hash_map. Pinned to the same commit +# (v4.4.0) consumed by Redpanda. +cooking_ingredient (unordered_dense + EXTERNAL_PROJECT_ARGS + URL https://github.com/martinus/unordered_dense/archive/f30ed41b58af8c79788e8581fe57a6faf856258e.tar.gz + URL_MD5 0370e4a35c1e573aa6639fde97f0c93f) diff --git a/pkgconfig/seastar.pc.in b/pkgconfig/seastar.pc.in index 1438bf03d2f..49d800290e2 100644 --- a/pkgconfig/seastar.pc.in +++ b/pkgconfig/seastar.pc.in @@ -23,6 +23,7 @@ c_ares_cflags=-I$ c_ares_libs=$ fmt_cflags=-I$, -I> fmt_libs=$ +unordered_dense_cflags=-I$, -I> lksctp_tools_cflags=-I$ lksctp_tools_libs=$ liburing_cflags=$<$:-I$, -I>> @@ -35,6 +36,6 @@ seastar_libs=${libdir}/$ @Seastar_SPLIT_DWARF_FLAG@ $< Requires: liblz4 >= 1.7.3 Requires.private: gnutls >= 3.2.26, protobuf >= 2.5.0, hwloc >= 1.11.2, $<$:liburing $= 2.0, >yaml-cpp >= 0.5.1 Conflicts: -Cflags: @Seastar_CXX_COMPILE_OPTION@ ${boost_cflags} ${c_ares_cflags} ${fmt_cflags} ${liburing_cflags} ${lksctp_tools_cflags} ${seastar_cflags} +Cflags: @Seastar_CXX_COMPILE_OPTION@ ${boost_cflags} ${c_ares_cflags} ${fmt_cflags} ${unordered_dense_cflags} ${liburing_cflags} ${lksctp_tools_cflags} ${seastar_cflags} Libs: ${seastar_libs} ${boost_program_options_libs} ${c_ares_libs} ${fmt_libs} Libs.private: ${dl_libs} ${rt_libs} ${lksctp_tools_libs} ${liburing_libs} ${stdatomic_libs} ${dpdk_libs} From 85d8e0aba94809ff7c1043ea30a71f7f9ae1f737 Mon Sep 17 00:00:00 2001 From: Willem Kaufmann Date: Tue, 23 Jun 2026 11:20:38 -0400 Subject: [PATCH 2/5] build: add abseil dependency Wire abseil into the build as a required dependency for chunked_hash_map's absl::Hash support: find_package(absl CONFIG), link absl::hash PUBLIC, a cooking recipe pinned to the same LTS release Redpanda consumes (20250814.1), the absl_hash pkg-config requirement for consumers, and the abseil dev packages for the distros that reliably carry them. This is a prerequisite for the upcoming chunked_hash_map container. --- CMakeLists.txt | 1 + cmake/SeastarDependencies.cmake | 1 + cooking_recipe.cmake | 11 +++++++++++ install-dependencies.sh | 3 +++ pkgconfig/seastar.pc.in | 2 +- 5 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index b825f3b6c29..4bff036b789 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -944,6 +944,7 @@ target_link_libraries (seastar fmt::fmt lz4::lz4 unordered_dense::unordered_dense + absl::hash PRIVATE ${CMAKE_DL_LIBS} StdAtomic::atomic diff --git a/cmake/SeastarDependencies.cmake b/cmake/SeastarDependencies.cmake index d958656ba49..4caf49ce884 100644 --- a/cmake/SeastarDependencies.cmake +++ b/cmake/SeastarDependencies.cmake @@ -92,6 +92,7 @@ macro (seastar_find_dependencies) # distributions. When it is not installed, Seastar's own build fetches it # (see CMakeLists.txt); consumers that lack it must provide it themselves. seastar_find_dep (unordered_dense) + seastar_find_dep (absl CONFIG REQUIRED) seastar_find_dep (GnuTLS 3.7.4) seastar_find_dep (OpenSSL 3.0) if (Seastar_IO_URING) diff --git a/cooking_recipe.cmake b/cooking_recipe.cmake index c9638108265..4ccd5dfbcfb 100644 --- a/cooking_recipe.cmake +++ b/cooking_recipe.cmake @@ -343,3 +343,14 @@ cooking_ingredient (unordered_dense EXTERNAL_PROJECT_ARGS URL https://github.com/martinus/unordered_dense/archive/f30ed41b58af8c79788e8581fe57a6faf856258e.tar.gz URL_MD5 0370e4a35c1e573aa6639fde97f0c93f) + +# Used by chunked_hash_map for absl::Hash support. Pinned to the same LTS +# release (20250814.1) consumed by Redpanda. +cooking_ingredient (absl + EXTERNAL_PROJECT_ARGS + URL https://github.com/abseil/abseil-cpp/releases/download/20250814.1/abseil-cpp-20250814.1.tar.gz + URL_MD5 d4d3c25f78e28d61ad83e54cd1116933 + CMAKE_ARGS + -DABSL_PROPAGATE_CXX_STD=ON + -DABSL_ENABLE_INSTALL=ON + -DBUILD_TESTING=OFF) diff --git a/install-dependencies.sh b/install-dependencies.sh index 191500b70b6..a15a86c2973 100755 --- a/install-dependencies.sh +++ b/install-dependencies.sh @@ -33,6 +33,7 @@ debian_packages=( doxygen g++ gcc + libabsl-dev libboost-all-dev libc-ares-dev libcrypto++-dev @@ -113,6 +114,7 @@ redhat_packages=( fedora_packages=( "${redhat_packages[@]}" + abseil-cpp-devel boost-devel fmt-devel gcc-c++ @@ -166,6 +168,7 @@ centos9_packages=( # 3) aur installations require having sudo and being # a sudoer. makepkg does not work otherwise. arch_packages=( + abseil-cpp boost boost-libs c-ares diff --git a/pkgconfig/seastar.pc.in b/pkgconfig/seastar.pc.in index 49d800290e2..4098a7db870 100644 --- a/pkgconfig/seastar.pc.in +++ b/pkgconfig/seastar.pc.in @@ -33,7 +33,7 @@ dpdk_libs=$ seastar_cflags=${seastar_include_flags} $, > -D$, -D> seastar_libs=${libdir}/$ @Seastar_SPLIT_DWARF_FLAG@ $ -Requires: liblz4 >= 1.7.3 +Requires: liblz4 >= 1.7.3, absl_hash Requires.private: gnutls >= 3.2.26, protobuf >= 2.5.0, hwloc >= 1.11.2, $<$:liburing $= 2.0, >yaml-cpp >= 0.5.1 Conflicts: Cflags: @Seastar_CXX_COMPILE_OPTION@ ${boost_cflags} ${c_ares_cflags} ${fmt_cflags} ${unordered_dense_cflags} ${liburing_cflags} ${lksctp_tools_cflags} ${seastar_cflags} From b629fa0cc0a597a667d7a7c2f6cbc73c83a5f991 Mon Sep 17 00:00:00 2001 From: Willem Kaufmann Date: Tue, 23 Jun 2026 11:20:54 -0400 Subject: [PATCH 3/5] core: add chunked_vector container Port chunked_vector from Redpanda: a random-access vector whose storage is split across fixed-size fragments instead of one contiguous block, so it grows without large reallocations and keeps element addresses stable across growth. Adapted to Seastar conventions (seastar namespace, SEASTAR_ASSERT, .hh header) with a Boost.Test port of the test suite. --- CMakeLists.txt | 1 + include/seastar/core/chunked_vector.hh | 578 ++++++++++++++++++++++++ tests/unit/CMakeLists.txt | 4 + tests/unit/chunked_vector_test.cc | 581 +++++++++++++++++++++++++ 4 files changed, 1164 insertions(+) create mode 100644 include/seastar/core/chunked_vector.hh create mode 100644 tests/unit/chunked_vector_test.cc diff --git a/CMakeLists.txt b/CMakeLists.txt index 4bff036b789..7c4173038e6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -579,6 +579,7 @@ add_library (seastar include/seastar/core/cacheline.hh include/seastar/core/checked_ptr.hh include/seastar/core/chunked_fifo.hh + include/seastar/core/chunked_vector.hh include/seastar/core/circular_buffer.hh include/seastar/core/circular_buffer_fixed_capacity.hh include/seastar/core/condition-variable.hh diff --git a/include/seastar/core/chunked_vector.hh b/include/seastar/core/chunked_vector.hh new file mode 100644 index 00000000000..959d2125215 --- /dev/null +++ b/include/seastar/core/chunked_vector.hh @@ -0,0 +1,578 @@ +/* + * This file is open source software, licensed to you under the terms + * of the Apache License, Version 2.0 (the "License"). See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. You may not use this file except in compliance with the License. + * + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +/* + * Copyright 2020 Redpanda Data, Inc. + */ +#pragma once + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace seastar { + +/** + * A chunked vector is a container that provides random access like a + * vector, but does not store its data in contiguous memory. + * + * Instead the allocations are broken up across many different individual + * vectors, but the exposed view is of a single container. + * + * Additionally the allocation strategy is like a "normal" vector until the + * first chunk is full, after which we will then only allocate full chunks. + * + * The iterator implementation works for a few things like std::lower_bound, + * upper_bound, distance, etc... see chunked_vector_test. + */ +template +class chunked_vector { + static constexpr size_t max_allocation_size = 128UL * 1024; + + // calculate the maximum number of elements per fragment while + // keeping the element count a power of two + static consteval size_t calc_elems_per_frag() { + size_t max = max_allocation_size / sizeof(T); + return std::bit_floor(max); + } + + /** + * The maximum number of bytes per fragment as specified in + * as part of the type. Note that for most types, the true + * number of bytes in a full fragment may as low as half + * of this amount (+1) since the number of elements is restricted + * to a power of two. + */ + static consteval size_t calc_max_frag_bytes() { + return calc_elems_per_frag() * sizeof(T); + } + +public: + using this_type = chunked_vector; + using backing_type = std::vector>; + using value_type = T; + using reference = std::conditional_t, bool, T&>; + using const_reference + = std::conditional_t, bool, const T&>; + using size_type = size_t; + using allocator_type = backing_type::allocator_type; + using difference_type = backing_type::difference_type; + using pointer = T*; + using const_pointer = const T*; + + chunked_vector() noexcept = default; + explicit chunked_vector(allocator_type alloc) + : _frags(alloc) {} + chunked_vector& operator=(const chunked_vector&) noexcept = delete; + chunked_vector(chunked_vector&& other) noexcept { + *this = std::move(other); + } + + /** + * @brief Create a vector from a begin, end iterator pair. + * + * This has the same semantics as the corresponding std::vector + * constructor. + */ + template + requires std::input_iterator + chunked_vector(Iter begin, Iter end) + : chunked_vector() { + if constexpr (std::random_access_iterator) { + reserve(std::distance(begin, end)); + } + // Improvement: Write a more efficient implementation for + // std::contiguous_iterator + for (auto it = begin; it != end; ++it) { + push_back(*it); + } + } + + /** + * @brief Construct a new vector using an initializer list + * + * In the same manner as the corresponding std::vector method. + */ + chunked_vector(std::initializer_list elems) + : chunked_vector(elems.begin(), elems.end()) {} + + chunked_vector& operator=(chunked_vector&& other) noexcept { + if (this != &other) { + this->_size = other._size; + this->_capacity = other._capacity; + this->_frags = std::move(other._frags); + // Move compatibility with std::vector that post move + // the vector is empty(). + other._size = other._capacity = 0; + other.update_generation(); + update_generation(); + } + return *this; + } + ~chunked_vector() noexcept = default; + + chunked_vector copy() const noexcept { return *this; } + + auto get_allocator() const { return _frags.get_allocator(); } + + void swap(chunked_vector& other) noexcept { + std::swap(_size, other._size); + std::swap(_capacity, other._capacity); + std::swap(_frags, other._frags); + other.update_generation(); + update_generation(); + } + + template + void push_back(E&& elem) { + maybe_add_capacity(); + _frags.back().push_back(std::forward(elem)); + ++_size; + update_generation(); + } + + template + T& emplace_back(Args&&... args) { + maybe_add_capacity(); + T& emplaced = _frags.back().emplace_back(std::forward(args)...); + ++_size; + update_generation(); + return emplaced; + } + + void pop_back() { + SEASTAR_ASSERT(_size > 0 && "Cannot pop from empty container"); + _frags.back().pop_back(); + --_size; + if (_frags.back().empty()) { + _frags.pop_back(); + _capacity -= std::min(calc_elems_per_frag(), _capacity); + } + update_generation(); + } + + /* + * Replacement for `erase(some_it, end())` but more efficient than n + * `pop_back()s` + */ + void pop_back_n(size_t n) { + SEASTAR_ASSERT( + _size >= n && "Cannot pop more than size() elements in container"); + + if (_size == n) { + clear(); + return; + } + + _size -= n; + + while (n >= _frags.back().size()) { + n -= _frags.back().size(); + _frags.pop_back(); + _capacity -= calc_elems_per_frag(); + } + + for (size_t i = 0; i < n; ++i) { + _frags.back().pop_back(); + } + update_generation(); + } + + const_reference at(size_t index) const { + static constexpr size_t elems_per_frag = calc_elems_per_frag(); + return _frags.at(index / elems_per_frag).at(index % elems_per_frag); + } + + reference at(size_t index) { + static constexpr size_t elems_per_frag = calc_elems_per_frag(); + return _frags.at(index / elems_per_frag).at(index % elems_per_frag); + } + + const_reference operator[](size_t index) const { + static constexpr size_t elems_per_frag = calc_elems_per_frag(); + return _frags[index / elems_per_frag][index % elems_per_frag]; + } + + reference operator[](size_t index) { + static constexpr size_t elems_per_frag = calc_elems_per_frag(); + return _frags[index / elems_per_frag][index % elems_per_frag]; + } + + const_reference front() const { return _frags.front().front(); } + const_reference back() const { return _frags.back().back(); } + reference front() { return _frags.front().front(); } + reference back() { return _frags.back().back(); } + bool empty() const noexcept { return _size == 0; } + size_t size() const noexcept { return _size; } + size_t capacity() const noexcept { return _capacity; } + + /** + * Requests the removal of unused capacity. + * + * If reallocation occurs, all iterators (including the end() iterator) and + * all references to the elements are invalidated. + */ + void shrink_to_fit() { + // Calling shrink to fix then modifying the container could result + // in allocations that overshoot our max_frag_bytes, except when + // we're managing the dynamic size of the first fragment. + if (_frags.size() == 1) { + auto& front = _frags.front(); + front.shrink_to_fit(); + _capacity = front.capacity(); + } + update_generation(); + } + + /** + * Increase the capacity of the vector (the total number of elements that + * the vector can hold without requiring reallocation) to a value that's + * greater or equal to new_cap. + * + * This method has the same guarantees as `std::vector::reserve`, that being + * calling reserve doesn't preserve pointer or iterator stability when + * new_cap is larger than the current capacity. There maybe cases where this + * class invalidates less than `std::vector`, but that should not be relied + * upon. + * + * Performance wise if you know the intended size of this structure is + * desired to call this to prevent costly reallocations when inserting + * elements. + */ + void reserve(size_t new_cap) { + static constexpr size_t elems_per_frag = calc_elems_per_frag(); + if (new_cap > _capacity) { + if (_frags.empty()) { + auto& frag = _frags.emplace_back(); + frag.reserve(std::min(elems_per_frag, new_cap)); + _capacity = frag.capacity(); + } else if (_frags.size() == 1) { + auto& frag = _frags.front(); + frag.reserve(std::min(elems_per_frag, new_cap)); + _capacity = frag.capacity(); + } + // We only reserve the first fragment as all fragments after the + // first are allocated at the maximum size, so we don't save + // anything in terms of reallocs after fully allocating the + // first fragment. In addition, due to cache locality, it's + // better to delay the allocations of those other fragments + // until they're going to be used. + } + update_generation(); + } + + bool operator==(const chunked_vector& o) const noexcept { + return o._frags == _frags; + } + + /** + * Returns the approximate in-memory size of this vector in bytes. + */ + size_t memory_size() const { + return (_frags.size() * sizeof(_frags[0])) + (_capacity * sizeof(T)); + } + + /** + * Returns the (maximum) number of elements in each fragment of this vector. + */ + static constexpr size_t elements_per_fragment() { + return calc_elems_per_frag(); + } + + static constexpr size_t max_frag_bytes() { return calc_max_frag_bytes(); } + + /** + * Remove all elements from the vector. + * + * Unlike std::vector, this also releases all the memory from + * the vector (since this vector already the same pointer + * and iterator stability guarantees that std::vector provides + * based on non-reallocation and capacity()). + */ + void clear() { + // do the swap dance to actually clear the memory held by the vector + std::vector>{}.swap(_frags); + _size = 0; + _capacity = 0; + update_generation(); + } + + template + class iter { + public: + using iterator_category = std::random_access_iterator_tag; + using iterator_concept = std::random_access_iterator_tag; + using value_type = T; + using difference_type = std::ptrdiff_t; + using pointer = std::conditional_t< + std::is_same_v, + bool, + std::conditional_t>; + using reference = std::conditional_t< + std::is_same_v, + bool, + std::conditional_t>; + + iter() = default; + + /** + * Conversion operator allowing iterator to be converted to + * const_iterator, as required by the general iterator contract. + */ + operator iter() const { // NOLINT(hicpp-explicit-conversions) + check_generation(); + iter ret; + ret._vec = _vec; + ret._index = _index; +#ifndef NDEBUG + ret._my_generation = _my_generation; +#endif + return ret; + } + + reference operator*() const { + check_generation(); + return _vec->operator[](_index); + } + pointer operator->() const { + check_generation(); + return &_vec->operator[](_index); + } + + iter& operator+=(ssize_t n) { + check_generation(); + _index += n; + return *this; + } + + iter& operator-=(ssize_t n) { + check_generation(); + _index -= n; + return *this; + } + + iter& operator++() { + check_generation(); + ++_index; + return *this; + } + + iter& operator--() { + check_generation(); + --_index; + return *this; + } + + iter operator++(int) { + check_generation(); + auto tmp = *this; + ++*this; + return tmp; + } + + iter operator--(int) { + check_generation(); + auto tmp = *this; + --*this; + return tmp; + } + + iter operator+(difference_type offset) const { + check_generation(); + return iter{*this} += offset; + } + + iter operator-(difference_type offset) const { + check_generation(); + return iter{*this} -= offset; + } + + reference operator[](difference_type offset) const { + check_generation(); + return *(*this + offset); + } + + bool operator==(const iter& o) const { + check_generation(); +#ifndef NDEBUG + SEASTAR_ASSERT( + _vec == o._vec + && "iterator compared with different chunked_vector"); +#endif + return std::tie(_index, _vec) == std::tie(o._index, o._vec); + }; + auto operator<=>(const iter& o) const { + check_generation(); +#ifndef NDEBUG + SEASTAR_ASSERT( + _vec == o._vec + && "iterator compared with different chunked_vector"); +#endif + return std::tie(_index, _vec) <=> std::tie(o._index, o._vec); + }; + + friend ssize_t operator-(const iter& a, const iter& b) { + return a._index - b._index; + } + + friend iter operator+(const difference_type& offset, const iter& i) { + return i + offset; + } + + private: + friend class chunked_vector; + using vec_type = std::conditional_t; + + iter(vec_type* vec, size_t index) + : _index(index) +#ifndef NDEBUG + , _my_generation(vec->_generation) +#endif + , _vec(vec) { + } + + inline void check_generation() const { +#ifndef NDEBUG + SEASTAR_ASSERT( + _vec->_generation == _my_generation + && "Attempting to use an invalidated iterator. The corresponding " + "chunked_vector container has been mutated since this " + "iterator was constructed."); +#endif + } + + size_t _index{}; +#ifndef NDEBUG + size_t _my_generation{}; +#endif + vec_type* _vec{}; + }; + + using const_iterator = iter; + using iterator = iter; + + using const_reverse_iterator = std::reverse_iterator; + using reverse_iterator = std::reverse_iterator; + + iterator begin() { return iterator(this, 0); } + iterator end() { return iterator(this, _size); } + + const_iterator begin() const { return const_iterator(this, 0); } + const_iterator end() const { return const_iterator(this, _size); } + + const_iterator cbegin() const { return const_iterator(this, 0); } + const_iterator cend() const { return const_iterator(this, _size); } + + reverse_iterator rbegin() { return reverse_iterator(end()); } + reverse_iterator rend() { return reverse_iterator(begin()); } + + const_reverse_iterator rbegin() const { + return const_reverse_iterator(end()); + } + const_reverse_iterator rend() const { + return const_reverse_iterator(begin()); + } + + const_reverse_iterator crbegin() const { + return const_reverse_iterator(end()); + } + const_reverse_iterator crend() const { + return const_reverse_iterator(begin()); + } + + /** + * @brief Erases all elements from begin to the end of the vector. + */ + void erase_to_end(const_iterator begin) { pop_back_n(cend() - begin); } + + template + static chunked_vector single(Args&&... args) { + chunked_vector v; + v.emplace_back(std::forward(args)...); + return v; + } + + friend std::ostream& + operator<<(std::ostream& os, const chunked_vector& v) { + fmt::print(os, "{}", v); + return os; + } + +private: + [[gnu::always_inline]] void maybe_add_capacity() { + if (_size == _capacity) [[unlikely]] { + add_capacity(); + } + } + + void add_capacity() { + static constexpr size_t elems_per_frag = calc_elems_per_frag(); + static_assert( + calc_max_frag_bytes() <= max_allocation_size, + "max size of a fragment must be <= 128KiB"); + if (_frags.size() == 1 && _frags.back().capacity() < elems_per_frag) { + auto& frag = _frags.back(); + auto new_cap = std::min(elems_per_frag, frag.capacity() * 2); + frag.reserve(new_cap); + _capacity = new_cap; + return; + } else if (_frags.empty()) { + // At least one element or 32 bytes worth of elements for small + // items. + static constexpr size_t initial_cap = std::max( + 1UL, 32UL / sizeof(T)); + _capacity = initial_cap; + _frags.emplace_back(_frags.get_allocator()).reserve(_capacity); + return; + } + _frags.emplace_back(_frags.get_allocator()).reserve(elems_per_frag); + _capacity += elems_per_frag; + } + + inline void update_generation() { +#ifndef NDEBUG + ++_generation; +#endif + } + +private: + friend class chunked_vector_validator; + chunked_vector(const chunked_vector&) noexcept = default; + + size_t _size{0}; + size_t _capacity{0}; + backing_type _frags; +#ifndef NDEBUG + // Add a generation number that is incremented on every mutation to catch + // invalidated iterator accesses. + size_t _generation{0}; +#endif +}; + +} // namespace seastar diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index b44c2b3b667..b80396201b9 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -321,6 +321,10 @@ seastar_add_test (chunked_fifo KIND BOOST SOURCES chunked_fifo_test.cc) +seastar_add_test (chunked_vector + KIND BOOST + SOURCES chunked_vector_test.cc) + seastar_add_test (linux_perf_event KIND BOOST SOURCES linux_perf_event_test.cc ../perf/linux_perf_event.cc) diff --git a/tests/unit/chunked_vector_test.cc b/tests/unit/chunked_vector_test.cc new file mode 100644 index 00000000000..2a46994d1da --- /dev/null +++ b/tests/unit/chunked_vector_test.cc @@ -0,0 +1,581 @@ +/* + * This file is open source software, licensed to you under the terms + * of the Apache License, Version 2.0 (the "License"). See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. You may not use this file except in compliance with the License. + * + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +/* + * Copyright 2024 Redpanda Data, Inc. + */ + +#define BOOST_TEST_MODULE chunked_vector + +#include +#include + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using seastar::chunked_vector; +using vec = chunked_vector; + +static_assert(std::random_access_iterator); +static_assert(std::random_access_iterator); +static_assert(std::ranges::random_access_range); +static_assert(std::ranges::random_access_range); + +namespace seastar { + +class chunked_vector_validator { +public: + // Perform an internal consistency check of the vector structure. Returns + // an empty string on success, otherwise a description of the inconsistency. + template + static std::string validate(const chunked_vector& v) { + if (v._size > v._capacity) { + return "size greater than capacity"; + } + if (v._size >= std::numeric_limits::max() / 2) { + return "size too big"; + } + if (v._capacity >= std::numeric_limits::max() / 2) { + return "capacity too big"; + } + size_t calc_size = 0, calc_cap = 0; + + for (size_t i = 0; i < v._frags.size(); ++i) { + auto& f = v._frags[i]; + + calc_size += f.size(); + calc_cap += f.capacity(); + + if (i + 1 < v._frags.size()) { + if (f.size() < v.elements_per_fragment()) { + return fmt::format( + "fragment {} is undersized ({} < {})", + i, + f.size(), + v.elements_per_fragment()); + } + } + if (f.capacity() > std::decay_t::max_frag_bytes()) { + return fmt::format( + "fragment {} capacity over max_frag_bytes ({})", i, calc_cap); + } + } + + if (calc_size != v.size()) { + return fmt::format( + "calculated size is wrong ({} != {})", calc_size, v.size()); + } + if (calc_cap != v._capacity) { + return fmt::format( + "calculated capacity is wrong ({} != {})", calc_cap, v._capacity); + } + return {}; + } +}; + +} // namespace seastar + +using seastar::chunked_vector_validator; + +namespace { + +// Minimal replacement for redpanda's random_generators used by these tests. +std::mt19937& test_rng() { + static std::mt19937 engine{std::random_device{}()}; + return engine; +} + +// Returns a value in the inclusive range [0, max]. +template +I get_int(I max) { + return std::uniform_int_distribution(0, max)(test_rng()); +} + +// Returns a value in the inclusive range [min, max]. +template +I get_int(I min, I max) { + return std::uniform_int_distribution(min, max)(test_rng()); +} + +#define ASSERT_VALID(v) \ + do { \ + auto err = chunked_vector_validator::validate(v); \ + BOOST_REQUIRE_MESSAGE(err.empty(), err); \ + } while (0) + +/** + * Proxy that applies a consistency check before deference + */ +template +struct checker { + using underlying = chunked_vector; + + static checker make(std::vector in) { + checker ret; + for (auto& e : in) { + ret->push_back(e); + } + return ret; + } + + underlying* operator->() { + auto err = chunked_vector_validator::validate(u); + if (!err.empty()) { + throw std::runtime_error(err); + } + return &u; + } + + underlying& get() { return *operator->(); } + + auto operator<=>(const checker&) const = default; + + underlying u; +}; + +template +void check_eq(chunked_vector& impl, const std::vector& shadow) { + BOOST_REQUIRE_MESSAGE( + std::equal(impl.begin(), impl.end(), shadow.begin(), shadow.end()), + "iterators not equal"); + BOOST_REQUIRE_EQUAL(impl.empty(), shadow.empty()); + BOOST_REQUIRE_EQUAL(impl.size(), shadow.size()); + ASSERT_VALID(impl); +} + +void push(chunked_vector& impl, std::vector& shadow, int count) { + for (int i = 0; i < count; ++i) { + shadow.push_back(i); + impl.push_back(i); + check_eq(impl, shadow); + } +} + +void pop(chunked_vector& impl, std::vector& shadow, int count) { + for (int i = 0; i < count; ++i) { + shadow.pop_back(); + impl.pop_back(); + check_eq(impl, shadow); + } +} + +BOOST_AUTO_TEST_CASE(PushPop) { + std::vector shadow; + chunked_vector impl; + BOOST_REQUIRE(impl.empty()); + push(impl, shadow, 2500); + pop(impl, shadow, 1234); + push(impl, shadow, 123); + pop(impl, shadow, 1389); + BOOST_REQUIRE(std::ranges::equal(impl, shadow)); + BOOST_REQUIRE_EQUAL(impl.empty(), shadow.empty()); + BOOST_REQUIRE_EQUAL(impl.size(), shadow.size()); +} + +BOOST_AUTO_TEST_CASE(Iterator) { + std::vector shadow; + chunked_vector impl; + + push(impl, shadow, 2000); + for (int i = 0; i < 6000; i++) { + auto val = get_int(0, 4000); + + auto it = std::lower_bound(shadow.begin(), shadow.end(), val); + auto it2 = std::lower_bound(impl.begin(), impl.end(), val); + BOOST_REQUIRE_EQUAL(it == shadow.end(), it2 == impl.end()); + BOOST_REQUIRE_EQUAL( + std::distance(shadow.begin(), it), std::distance(impl.begin(), it2)); + BOOST_REQUIRE_EQUAL( + std::distance(it, shadow.end()), std::distance(it2, impl.end())); + + it = std::upper_bound(shadow.begin(), shadow.end(), val); + it2 = std::upper_bound(impl.begin(), impl.end(), val); + BOOST_REQUIRE_EQUAL(it == shadow.end(), it2 == impl.end()); + BOOST_REQUIRE_EQUAL( + std::distance(shadow.begin(), it), std::distance(impl.begin(), it2)); + BOOST_REQUIRE_EQUAL( + std::distance(it, shadow.end()), std::distance(it2, impl.end())); + + it = std::find(shadow.begin(), shadow.end(), val); + it2 = std::find(impl.begin(), impl.end(), val); + BOOST_REQUIRE_EQUAL(it == shadow.end(), it2 == impl.end()); + BOOST_REQUIRE_EQUAL( + std::distance(shadow.begin(), it), std::distance(impl.begin(), it2)); + BOOST_REQUIRE_EQUAL( + std::distance(it, shadow.end()), std::distance(it2, impl.end())); + } +} + +BOOST_AUTO_TEST_CASE(IteratorTypes) { + using vtype = chunked_vector; + using iter = vtype::iterator; + using citer = vtype::const_iterator; + using riter = vtype::reverse_iterator; + using criter = vtype::const_reverse_iterator; + auto v = vtype{}; + + // const and non-const iterators should be different! + static_assert(!std::is_same_v); + static_assert(std::is_same_v); + static_assert( + std::is_same_v); + static_assert(std::is_same_v< + decltype(std::as_const(v).begin()), + decltype(v)::const_iterator>); + + // const and non-const reverse iterators should be different! + static_assert(!std::is_same_v); + static_assert(std::is_same_v); + static_assert( + std:: + is_same_v); + static_assert(std::is_same_v< + decltype(std::as_const(v).rbegin()), + decltype(v)::const_reverse_iterator>); +} + +struct foo { + int a; + bool operator==(const foo&) const = default; +}; + +BOOST_AUTO_TEST_CASE(IteratorAccess) { + using vtype = chunked_vector; + auto vec = vtype{}; + vec.push_back(foo{2}); + + BOOST_REQUIRE(*vec.begin() == foo{2}); + BOOST_REQUIRE_EQUAL((*vec.begin()).a, 2); + BOOST_REQUIRE_EQUAL(vec.begin()->a, 2); +} + +BOOST_AUTO_TEST_CASE(ReverseIteratorAccess) { + using vtype = chunked_vector; + auto vec = vtype{}; + vec.push_back(foo{2}); + vec.push_back(foo{3}); + + BOOST_REQUIRE(*vec.rbegin() == foo{3}); + BOOST_REQUIRE_EQUAL((*vec.rbegin()).a, 3); + BOOST_REQUIRE_EQUAL(vec.rbegin()->a, 3); +} + +BOOST_AUTO_TEST_CASE(IteratorArithmetic) { + auto v = checker::make({0, 1, 2, 3}); + + auto b = v->begin(); + + BOOST_REQUIRE_EQUAL(*(b + 0), 0); + BOOST_REQUIRE_EQUAL(*(b + 1), 1); + BOOST_REQUIRE_EQUAL(*(b + 2), 2); + BOOST_REQUIRE_EQUAL(*(b + 3), 3); + + auto e = v->end(); + + BOOST_REQUIRE((e - 0) == e); + + BOOST_REQUIRE_EQUAL(*(e - 1), 3); + BOOST_REQUIRE_EQUAL(*(e - 2), 2); + BOOST_REQUIRE_EQUAL(*(e - 3), 1); + BOOST_REQUIRE_EQUAL(*(e - 4), 0); +} + +BOOST_AUTO_TEST_CASE(ReverseIteratorArithmetic) { + auto v = checker::make({0, 1, 2, 3}); + + auto b = v->rbegin(); + + BOOST_REQUIRE_EQUAL(*(b + 0), 3); + BOOST_REQUIRE_EQUAL(*(b + 1), 2); + BOOST_REQUIRE_EQUAL(*(b + 2), 1); + BOOST_REQUIRE_EQUAL(*(b + 3), 0); + + auto e = v->rend(); + + BOOST_REQUIRE((e - 0) == e); + + BOOST_REQUIRE_EQUAL(*(e - 1), 0); + BOOST_REQUIRE_EQUAL(*(e - 2), 1); + BOOST_REQUIRE_EQUAL(*(e - 3), 2); + BOOST_REQUIRE_EQUAL(*(e - 4), 3); +} + +BOOST_AUTO_TEST_CASE(IteratorCmp) { + auto v = checker::make({0, 1, 2, 3}); + + auto b = v->begin(); + + BOOST_REQUIRE(b == b); + BOOST_REQUIRE(b <= b); + BOOST_REQUIRE(!(b < b)); + BOOST_REQUIRE(!(b > b)); + BOOST_REQUIRE(!(b != b)); + + auto b1 = b + 1; + + BOOST_REQUIRE(b <= b1); + BOOST_REQUIRE(b < b1); + BOOST_REQUIRE(b1 >= b); + BOOST_REQUIRE(b1 > b); + BOOST_REQUIRE(b1 != b); +} + +BOOST_AUTO_TEST_CASE(EmptyAfterMove) { + // Checks that post move, the source vector is empty(). + // This is inline with std::vector guarantees. + chunked_vector v1; + v1.push_back(1); + BOOST_REQUIRE(!v1.empty()); + + auto v2 = std::move(v1); + // NOLINTNEXTLINE(bugprone-use-after-move) + BOOST_REQUIRE(v1.empty()); + BOOST_REQUIRE(v1.begin() == v1.end()); + + auto v3(std::move(v2)); + // NOLINTNEXTLINE(bugprone-use-after-move) + BOOST_REQUIRE(v2.empty()); + BOOST_REQUIRE(v2.begin() == v2.end()); +} + +BOOST_AUTO_TEST_CASE(Sort) { + vec v; + v.push_back(3); + v.push_back(2); + v.push_back(1); + + std::sort(v.begin(), v.end()); + + BOOST_REQUIRE(std::ranges::equal(v, std::vector{1, 2, 3})); +} + +BOOST_AUTO_TEST_CASE(Heap) { + vec v{1, 2, 3}; + std::ranges::make_heap(v); + BOOST_REQUIRE(std::ranges::is_heap(v)); +} + +BOOST_AUTO_TEST_CASE(Clear) { + auto v = checker::make({}); + BOOST_REQUIRE_EQUAL(v->size(), 0u); + v->push_back(3); + BOOST_REQUIRE_EQUAL(v->size(), 1u); + v->push_back(2); + BOOST_REQUIRE_EQUAL(v->size(), 2u); + v->push_back(1); + BOOST_REQUIRE_EQUAL(v->size(), 3u); + v->clear(); + BOOST_REQUIRE(v.get() == vec{}); + BOOST_REQUIRE_EQUAL(v->size(), 0u); + BOOST_REQUIRE(v.get().empty()); + v = checker::make({5, 5, 5, 5}); + BOOST_REQUIRE_EQUAL(v->size(), 4u); +} + +BOOST_AUTO_TEST_CASE(PopBackN) { + const int elements = 6; + for (int i = 0; i <= elements; ++i) { + std::vector start_values(elements); + std::iota(start_values.begin(), start_values.end(), 0); + auto vec = checker::make(start_values); + + vec->pop_back_n(i); + + std::vector expected_values(elements - i); + std::iota(expected_values.begin(), expected_values.end(), 0); + BOOST_REQUIRE_EQUAL(vec->size(), expected_values.size()); + BOOST_REQUIRE(std::ranges::equal(vec.get(), expected_values)); + + if (elements - i > 0) { + BOOST_REQUIRE_EQUAL(vec->back(), expected_values.back()); + } + } +} + +BOOST_AUTO_TEST_CASE(EraseToEnd) { + chunked_vector v; + + ASSERT_VALID(v); + + v.erase_to_end(v.begin()); + ASSERT_VALID(v); + BOOST_REQUIRE_EQUAL(v.size(), 0u); + + v.erase_to_end(v.end()); + ASSERT_VALID(v); + BOOST_REQUIRE_EQUAL(v.size(), 0u); + + v.push_back(0); + v.erase_to_end(v.end()); + BOOST_REQUIRE(std::ranges::equal(v, std::vector{0})); + v.erase_to_end(v.begin()); + BOOST_REQUIRE(v.empty()); + + v.push_back(0); + v.push_back(1); + v.push_back(2); + v.push_back(3); + BOOST_REQUIRE(std::ranges::equal(v, std::vector{0, 1, 2, 3})); + v.erase_to_end(v.begin() + 1); + BOOST_REQUIRE(std::ranges::equal(v, std::vector{0})); +} + +BOOST_AUTO_TEST_CASE(FromIterRangeConstructor) { + std::vector vals{1, 2, 3}; + + chunked_vector fv(vals.begin(), vals.end()); + + ASSERT_VALID(fv); + BOOST_REQUIRE(std::ranges::equal(fv, std::vector{1, 2, 3})); +} + +BOOST_AUTO_TEST_CASE(FromInitializerListConstructor) { + { + chunked_vector fv({}); + + ASSERT_VALID(fv); + BOOST_REQUIRE(fv.empty()); + } + + { + chunked_vector fv({1, 2, 3}); + + ASSERT_VALID(fv); + BOOST_REQUIRE(std::ranges::equal(fv, std::vector{1, 2, 3})); + } + + { + chunked_vector fv({1, 2, 3}); + + ASSERT_VALID(fv); + BOOST_REQUIRE(std::ranges::equal(fv, std::vector{1, 2, 3})); + // chunked_vector should have a "tight" capacity when constructed + // from a list + BOOST_REQUIRE_EQUAL(fv.capacity(), 3u); + } +} + +BOOST_AUTO_TEST_CASE(ChunkedVectorPushPop) { + for (int i = 0; i < 100; ++i) { + chunked_vector vec; + for (size_t j = 0; j < vec.elements_per_fragment(); ++j) { + bool push_back = vec.empty() || bool(get_int(1)); + if (push_back) { + vec.push_back(j); + } else { + vec.pop_back(); + } + ASSERT_VALID(vec); + } + } +} + +BOOST_AUTO_TEST_CASE(ChunkedVectorPushPopN) { + for (int i = 0; i < 100; ++i) { + chunked_vector vec; + for (size_t j = 0; j < vec.elements_per_fragment(); ++j) { + // Slight preference to make larger vectors because we could be + // popping back multiple + switch (get_int(4)) { + case 0: + case 1: + case 2: + vec.push_back(j); + break; + case 3: + vec.pop_back_n(get_int(vec.size())); + break; + case 4: + vec.erase_to_end(vec.begin() + get_int(vec.size())); + break; + } + + ASSERT_VALID(vec); + } + } +} + +BOOST_AUTO_TEST_CASE(FirstChunkCapacityDoubles) { + chunked_vector vec; + for (size_t i = 0; i < vec.elements_per_fragment(); ++i) { + vec.push_back(i); + ASSERT_VALID(vec); + } +} + +BOOST_AUTO_TEST_CASE(ReserveAndPushBack) { + chunked_vector vec; + vec.reserve(vec.elements_per_fragment()); + vec.push_back(-1); + int* initial_location = &vec.front(); + for (size_t i = 0; i < vec.elements_per_fragment(); ++i) { + vec.push_back(i); + BOOST_REQUIRE_EQUAL(initial_location, &vec.front()); + } +} + +BOOST_AUTO_TEST_CASE(Reserve) { + chunked_vector growing_vec; + for (size_t i = 0; i < chunked_vector::elements_per_fragment(); + ++i) { + growing_vec.reserve(i); + BOOST_REQUIRE_EQUAL(growing_vec.capacity(), i); + ASSERT_VALID(growing_vec); + // Also ensure "jumping" to that reserved size does the right thing. + chunked_vector new_vec; + new_vec.reserve(i); + BOOST_REQUIRE_EQUAL(new_vec.capacity(), i); + ASSERT_VALID(new_vec); + } +} + +BOOST_AUTO_TEST_CASE(ShrinkToFit) { + chunked_vector vec; + vec.reserve(32); + ASSERT_VALID(vec); + for (int i = 0; i < 10; ++i) { + vec.push_back(1); + ASSERT_VALID(vec); + } + vec.shrink_to_fit(); + ASSERT_VALID(vec); + BOOST_REQUIRE_EQUAL(vec.capacity(), 10u); +} + +BOOST_AUTO_TEST_CASE(InPlaceSingleElement) { + auto v = chunked_vector>::single( + "a2", 3); + BOOST_REQUIRE_EQUAL(v.size(), 1u); + BOOST_REQUIRE(v[0].first == "a2"); + BOOST_REQUIRE_EQUAL(v[0].second, 3); +} + +} // namespace From 4f04909ce7b81994bb43cbf8228df1ddc4e879c9 Mon Sep 17 00:00:00 2001 From: Willem Kaufmann Date: Tue, 23 Jun 2026 11:21:10 -0400 Subject: [PATCH 4/5] core: add chunked_hash_map container Port chunked_hash_map/chunked_hash_set from Redpanda: ankerl's segmented hash map/set backed by chunked_vector storage, suited to maps that grow large (scaling with partitions or topics) without large contiguous allocations. Hashing dispatches to absl::Hash when a type provides AbslHashValue, otherwise to unordered_dense's hash. Adapted to the seastar namespace, with a Boost.Test port of the test suite. --- CMakeLists.txt | 1 + include/seastar/core/chunked_hash_map.hh | 262 +++++++++++++++++++++++ tests/unit/CMakeLists.txt | 4 + tests/unit/chunked_hash_map_test.cc | 106 +++++++++ 4 files changed, 373 insertions(+) create mode 100644 include/seastar/core/chunked_hash_map.hh create mode 100644 tests/unit/chunked_hash_map_test.cc diff --git a/CMakeLists.txt b/CMakeLists.txt index 7c4173038e6..bebdf7834bb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -579,6 +579,7 @@ add_library (seastar include/seastar/core/cacheline.hh include/seastar/core/checked_ptr.hh include/seastar/core/chunked_fifo.hh + include/seastar/core/chunked_hash_map.hh include/seastar/core/chunked_vector.hh include/seastar/core/circular_buffer.hh include/seastar/core/circular_buffer_fixed_capacity.hh diff --git a/include/seastar/core/chunked_hash_map.hh b/include/seastar/core/chunked_hash_map.hh new file mode 100644 index 00000000000..d5f5137ea06 --- /dev/null +++ b/include/seastar/core/chunked_hash_map.hh @@ -0,0 +1,262 @@ +/* + * This file is open source software, licensed to you under the terms + * of the Apache License, Version 2.0 (the "License"). See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. You may not use this file except in compliance with the License. + * + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +/* + * Copyright 2024 Redpanda Data, Inc. + */ +#pragma once + +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace seastar { + +namespace internal { + +template +concept has_absl_hash = requires(T val) { + { AbslHashValue(std::declval(), val) }; +}; + +/// Wrapper around absl::Hash that disables the extra hash mixing in +/// unordered_dense +template +struct avalanching_absl_hash { + // absl always hash mixes itself so no need to do it again + using is_avalanching = void; + + auto operator()(const T& x) const noexcept -> uint64_t { + return absl::Hash()(x); + } +}; + +} // namespace internal + +/** + * @brief A hash map that uses a chunked vector as the underlying storage. + * + * Use when the hash map is expected to have a large number of elements (e.g.: + * scales with partitions or topics). Performance wise it's equal to the abseil + * hashmaps. + * + * NB: References and iterators are not stable across insertions and deletions. + * + * Both std::hash and abseil's AbslHashValue are supported. We dispatch to the + * latter if available. Given AbslHashValue also supports std::hash we could + * also unconditionally dispatch to it. However, absl's hash mixing seems more + * extensive (and hence less performant) so we only do that when needed. + * + * For more info please see + * https://github.com/martinus/unordered_dense/?tab=readme-ov-file#1-overview + */ +template< + typename Key, + typename Value, + typename Hash = std::conditional_t< + internal::has_absl_hash, + internal::avalanching_absl_hash, + ankerl::unordered_dense::hash>, + typename EqualTo = std::equal_to> +using chunked_hash_map = ankerl::unordered_dense::segmented_map< + Key, + Value, + Hash, + EqualTo, + chunked_vector>, + ankerl::unordered_dense::bucket_type::standard, + chunked_vector>; + +namespace internal { +template +struct chunked_hash_map_from_range_impl { + using value_t = std::ranges::range_value_t>; + using first_t = typename value_t::first_type; + using second_t = typename value_t::second_type; + using ret_t = chunked_hash_map; +}; +} // namespace internal + +// reserves if range size is known +template +typename internal::chunked_hash_map_from_range_impl::ret_t +chunked_hash_map_from_range(Range&& range) { + size_t size = 0; + if constexpr (std::ranges::sized_range) { + size = std::ranges::size(range); + } + return {std::ranges::begin(range), std::ranges::end(range), size}; +}; + +/** + * @brief A set counterpart of chunked_hash_map (uses a chunked vector as the + * underlying storage). + */ +template< + typename Key, + typename Hash = std::conditional_t< + internal::has_absl_hash, + internal::avalanching_absl_hash, + ankerl::unordered_dense::hash>, + typename EqualTo = std::equal_to> +using chunked_hash_set = ankerl::unordered_dense::segmented_set< + Key, + Hash, + EqualTo, + chunked_vector, + ankerl::unordered_dense::bucket_type::standard, + chunked_vector>; + +/// Returns a lower bound on the memory currently being held by `m`. +template< + typename K, + typename V, + typename Hash = std::conditional_t< + internal::has_absl_hash, + internal::avalanching_absl_hash, + ankerl::unordered_dense::hash>, + typename EqualTo = std::equal_to> +size_t +memory_usage_lower_bound(const chunked_hash_map& m) { + return m.bucket_count() + * sizeof(typename chunked_hash_map::bucket_type) + + m.values().capacity() * sizeof(m.values()[0]); +} + +} // namespace seastar + +// Disable fmt's range formatter for chunked_hash_map/set to use our custom +// formatters instead. +template< + typename K, + typename V, + typename H, + typename E, + typename AllocatorOrContainer, + typename Bucket, + typename BucketContainer, + bool IsSegmented, + typename Char> +struct fmt::range_format_kind< + ankerl::unordered_dense::detail::table< + K, + V, + H, + E, + AllocatorOrContainer, + Bucket, + BucketContainer, + IsSegmented>, + Char> + : std::integral_constant {}; + +template< + typename K, + typename V, + typename H, + typename E, + typename AllocatorOrContainer, + typename Bucket, + typename BucketContainer, + bool IsSegmented> +struct fmt::formatter> { + using type = ankerl::unordered_dense::detail::table< + K, + V, + H, + E, + AllocatorOrContainer, + Bucket, + BucketContainer, + IsSegmented>; + + constexpr auto parse(format_parse_context& ctx) const { + return ctx.begin(); + } + + template + typename FormatContext::iterator + format(const type& map, FormatContext& ctx) const { + auto out = ctx.out(); + out = fmt::format_to(out, "["); + auto it = map.begin(); + if (it != map.end()) { + out = fmt::format_to(out, "{{{} -> {}}}", it->first, it->second); + for (++it; it != map.end(); ++it) { + out = fmt::format_to( + out, ", {{{} -> {}}}", it->first, it->second); + } + } + return fmt::format_to(out, "]"); + } +}; + +template< + typename K, + typename H, + typename E, + typename AllocatorOrContainer, + typename Bucket, + typename BucketContainer, + bool IsSegmented> +struct fmt::formatter> { + using type = ankerl::unordered_dense::detail::table< + K, + void, + H, + E, + AllocatorOrContainer, + Bucket, + BucketContainer, + IsSegmented>; + + constexpr auto parse(format_parse_context& ctx) const { + return ctx.begin(); + } + + template + typename FormatContext::iterator + format(const type& set, FormatContext& ctx) const { + return fmt::format_to(ctx.out(), "[{}]", fmt::join(set, ",")); + } +}; diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index b80396201b9..d4e0de84e68 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -325,6 +325,10 @@ seastar_add_test (chunked_vector KIND BOOST SOURCES chunked_vector_test.cc) +seastar_add_test (chunked_hash_map + KIND BOOST + SOURCES chunked_hash_map_test.cc) + seastar_add_test (linux_perf_event KIND BOOST SOURCES linux_perf_event_test.cc ../perf/linux_perf_event.cc) diff --git a/tests/unit/chunked_hash_map_test.cc b/tests/unit/chunked_hash_map_test.cc new file mode 100644 index 00000000000..0e76f3023f6 --- /dev/null +++ b/tests/unit/chunked_hash_map_test.cc @@ -0,0 +1,106 @@ +/* + * This file is open source software, licensed to you under the terms + * of the Apache License, Version 2.0 (the "License"). See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. You may not use this file except in compliance with the License. + * + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +/* + * Copyright 2024 Redpanda Data, Inc. + */ + +#define BOOST_TEST_MODULE chunked_hash_map + +#include + +#include + +#include +#include +#include +#include +#include + +using seastar::chunked_hash_map; +using seastar::chunked_hash_map_from_range; + +struct foo_with_std_hash { + int a; + int b; + auto operator<=>(const foo_with_std_hash&) const = default; +}; + +namespace std { + +template<> +struct hash { + size_t operator()(const foo_with_std_hash& x) const { + return std::hash()(x.a + x.b); + } +}; + +} // namespace std + +struct foo_with_absl_hash { + int a; + int b; + + auto operator<=>(const foo_with_absl_hash&) const = default; + + template + friend H AbslHashValue(H h, const foo_with_absl_hash& x) { + return H::combine(std::move(h), x.a, x.b); + } +}; + +BOOST_AUTO_TEST_CASE(basic_compile_std_hash) { + chunked_hash_map map; + map[{1, 2}] = 2; + BOOST_REQUIRE_EQUAL(map.size(), 1u); +} + +BOOST_AUTO_TEST_CASE(basic_compile_absl_hash) { + static_assert(seastar::internal::has_absl_hash); + chunked_hash_map map; + map[{1, 2}] = 2; + BOOST_REQUIRE_EQUAL(map.size(), 1u); +} + +BOOST_AUTO_TEST_CASE(test_move_assignment) { + chunked_hash_map map; + chunked_hash_map other_map; + other_map = std::move(map); +} + +BOOST_AUTO_TEST_CASE(from_range_vector) { + std::vector> input{{1, 10}, {2, 20}, {3, 30}}; + auto map = chunked_hash_map_from_range(input); + chunked_hash_map expected{{1, 10}, {2, 20}, {3, 30}}; + BOOST_REQUIRE(map == expected); +} + +BOOST_AUTO_TEST_CASE(from_range_list) { + std::list> input{ + {"one", 1}, {"two", 2}, {"three", 3}}; + auto map = chunked_hash_map_from_range(input); + chunked_hash_map expected{ + {"one", 1}, {"two", 2}, {"three", 3}}; + BOOST_REQUIRE(map == expected); +} + +BOOST_AUTO_TEST_CASE(from_range_array) { + std::array, 2> input{{{1, "one"}, {2, "two"}}}; + auto map = chunked_hash_map_from_range(input); + chunked_hash_map expected{{1, "one"}, {2, "two"}}; + BOOST_REQUIRE(map == expected); +} From 8b2da9c75902b5d894e0deb28ca123554e644389 Mon Sep 17 00:00:00 2001 From: Willem Kaufmann Date: Tue, 23 Jun 2026 11:21:18 -0400 Subject: [PATCH 5/5] `core`: use `chunked_hash_map` for label aggregation Replace the std::unordered_map backing metric_aggregate_by_labels with chunked_hash_map so per-label aggregation storage grows without large contiguous allocations when exporting many metric series. --- src/core/prometheus-impl.hh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/prometheus-impl.hh b/src/core/prometheus-impl.hh index 8011fbba58c..158e24f1f56 100644 --- a/src/core/prometheus-impl.hh +++ b/src/core/prometheus-impl.hh @@ -20,11 +20,11 @@ #include #include +#include #include #include #include -#include #include #include @@ -113,7 +113,7 @@ class metric_aggregate_by_labels { public: using label_list_type = std::vector; - using map_type = std::unordered_map; + using map_type = chunked_hash_map; const label_list_type& _labels_to_aggregate_by; label_key scratch_key; map_type _values;