Skip to content
Merged
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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Clang >= 22 and GCC >= 15.
| `vector` | Contiguous growable sequence. |
| `small_vector` | Same, holding its first `N` elements inline and spilling past them. |
| `inline_vector` | Same, bounded at `N` inline elements; allocates nothing, ever, and works at compile time. |
| `constexpr_inline_vector` | Same, for an element type that is `constexpr` default-constructible but not trivially so; default-initialises its slots to get there. |
| `fixed_vector` | Same, bounded at `N` with the slots on a resource instead of inline. |
| `sparse_set` | Dense-packed id set, O(1) insert / erase / contains, contiguous iteration. |
| `sparse_map` | `sparse_set` plus a payload per id; `keys()` and `values()` stay index-aligned. |
Expand Down Expand Up @@ -52,13 +53,16 @@ libmem::small_vector<hit, 8> hits{}; // allocates nothing until
`reserve` returns `bool`, and nothing throws `std::bad_alloc`. No container is
implicitly copyable either, since a copy constructor could not report the
failure; copying is explicit through `clone()` and `try_clone()`. Everything
moves, inline buffers included, and every move is `noexcept`. See
moves, inline buffers included, and every move is `noexcept` unless the element
type's own default constructor can throw. See
[docs/containers.md](docs/containers.md).

**The inline containers work at compile time.** `inline_vector`, `sparse_set`
and `sparse_map` over inline storage constant-evaluate end to end for trivially
default-constructible and trivially destructible element types, at no cost in
size or alignment.
size or alignment. `constexpr_inline_vector` opts an element type whose default
constructor is `constexpr` but not trivial into the same thing, for the price of
running it once per slot on every such vector built, moved or cloned.

**It interoperates both ways.** A `std::pmr::polymorphic_allocator` can back a
libmem container, and `resource_allocator` turns any libmem resource into a
Expand Down
8 changes: 8 additions & 0 deletions docs/complexity.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,12 @@ element type allows it, so `sizeof` and `alignof` are identical on both branches
the array is never zeroed. A container that constant-evaluates costs exactly what
the same container costs at run time.

`constexpr_inline_storage` is the one exception, and the reason it is opt-in rather
than a wider trait: it default-initialises its slots, so building one runs `N`
default constructors where `inline_storage` runs none. `sizeof` and `alignof` are
unchanged, but the `N` is per storage object, not per program: a move and a clone
each build a destination and so each pay it again, whatever the element count. A
1024-slot vector holding two elements runs 1024 default constructors to be
constructed, 1024 more to be moved, and 1024 more to be cloned.

See [containers.md](containers.md#constant-evaluation) for which containers qualify.
28 changes: 26 additions & 2 deletions docs/containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,11 @@ they had set. `std::vector<int>{4}` sets the same trap; the difference is that
up much later as growth that was supposed to be bounded.

Every container moves, inline-backed ones included, and every move is `noexcept`.
Where the storage cannot transfer its slots the move relocates the elements and
empties the source, exactly as `std::array` does. That costs O(size) rather than
The one exception is a `constexpr_inline_storage` over an element type whose
default constructor can throw: relocating builds the destination's slots, so that
move is `noexcept` only where building them is, and it propagates rather than
terminating. Where the storage cannot transfer its slots the move relocates the
elements and empties the source, exactly as `std::array` does. That costs O(size) rather than
O(1), which is what the container-level `relocatable` constant now reports: a cost,
not an availability. Non-movable containers were a standing tax on generic code,
so the only non-movable thing left is `spsc_ring`, deliberately, since a
Expand Down Expand Up @@ -248,6 +251,27 @@ lets that array be left trivially default-initialised inside a `constexpr`
constructor, so nothing is zeroed at runtime and `sizeof` is unchanged.
`inline_storage<T, N>::constexpr_usable` reports which branch a `T` takes.

A `T` that is `constexpr` default-constructible but not *trivially* so, which is
any type with a member initialiser or a user-provided `constexpr` constructor, is
not shut out; it has to ask. `constexpr_inline_vector<T, N>`, or
`constexpr_inline_storage<T, N>` under a sparse container, holds the same `T[N]`
and default-initialises it:

```cpp
struct vec2 { float x{}, y{}; };

constexpr float folded = [] {
libmem::constexpr_inline_vector<vec2, 8> v{};
v.push_back(vec2{1.0F, 2.0F});
return v[0].x + v[0].y;
}();
static_assert(folded == 3.0F);
```

The `N` default constructions that costs, on every such container built, moved or
cloned, are why it is a separate name rather than a wider trait;
[docs/storage.md](storage.md) has the trade in full.

Raw bytes are the obstacle, not a missing keyword: `reinterpret_cast` is forbidden
during constant evaluation, so a byte array can never be viewed as a `T*`. The
single-member-union trick that constexpr `std::vector` uses does not help either,
Expand Down
31 changes: 31 additions & 0 deletions docs/storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ touching its own logic.
| `fixed_storage<T, N, R>` | compile-time | from a `memory_resource` |
| `dynamic_storage<T, R>` | runtime, geometric growth | from a `memory_resource` |
| `small_storage<T, N, R>` | runtime, geometric growth | inside the object for the first `N`, then from a `memory_resource` |
| `constexpr_inline_storage<T, N>` | compile-time | inside the object, default-initialised |

```cpp
libmem::spsc_ring<command, 64> small{}; // slots inline
Expand Down Expand Up @@ -60,6 +61,36 @@ The resource-backed kinds cannot follow, for an unrelated reason: they reach
blessed for constant evaluation. Their members carry `constexpr` but can only ever
run at run time.

## Opting in: `constexpr_inline_storage`

That table is a property of `T`, not a choice, and it is stricter than constant
evaluation itself needs. A `T` whose default constructor is `constexpr` but not
trivial (an aggregate with member initialisers, a user-provided `constexpr`
constructor) can hold a real `T[N]` perfectly well. What it cannot hold is an
*uninitialised* one, which is the only thing the trait is protecting.

`constexpr_inline_storage<T, N>` makes that trade, named at the call site:

```cpp
struct vec2 { float x{}, y{}; }; // not trivially default constructible

libmem::inline_vector<vec2, 8> plain{}; // raw bytes, run time only
libmem::constexpr_inline_vector<vec2, 8> folded{}; // vec2[8], constant-evaluable
```

The cost is `N` default constructions every time a storage is built, for slots
nobody has used yet, which is the work `inline_storage` exists to avoid. Every
storage object pays it, so a move and a clone each pay it again over the
destination's slots, independently of how many elements are live. Hence a separate
name rather than a relaxed trait: worth paying when it is asked for, not worth
paying silently. Capacity, alignment and `sizeof` are unchanged, and for a
trivially default constructible `T` the two are the same thing in all but name.

`T` must still be trivially destructible. The slot array destroys its elements, so
a real destructor would run a second time over what the container already
destroyed. `rebind<U>` carries both requirements, which is what a `sparse_map` over
this storage applies to its mapped type.

## Resources

`memory_resource` is the single allocation interface: `allocate(size)` and
Expand Down
7 changes: 7 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ being as far as atomics allow. The non-trivial fallback is covered too: a
`std::string` element takes the byte-array branch, which cannot constant-evaluate but
must still move and clone correctly at run time.

The opt-in storage is pinned the same way, over both shapes the default trait turns
away: `constexpr_inline_vector` runs the same erase-and-clone round trip over an
aggregate with member initialisers and over a user-provided `constexpr` constructor,
and a `sparse_map` over `constexpr_inline_storage` does it with such a type as its
payload. What it costs is a runtime test rather than a `static_assert`, since
counting the default constructions is exactly what a `static_assert` cannot do.

Two helper types carry most of the weight. A counting payload proves no leaks and
no double destroys; a payload whose move throws once a budget runs out is what
actually instantiates the rollback branch of `relocate_grow`, which every
Expand Down
2 changes: 1 addition & 1 deletion src/libmem/libmem.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* - **Resources**: `default_resource`, `resource_ref`, `allocator_resource`, `threshold_policy`.
* - **Interop**: `resource_allocator`, a standard Allocator over any libmem resource.
* - **Identifiers**: `regular_indexable_id`, `null_id_v`, `to_index`.
* - **Storage**: `inline_storage`, `fixed_storage`, `dynamic_storage`, `small_storage`, `relocate_grow`.
* - **Storage**: `inline_storage`, `constexpr_inline_storage`, `fixed_storage`, `dynamic_storage`, `small_storage`, `relocate_grow`.
* - **Sparse index**: `flat_sparse_index`, `paged_sparse_index`.
* - **Allocators**: `slab`, `multislab`, `arena`, `typed_arena`.
* - **Containers**: `pool`, `spsc_ring`, `vector`, `small_vector`, `sparse_set`, `sparse_map`.
Expand Down
5 changes: 4 additions & 1 deletion src/libmem/sparse_index.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,10 @@ public:
static constexpr bool growable{growable_storage<Storage>};
static constexpr bool relocatable{Storage::relocatable};

constexpr flat_sparse_index()
/* `fill_from` is noexcept, so the storage's own construction is the only thing
* here that can throw. Spelled out because a container's move constructor
* default-constructs one of these and reports its own noexcept from it. */
constexpr flat_sparse_index() noexcept(std::is_nothrow_default_constructible_v<Storage>)
requires std::default_initializable<Storage>
{
fill_from(0);
Expand Down
51 changes: 43 additions & 8 deletions src/libmem/sparse_set.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@
* - `inline_storage<Id, N>` gives a fixed set that allocates nothing, holding at
* most `N` ids drawn from `[0, N)` (the rebound sparse array has `N` slots
* too). `insert` reports failure instead of growing.
* - `constexpr_inline_storage<Id, N>` is that same fixed set with the slots
* default-initialised, which is what keeps a map constant-evaluable over a
* mapped type that is not trivially default constructible.
* - `fixed_storage<Id, N, R>` is the same fixed extent with the slots on a
* resource.
*
Expand Down Expand Up @@ -236,11 +239,14 @@ public:
/**
* @brief Inline-backed set: the sparse index drains and the dense ids are relocated one at a time.
*
* @note Requires a non-throwing id move, which keeps every move `noexcept` and
* leaves no half-moved state to unwind: the sparse index is drained in
* the mem-initialiser, before the dense ids are touched.
* @note Requires a non-throwing id move, which leaves no half-moved state to
* unwind: the sparse index is drained in the mem-initialiser, before the
* dense ids are touched.
* @note The dense slots are default-constructed here, so the move is `noexcept`
* only where that construction is; see `constexpr_inline_storage`, the
* one storage for which it may not be.
*/
constexpr sparse_set(sparse_set&& other) noexcept
constexpr sparse_set(sparse_set&& other) noexcept(std::is_nothrow_default_constructible_v<DenseStorage>)
requires(!relocatable) && std::default_initializable<SparseIndex> && std::default_initializable<DenseStorage> &&
std::is_nothrow_move_constructible_v<Id>
: sparse_{std::move(other.sparse_)} {
Expand Down Expand Up @@ -672,11 +678,15 @@ public:
/**
* @brief Inline-backed map: the payloads are relocated one at a time.
*
* @note Requires a non-throwing payload move, so the move stays `noexcept` and
* leaves no half-moved state. The payloads are relocated *before* the key
* set is moved, so `other.size()` still describes them while they travel.
* @note Requires a non-throwing payload move, so nothing is left half-moved.
* The payloads are relocated *before* the key set is moved, so
* `other.size()` still describes them while they travel.
* @note Both arrays are default-constructed here, so the move is `noexcept`
* only where those constructions are; see `constexpr_inline_storage`, the
* one storage for which they may not be.
*/
constexpr sparse_map(sparse_map&& other) noexcept
constexpr sparse_map(sparse_map&& other) noexcept(
std::is_nothrow_default_constructible_v<key_set> && std::is_nothrow_default_constructible_v<value_storage>)
requires(!relocatable) && std::default_initializable<key_set> && std::default_initializable<value_storage> && std::is_nothrow_move_constructible_v<T> &&
std::movable<key_set>
{
Expand Down Expand Up @@ -1070,8 +1080,33 @@ consteval int constexpr_map() {
return found ? *found : -1;
}

/* A payload with member initialisers is not trivially default constructible, so
* the dense storage has to be asked for typed slots for the map to stay
* constant-evaluable over it. */
struct sparse_test_value {
float x{}, y{};
};

using constexpr_inline_map = sparse_map<sparse_test_id, sparse_test_value, constexpr_inline_storage<sparse_test_id, 32>>;

consteval float constexpr_default_init_map() {
constexpr_inline_map m{};
m.emplace(sparse_test_id{1}, sparse_test_value{1.0F, 2.0F});
m.emplace(sparse_test_id{2}, sparse_test_value{3.0F, 4.0F});
m.erase(sparse_test_id{1});

const sparse_test_value* found{m.find(sparse_test_id{2})};
return found ? found->x + found->y : -1.0F;
}

static_assert(constexpr_set() == 8);
static_assert(constexpr_map() == 20);
static_assert(constexpr_default_init_map() == 7.0F);

/* The dense slots and the payloads are default-constructed by the move, so a map
* over the opt-in storage keeps its `noexcept` move only while those cannot throw. */
static_assert(std::is_nothrow_move_constructible_v<constexpr_inline_map>);
static_assert(std::is_nothrow_move_constructible_v<inline_map>);

template <typename S>
concept has_clone = requires(const S& s) { s.clone(); };
Expand Down
Loading
Loading