backends — the allocator seam (L0)¶
In one paragraph
tr::mem::mem_backend_t is a small, user-implementable interface: subclass it
to bind libtracer to any memory — a heap, a fixed arena, live registers, a DMA
ring. libtracer never allocates payload bytes on its own; it asks a backend. (Its own
bookkeeping — a rope’s spilled link chain, the CAN splitter’s window vector, the
route-handle label tables — allocates from the global heap or from an injected
std::pmr::memory_resource; those seams are
failable allocation and backpressure.) Three backends are
provided: mem_heap (owns malloc’d bytes), mem_borrowed (wraps your
bytes, frees nothing), and mem_pool (a bounded fixed-slab,
alloc-or-null).
What it does¶
The protocol treats application data as opaque, and mem_backend_t extends that
to the memory plane: libtracer is a transparent byte router
(ADR-0012 — memory binding is a modular spectrum).
A backend declares its own per-architecture contract (alignment, cache
hooks, ISR-safety) and owns reclamation; the layers above see only segment_ts. The
interface deliberately makes allocation optional (alloc may return nullptr)
because many substrates — MMIO, hardware FIFOs — cannot allocate at all.
Backend |
Owns |
|
Use |
|---|---|---|---|
|
malloc’d bytes |
frees bytes + control block |
hosted targets |
|
nothing (your bytes) |
frees only the control block |
live/raw, MMIO header, ROM |
|
a caller slab |
returns the slot to a free list |
bounded / MCU / deterministic |
A fourth, mem_cuda, is not part of core at all: it is a tier module under
backends/cuda/,
its own CMake project that consumes core (backends/cuda/CMakeLists.txt:36). It needs the CUDA
toolkit and a GPU, so it is never built in CI. Core carries no vendor name and no #ifdef for
it — a DEVICE-space backend plugs in by registering a transfer hook (below).
mem_pool is the bounded “custom allocator”: it carves a caller-owned slab
into fixed slots with the free list threaded through the slab (no auxiliary
heap), and returns nullptr when full — the BACKPRESSURE signal. pool_t is not
synchronized; synchronized_pool_t<Sync> (core/include/libtracer/mem_pool.hpp:194)
composes over it and guards the free list with a compile-time synchronisation policy,
which is what any shared seam needs — a segment self-routes its reclaim on whatever thread
drops the last reference, concurrent with a writer’s alloc. Two policies ship: the
spinlock spin_sync_t for a multi-core host (sync_pool_t is the alias for that pairing)
and the interrupt-disable tr::esp::portmux_sync_t for a single-core, priority-preemptive
MCU (integrations/esp-idf/libtracer/include/libtracer_esp/critical_pool.hpp, aliased
tr::esp::critical_pool_t — it needs FreeRTOS headers, so it ships with the ESP-IDF
component rather than in core/). The target picks; nothing defaults to either.
Each concrete backend also carries four compile-time traits the module set reads
without a virtual call — needs_cache_ops, is_isr_safe, is_nonblocking, owns_bytes
(ADR-0047 — build-time closed module sets §2).
owns_bytes is the one a caller must respect: mem_borrowed sets it false, so a
segment it produced must not be stored durably.
The seam lives at L0 (tr::mem); the segments it produces are owned at L1
(tr::view). A backend constructs and reclaims tr::view::segment_t — the one
sanctioned L0↔L1 boundary type, and the only tr::view symbol the L0 interface is
permitted to name
(ADR-0016 — substrate, zero-copy, layer namespaces §2).
alloc returns a raw segment_t* with a refcount of 1; the caller adopts it
with tr::view::segment_ptr_t::adopt (core/include/libtracer/segment.hpp:116).
The handle-producing conveniences heap_alloc / borrow / borrow_const
therefore live in tr::view, not here.
Interface¶
-
class mem_backend_t¶
A memory backend: the L0 seam libtracer binds any substrate behind.
Subclass this to bind libtracer to any allocator — a heap, a fixed caller-owned arena, live registers, lwIP pbufs, DMA descriptors. The interface deliberately does not make allocation mandatory: many substrates cannot allocate (MMIO, hardware FIFOs), so alloc may return
nullptr.Note
Each backend declares its own concurrency/ISR-safety contract; the protocol mandates none (docs/adr/0012).
Subclassed by
tr::mem::detail::borrowed_backend_t,tr::mem::detail::borrowed_device_backend_t, tr::mem::heap_backend_t, tr::mem::pool_t, tr::mem::synchronized_pool_t< Sync >Public Functions
-
inline explicit mem_backend_t(const char *name) noexcept¶
Construct a backend with a stable, human-readable
name(e.g. “mem_heap”).
-
inline virtual view::segment_t *alloc(std::size_t size, alloc_hint_t hint = alloc_hint_t::NONE)¶
Allocate a fresh segment of at least
sizebytes (refcount = 1).The returned segment is the caller’s to adopt via
tr::view::segment_ptr_t::adopt. A rawsegment_t*is returned, not asegment_ptr_t, to keep L0 from naming L1’s owning handle (docs/adr/0016 §2). Allocation-incapable substrates (MMIO, FIFOs) leave this default and returnnullptr.- Parameters:
hint – Backend-private allocation hint;
NONEfor “don’t care”.- Return values:
nullptr – Backpressure (pool exhausted / OOM) or allocation unsupported.
-
virtual void destroy(view::segment_t *seg) noexcept = 0¶
Reclaim a segment whose refcount has reached zero (the only reclaim path).
Frees whatever the backend owns (the bytes and/or the
segment_tcontrol block) and nothing it does not — a borrowed backend never frees the user’s bytes. Invoked bysegment_ptr_tat zero, never by user code.Warning
Never called on a live segment.
-
inline virtual void before_io(view::segment_t*, io_dir_t) noexcept¶
Cache prep before handing the segment to a DMA transfer.
Clean or invalidate per
dirso the device sees coherent memory. No-op by default and on cacheless cores (Cortex-M0/M3/M4); only DMA-class backends override it (docs/reference/09 §cache coherency).
-
inline virtual void after_io(view::segment_t*, io_dir_t) noexcept¶
Cache reconcile after a DMA transfer completes.
Invalidate per
dirso the next CPU reader sees HW’s writes. No-op by default and on cacheless cores.
-
inline virtual std::size_t alignment() const noexcept¶
The alignment (bytes) this backend guarantees for allocated bytes.
-
inline virtual std::size_t max_segment_size() const noexcept¶
The largest single segment this backend can produce.
-
inline virtual mem_space_t space() const noexcept¶
The address space this backend’s segments live in (default
HOST).A
DEVICEbackend (one from thebackends/tier) must override this; segments inherit it (segment.hpp), and the codec uses it to skip CPU access to device links.
-
inline virtual backend_tag tag() const noexcept¶
The build-time-closed module-set tag (default
UNKNOWN, ADR-0047 §2).A backend that participates in the fast destroy dispatch overrides this to return its backend_tag; segments read it once at construction (like space). A backend that leaves the default is dispatched through its virtual
destroy.
-
inline const char *name() const noexcept¶
The backend’s stable identifier (e.g. for introspection / metrics).
-
inline explicit mem_backend_t(const char *name) noexcept¶
The DMA/allocation enums the seam uses:
-
enum class tr::mem::io_dir_t : std::uint8_t¶
Direction of a DMA / cache-coherency transfer, for the cache hooks.
The hook method carries the timing (before/after the transfer); this enum carries the direction; the backend maps the pair to clean/invalidate.
Values:
-
enumerator DEVICE_TO_CPU¶
After DMA-in: invalidate so the CPU reads HW’s writes.
-
enumerator CPU_TO_DEVICE¶
Before DMA-out: clean so HW reads the CPU’s writes.
-
enumerator DEVICE_TO_CPU¶
-
enum class tr::mem::alloc_hint_t : std::uint32_t¶
Opaque, backend-private allocation hint.
A hint’s meaning is private to the backend that defines it: there is no cross-backend hint registry, no two backends share a value’s meaning, and a hint-ignoring backend accepts any value (docs/adr/0016 §”Considered options”). This strong typedef also stops a hint being swapped for a
sizeargument.Values:
-
enumerator NONE¶
“Don’t care” — the default for every
alloccall.
-
enumerator NONE¶
The failable-block seam — block_source_t¶
The second L0 seam, and a distinct one
(ADR-0065 — failable allocation gets its own seam,
reference/09). mem_backend_t above vends
refcounted segments for payload bytes; this one vends raw single-owner blocks
and reports exhaustion by value, because std::pmr::memory_resource structurally
cannot — its allocate signals failure only by throwing, and on a -fno-exceptions
target that lowers to the toolchain’s abort() stub, which a peer can provoke.
The policy the seam exists to serve — which allocations a peer can reach, which status each exhaustion answers with, and how to size a bounded source — is described in failable allocation and backpressure. This page documents only the API.
-
class block_source_t¶
The nothrow block seam every FAILABLE allocation draws from — the ones a PEER can provoke (#551, ADR-0065; ADR-0039 erratum 5/6).
RFC-0014 made vertex registration a runtime, wire-driven operation: a peer’s CREATE frame reaches
register_vertex_key. Every allocation on that path is an unguarded throwing one, and ESP-IDF link-wraps__cxa_throw/__cxa_allocate_exceptiontoabort()stubs — so on the shipping profile a peer can reboot the node by exhausting the heap. This seam is the failure-by-value answer: exhaustion returnsnullptrand the operation answers BACKPRESSURE.Nor does a budget-tracking variant fix it — one that counts its own bytes and answers
nullptrat the ceiling before delegating. Tracking a budget does not make the adapter honest: a FRAGMENTED pmr resource can throw well BELOW the budget, so the adapter is correct except exactly when the underlying resource is in the state the bound was supposed to protect against. Such an adapter is deliberately not offered and must not be added.**The supported answer for “reuse my existing arena” is pool_source_t’s span constructor**, which carves from a caller-provided slab with caller-provided size classes and is not pmr at all — point it at the same storage the pmr resource was partitioning, rather than at the resource. The one direction that IS offered is the opposite one:
tr::mem::source_resource_t(mem_source_pmr.hpp) serves astd::pmrcontainer FROM ablock_source_t.Note
“Failable”, not “control-plane”: CONTEXT.md already binds control plane to the
:field-write addressing plane, and this seam is orthogonal to that axis — a DATA-plane branch write is one of its first consumers.Note
Deliberately NOT a
std::pmr::memory_resource, and not derived from one. That type’sallocateis annotated__attribute__((__returns_nonnull__))(libstdc++bits/memory_resource.h), so a caller’sif (p == nullptr)is undefined-behaviour-deletable. Measured on riscv32-esp-elf-g++ 15.2.0 with the deployment flags: the soft-fail branch survives at-O0/-O1/-O2/-O3and is GONE at-Os/-Oz— the level the reference node ships at (CONFIG_COMPILER_OPTIMIZATION_SIZE), and the level at which no job exercises an allocation-failure path (see ADR-0065 §1). Inheriting would keep thatallocate()publicly callable on this object, one token away from every correcttry_alloccall site, with no diagnostic at any warning level. A separate type makes the slip a compile error.Warning
DO NOT WRAP A
std::pmr::memory_resourceBEHIND THIS SEAM. The note above says why this type is not a pmr resource; this one is about the REVERSE adaptation, which is the mistake a host migrating an existing pmr arena actually makes (#1493). The obvious adapter compiles, looks correct and passes review:void* try_alloc(std::size_t n, std::size_t a) noexcept override { return mr_->allocate(n, a); // <-- CANNOT report exhaustion }
std::pmr::memory_resource::allocatesignals exhaustion only by THROWING and has no nothrow form, so thistry_alloceither succeeds or never returns — it never answersnullptr. Under-fno-exceptionsthe throw reaches ESP-IDF’s link-wrapped__cxa_throw→abort()stub, which is exactly the reboot-a-node-by-exhausting-the-heap failure this seam exists to remove, reintroduced by a class whose declaration promises the opposite. A comment on the caveat does not fix it; it labels the landmine.Note
Also distinct from mem_backend_t, which vends a refcounted view::segment_t. Control-plane blocks have a single owner and no header; a refcount on them is pure overhead (a
segment_tmeasures 20 B on rv32 / 40 B on x86-64 against an 80 Bvertex_t).Note
Blocks are host-owned storage: the source MUST outlive the
graph_tand every object built in its blocks. Teardown is driven by whoever holds the source, never by the object itself — avertex_thas no room for the pointer (core/tests/vertex_size_test.cpp).Note
Each source declares its own concurrency contract, exactly as mem_backend_t does (ADR-0012). The RFC-0014 wire-driven registration path runs on a transport thread, so an injected source must be thread-safe on that target. heap_source_t is.
Subclassed by tr::mem::bump_source_t, tr::mem::heap_source_t, tr::mem::null_source_t, tr::mem::pool_source_t< Sync >
Public Functions
-
inline explicit constexpr block_source_t(const char *name) noexcept¶
Construct a source with a stable, human-readable
name(e.g."heap").
-
virtual ~block_source_t() = default¶
Sources are held by pointer and outlive their users; virtual teardown.
-
block_source_t(const block_source_t&) = delete¶
Non-copyable — a source is an identity, not a value.
-
block_source_t &operator=(const block_source_t&) = delete¶
Non-assignable.
-
virtual void *try_alloc(std::size_t bytes, std::size_t align = alignof(std::max_align_t)) noexcept = 0¶
Obtain
bytesof storage aligned to at leastalign— NOTHROW.- Parameters:
bytes – Size of the block; a zero-sized request is implementation-defined and callers do not make one.
align – Minimum alignment, a power of two.
- Return values:
nullptr – Exhaustion. The caller answers BACKPRESSURE; it never falls back to the global heap and never aborts.
-
virtual void release(void *p, std::size_t bytes, std::size_t align = alignof(std::max_align_t)) noexcept = 0¶
Return a block previously handed out by try_alloc.
Warning
bytesandalignMUST match the originating try_alloc call (sized reclaim), so a bump or pool source needs no per-block header.
-
inline const char *name() const noexcept¶
The source’s stable name, for census and diagnostics.
-
inline virtual source_stats_t stats() const noexcept¶
This source’s census — the interface-level introspection seam (#1492, #1503).
The whole vocabulary of this seam used to be name, so a host holding a
block_source_t&could introspect NOTHING: not the ceiling it injected, not how much of it was gone, and above all not whether anything had been refused —try_alloc → nullptrwas uncounted at every implementation in the tree.Optional, in the
tr::net::transport_t::drop_statsmould (#932): the DEFAULT is all-zero, which is the honest answer for a source that counts nothing, never a fabricated number. Concrete sources override it — bump_source_t and pool_source_t do; heap_source_t does not (the platform heap’s ceiling is not this seam’s to report), and neither does null_source_t, whose refusals are its whole contract and are the CALLER’s to count.Counted, never enforced, and never on the hot arm: the refusal counters are bumped only where try_alloc is already returning
nullptr, so a successful allocation pays nothing at all (core/STYLE.md§Introspection, counting doctrine 1 — ADR-0039’sbench_forward_heap == 0hop and ADR-0067’s rv32 text figure are the standing referees).
-
inline explicit constexpr block_source_t(const char *name) noexcept¶
The seam’s census block — the one introspection vocabulary every bounded resource
in the tree answers with (core/STYLE.md §Introspection).
-
struct source_stats_t¶
One block source’s census, in the unified introspection vocabulary (
core/STYLE.md§Introspection; #1503).The five nouns every bounded resource answers with, spelled the same way here as at every other seam: an effective ceiling, used-polarity occupancy, a high-water mark, and the two numbers a sizing operator actually needs — how often a request was refused, and how big the biggest refused one was (#1492: the TAIL is what refuses, so a median request size tells the operator nothing).
All-zero is the honest default for a source that counts nothing, exactly as
tr::net::transport_drop_stats_tis for a link that counts nothing (#932) — never a fabricated number. A field a particular source cannot answer stays 0;capacity == 0means “unbounded, or not reported”, never “a zero-byte ceiling”.Snapshot coherence is the
core/STYLE.md§Introspection clause: monotonic since construction, sampled unsynchronized, and the intended reading is the DIFFERENCE between two snapshots rather than an instant.Public Members
-
std::size_t capacity = 0¶
The effective byte ceiling this source serves from — the caller’s injected slab, not a compile-time constant. 0 = unbounded / not reported.
-
std::size_t in_use = 0¶
Bytes handed out and not returned to this source, USED-polarity (free is
capacity - in_use, and is deliberately not the primary).
-
std::size_t refused = 0¶
block_source_t::try_alloc calls this source answered
nullptr— requests refused BY VALUE, so the caller was told (it answered BACKPRESSURE). Distinct from adropped, where nobody was told.
-
std::size_t capacity = 0¶
-
class heap_source_t : public tr::mem::block_source_t¶
The default source: the platform heap, nothrow.
Behaviour is byte-identical to today for a host that injects nothing, EXCEPT that exhaustion returns
nullptrinstead of reaching the ESP-IDF__cxa_throwabort stub. Thread-safe: the global nothrowoperator newis.
-
block_source_t &tr::mem::heap_source() noexcept¶
The process-wide default block_source_t (the platform heap).
A namespace-scope
constinitobject behind a function, NOT a function-local static: the latter costs a__cxa_guardword in.bssand an acquire fence on every call.
-
class null_source_t : public tr::mem::block_source_t¶
The source that serves nothing — every request is exhaustion.
The upstream to give a bump_source_t when its buffer must be the HARD bound, so a frame that outgrows it is rejected rather than reaching the global heap. This is the bounded-node composition, and the honest replacement for
std::pmr::null_memory_resource(), which signals the same thing by throwing.Public Functions
-
inline constexpr null_source_t() noexcept¶
Constant-initializable, like heap_source_t.
-
inline virtual void *try_alloc(std::size_t, std::size_t) noexcept override¶
Always
nullptr.
-
inline virtual void release(void*, std::size_t, std::size_t) noexcept override¶
Unreachable — this source hands out nothing to return.
-
inline constexpr null_source_t() noexcept¶
-
block_source_t &tr::mem::null_source() noexcept¶
The process-wide null_source_t (serves nothing; see the class docs).
-
class bump_source_t : public tr::mem::block_source_t¶
A caller-owned buffer handed out by bump, falling back to
upstreamonce full.The nothrow twin of
std::pmr::monotonic_buffer_resourceover a fixed span. Blocks carved from the span are never individually reclaimed (release is a no-op for them, exactly as a monotonic resource behaves); blocks that came from the upstream are returned to it, so a decode that outgrows the buffer still frees what it borrowed.Note
The upstream is what keeps this a capability-preserving substitution: a
monotonic_buffer_resourcealso spills past its buffer, but it spills to a THROWING default resource, which on-fno-exceptionsis theabort()this whole seam exists to remove. Pass a bounded source (or a null-serving one) to make the buffer the hard limit instead.Note
Single-threaded by contract — a bump cursor is not synchronized. Its intended use is a function-scoped buffer on the calling thread’s stack.
Warning
SCOPE-LIFETIME USE ONLY. A bump block is never reclaimed, so a source that outlives one burst of work monotonically fills and then refuses everything. Construct it per operation (as the branch-write decode does), or reset it between operations. It is NOT a long-lived seam: an 8 KiB bump source wired as a router’s
rxdecoded 6 frames and rejected the next 194 — measured. A long-lived bounded seam wants pool_source_t, which recycles.Public Functions
-
inline explicit bump_source_t(std::span<std::byte> buffer, block_source_t &upstream = heap_source()) noexcept¶
Carve from
buffer; once it cannot serve a request, draw fromupstream.
-
inline virtual void *try_alloc(std::size_t bytes, std::size_t align) noexcept override¶
Bump-allocate, aligned; falls back to the upstream when the buffer cannot fit.
-
inline virtual void release(void *p, std::size_t bytes, std::size_t align) noexcept override¶
No-op for a bump block; a sized return to the upstream otherwise.
-
inline void reset() noexcept¶
Hand the whole buffer back for reuse — the next try_alloc starts at 0.
For a source reused across scopes (a terminus decoding frame after frame into the same slab). Deliberately NOT called
release: on astd::pmr::monotonic_buffer_resourcethat name means exactly this, while on block_source_t it means “return one block”, and the two must not be confusable at a call site.Warning
Every block previously carved from the buffer dangles afterwards. Blocks that came from the upstream are NOT reclaimed by this — return those first.
-
inline std::size_t used() const noexcept¶
Bytes carved from the buffer so far (diagnostics; excludes upstream blocks).
-
inline virtual source_stats_t stats() const noexcept override¶
This bump source’s census (source_stats_t;
core/STYLE.md§Introspection).capacity/in_use/peakdescribe the CALLER’S BUFFER — the span size this source was handed, how much of it the cursor has carved, and the deepest any reset cycle got. Upstream spill is deliberately outside all three: those bytes are the upstream’s census to report, and folding them in here would makein_useexceedcapacityon the very source whose ceiling the number exists to describe.refusedcounts what a caller experienced: a try_alloc that answerednullptr, which for this source means the buffer could not fit the request AND the upstream refused it too. Against a bounded upstream (null_source() — the composition that makes the buffer a hard limit) that is exactly “the buffer overflowed”; against heap_source() it stays 0 until the platform heap is gone, which is the honest reading in both cases.Plain counters, no atomics: this source is single-threaded BY CONTRACT (see the class note), so the ownership discipline that already protects
used_protects these (core/STYLE.md§Introspection, counting doctrine 5).
-
inline explicit bump_source_t(std::span<std::byte> buffer, block_source_t &upstream = heap_source()) noexcept¶
-
template<class Sync = sync_none_t>
class pool_source_t : public tr::mem::block_source_t¶ A BOUNDED, RECYCLING source: segregated exact-size free lists over a caller slab.
The long-lived counterpart to bump_source_t, and the source a node with a RAM ceiling injects. Exhaustion is
nullptr— never the platform heap, never an abort.Public Functions
-
inline pool_source_t(std::span<std::byte> slab, std::span<size_class_t> classes) noexcept¶
Serve allocations from
slab, recycling throughclasses.This is also the supported “reuse my existing arena” path (#1493). A host that already partitions a static slab with
std::pmr— themonotonic_buffer_resourceunder asynchronized_pool_resourceshape ADR-0039 describes — points this constructor at the SAME STORAGE rather than at the resource. There is no pmr in the result and nothing to adapt, so exhaustion stays anullptrall the way down; see block_source_t’s warning for why wrapping the resource instead cannot work.- Parameters:
slab – Caller-owned storage; must outlive every block carved from it.
classes – Caller-owned free-list slots. Running out is safe but lossy — see overflowed.
-
inline virtual void *try_alloc(std::size_t bytes, std::size_t align) noexcept override¶
Pop a recycled block of this exact shape, else carve a fresh one;
nullptrwhen full.
-
inline virtual void release(void *p, std::size_t bytes, std::size_t align) noexcept override¶
Return a block to its class’s free list.
bytesandalignMUST match the originating try_alloc, per the seam’s sized contract — that is what lets a block carry no header. A pointer from outside the slab is ignored rather than trusted, mirroringbump_source_t— two compares are cheaper than the corruption a foreign pointer would cause.
-
inline std::size_t used() const noexcept¶
Bytes carved from the slab so far, recycled blocks included (diagnostics).
-
inline std::size_t classes_used() const noexcept¶
Class slots in use — the number to size the injected span against.
-
inline std::size_t overflowed() const noexcept¶
Blocks lost because the class table was full; non-zero means the span is too small.
Note
A RECYCLING DEGRADE, not an allocation refusal, and the two are deliberately separate counters (
core/STYLE.md§Introspection): the block stays carved — bounded and safe — and the caller that freed it was never refused anything. The refusal number is refused.
-
inline std::size_t refused() const noexcept¶
try_alloc calls this pool answered
nullptr— primary slab exhaustion (#1492). Also reachable via stats.
-
inline std::size_t largest_refused() const noexcept¶
Bytes of the largest request in refused — the number to grow the slab to (#1492: the tail is what refuses).
-
inline virtual source_stats_t stats() const noexcept override¶
This pool’s census (source_stats_t;
core/STYLE.md§Introspection).capacityis the injected slab, andin_useis used — bytes CARVED, recycled blocks sitting on a free list included, because a carved block is never returned to the slab and so is not available to a different size class. That makes carving monotonic, andpeaktherefore equalsin_useby construction: the high-water mark costs this source not one instruction.Plain counters under the existing
Syncsection, not atomics: the refusal bump sits inside the sameguard_ttry_allocalready holds, so a shared pool’s counters are as synchronized as its free lists are and nothing new is locked. On rv32 an atomic wide enough to matter is not lock-free anyway — it takes a hidden libatomic lock per access (core/STYLE.md§Introspection, counting doctrine 5).Note
overflowed is NOT in this block. It counts a recycling degrade rather than a refusal, so it is neither
refusednordroppedin the shared vocabulary, and it stays this type’s own named accessor.
-
inline pool_source_t(std::span<std::byte> slab, std::span<size_class_t> classes) noexcept¶
-
struct size_class_t¶
One recycling free-list, keyed by the exact
(bytes, align)pair it serves.Caller-supplied storage: a pool_source_t is handed a span of these, so the number of classes is a deployment property rather than a constant in this header (ADR-0065’s injected-bounds rule). Sizing it is measurable — see pool_source_t::classes_used.
The std::pmr adapter — one direction only, and outside the library. It lets an
embedder point a std::pmr container at an injected block_source_t; it does not
make that container failable. See
the memory substrate reference for when to reach
for it and when to migrate the store instead.
-
class source_resource_t : public std::pmr::memory_resource¶
Serve a
std::pmrcontainer from an injected block_source_t.The adapter ADR-0079 ends on: *”`std::pmr` survives only as a thin adapter for
std-container interop on non-failable paths.”* It exists for the one case a store migration cannot solve by retyping — a
std::pmrcontainer whose element type is neither trivially copyable nor trivially destructible, so block_array_t’s two static assertions reject it (tr::net::can_reassembly_t’s slice map holds a refcountedtr::view::view_t; #873 family 5). Pointing such a container at a bounded pool_source_t is strictly better than leaving it on the process heap.Note
Stateless beyond the one pointer. There is deliberately NO refusal counter here: a non-atomic one races on a shared adapter and an atomic one puts a shared RMW on an allocation path, which #873’s cadence rules a reject. Counting belongs to the injected source, which already has the vocabulary (pool_source_t::used, pool_source_t::classes_used, pool_source_t::overflowed).
Note
Concurrency is entirely the injected source’s contract — the adapter adds no shared state of its own. Own one source per receiver: ADR-0060 erratum 1 measured a shared free-list pool collapsing to ~1/15 of its single-thread rate on a 12-core host. Wrapping it in a
memory_resourcedoes not change that.Note
Blocks are host-owned: both the source and this adapter must outlive every container built over them.
Warning
THIS DELIVERS PLACEMENT AND BOUNDING, NOT FAILABILITY.
std::pmr’s only exhaustion signal is a throw, so this adapter’s boundary is astd::bad_allocon a hosted build and astd::abort()under-fno-exceptions— byte-for-byte the behaviour libstdc++ itself produces for the same throw on that profile. A peer-provoked path must therefore NOT be moved onto astd::pmrcontainer just because this exists: a store that has to SURVIVE exhaustion migrates via the route-handle pattern (docs/reference/09-memory-substrate.md) onto block_array_t and fails by value. What this buys is that the bytes come from the deployer’s slab instead of the global heap, and that the slab’s size is the bound.Public Functions
-
inline explicit source_resource_t(block_source_t &src) noexcept¶
Serve every request from
src;srcmust outlive this adapter.
-
source_resource_t(const source_resource_t&) = delete¶
Non-copyable — a resource is an identity, exactly as a source is.
-
source_resource_t &operator=(const source_resource_t&) = delete¶
Non-assignable.
-
inline block_source_t &source() const noexcept¶
The source the bytes come from — for census and for sizing its slab.
Protected Functions
-
inline void *do_allocate(std::size_t bytes, std::size_t alignment) override¶
Draw
bytesaligned toalignmentfrom the source.- Throws:
std::bad_alloc – The source refused. This is the adapter’s boundary and the reason it is not a failable seam; under
-fno-exceptionsthe refusal isstd::abort()instead, which is what libstdc++ does with the same throw.
-
inline void do_deallocate(void *p, std::size_t bytes, std::size_t alignment) override¶
Return a block to the source.
std::pmr’s deallocate carries the original size and alignment, which maps 1:1 onto block_source_t::release’s sized-reclaim contract — that is what lets a pool_source_t recycle these blocks with no per-block header.
-
inline bool do_is_equal(const std::pmr::memory_resource &other) const noexcept override¶
Identity comparison — two adapters are equal only when they are the SAME object.
Address identity rather than a
dynamic_caston the source pointer: the reference node ships-fno-rtti, so a cross-typedynamic_castis not available to this header at all. libstdc++’s ownmonotonic_buffer_resourceanswers the same way.Note
The consequence is worth stating: two
source_resource_ts over the SAME block_source_t compare unequal, so containers built over them will copy rather than steal storage on a container move-assign. Construct one adapter per source and pass it around, rather than one per container.
-
inline explicit source_resource_t(block_source_t &src) noexcept¶
-
struct sync_none_t¶
The no-op synchronization policy — the default, and the one the hot seam wants.
A pool_source_t owned by exactly one thread needs no synchronization at all, and that is the intended shape for a per-receiver source: ownership removes the race instead of guarding it. See pool_source_t’s threading note for why this matters more than the choice of free-list algorithm.
A policy is anything with
lock()/unlock(); a target supplies its own where it needs one (an interrupt-disable critical section on single-core FreeRTOS,tr::mem::sync_mutex_tfrommem_source_sync.hppon a host). This header stays freestanding-clean, so it pulls in no threading facility of its own.
-
template<class T>
class block_array_t¶ A nothrow growable array of trivially-copyable
Tdrawn from a block_source_t.The container a failable path uses where a
std::pmr::vectorwould otherwise sit (#551 Q2, #588). Two differences carry the whole point:Growth returns instead of throwing.
std::pmr::vector::push_backon an exhausted resource throws, which on ESP-IDF reaches the link-wrapped__cxa_throwabort()stub — a peer-reachable reboot when the container sits on the RX decode path.Relocation is a .
Tis required trivially copyable, so growth needs no move loop and the vacated block needs no destruction. Both current users (wire::arena_tlv_t, the walk’s open-node record) are span/enum aggregates.
Same footprint as
std::pmr::vector(four words), one virtual call per growth instead of the allocator’s two.Public Functions
-
inline explicit block_array_t(block_source_t &src) noexcept¶
An empty array that will draw its storage from
src.
-
inline ~block_array_t()¶
Returns the block, if one was taken.
-
block_array_t(const block_array_t&) = delete¶
Non-copyable — one array, one block.
-
block_array_t &operator=(const block_array_t&) = delete¶
Non-assignable.
-
inline block_array_t(block_array_t &&o) noexcept¶
Move-constructible so a decode can return its arena by value.
-
inline block_array_t &operator=(block_array_t &&o) noexcept¶
Move-assignable (releases this array’s block first).
-
inline bool reserve(std::size_t n) noexcept¶
Ensure room for
nelements without growing again.- Return values:
false – The source is exhausted — the array is unchanged.
-
inline bool push_back(const T &v) noexcept¶
Append
v.- Return values:
false – The source is exhausted — the array is unchanged (BACKPRESSURE).
-
inline T *push_slot() noexcept¶
Claim one uninitialized slot at the end and return it — fill it IN PLACE.
The form the hot paths use.
push_back(T{...})has to materialize the aggregate on the stack and copy it in, and for a 48-byteTwritten field-by-field then read back as wide loads that is a store-forwarding stall on every element: measured on the terminus decode, ~45 % slower with FEWER instructions executed. Writing through this slot removes the temporary entirely.- Return values:
nullptr – The source is exhausted — the array is unchanged (BACKPRESSURE).
-
inline void pop_back() noexcept¶
Drop the last element. Precondition: not empty.
-
inline std::size_t size() const noexcept¶
Element count.
-
inline bool empty() const noexcept¶
True when no elements are held.
The bounded reference backend:
-
class pool_t : public tr::mem::mem_backend_t¶
A fixed-slot allocator over a caller-owned slab;
alloc-or-nullptr.Carves the slab into equal slots with the free list threaded through the slab (no auxiliary heap), so memory use is exactly the caller’s slab and exhaustion is a return value, not an OOM. The deterministic MCU choice.
Public Functions
-
pool_t(std::span<std::byte> slab, std::size_t slot_payload, std::size_t align = alignof(std::max_align_t)) noexcept¶
Carve
slab(caller-owned; must outlive the pool) into slots.Each slot holds a
segment_tcontrol block plusslot_payloadusable bytes, payload aligned toalign(a power of two). The slot count is whatever fits after aligning the slab base.
-
virtual view::segment_t *alloc(std::size_t size, alloc_hint_t hint = alloc_hint_t::NONE) override¶
Hand out the next free slot as a
segment_tofsizebytes.- Return values:
nullptr –
sizeexceeds the slot payload, or the pool is exhausted.
-
virtual void destroy(view::segment_t *seg) noexcept override¶
Return
seg'sslot to the free list (placement-destroying it).
-
inline virtual std::size_t alignment() const noexcept override¶
The alignment (bytes) this backend guarantees for allocated bytes.
-
inline virtual std::size_t max_segment_size() const noexcept override¶
The largest single segment this backend can produce.
-
inline virtual backend_tag tag() const noexcept override¶
The build-time-closed module-set tag (default
UNKNOWN, ADR-0047 §2).A backend that participates in the fast destroy dispatch overrides this to return its backend_tag; segments read it once at construction (like space). A backend that leaves the default is dispatched through its virtual
destroy.
-
inline std::size_t capacity() const noexcept¶
Total slots — the effective ceiling, i.e. whatever fitted in the caller’s slab, never a constant.
-
inline std::size_t available() const noexcept¶
FREE slots.
Note
The one shipped free-polarity accessor, kept for compatibility. New code reads in_use — the unified vocabulary is used-polarity throughout (
core/STYLE.md§Introspection), andavailableis the derived legacy name (capacity() - in_use()).
-
inline std::size_t in_use() const noexcept¶
Slots handed out and not yet returned — occupancy in the used-polarity vocabulary (
core/STYLE.md§Introspection).
Public Static Attributes
-
static constexpr bool needs_cache_ops = false¶
No DMA cache maintenance (plain RAM slab).
-
static constexpr bool is_isr_safe = false¶
Unsynchronized free-list RMW — NOT safe concurrent with an ISR.
-
static constexpr bool is_nonblocking = true¶
alloc/destroyare O(1) free-list ops — no heap, no syscall.
-
static constexpr bool owns_bytes = true¶
Bytes are backend-managed (freed only on
destroy) — durably storable.
-
pool_t(std::span<std::byte> slab, std::size_t slot_payload, std::size_t align = alignof(std::max_align_t)) noexcept¶
Device memory — the registration seam¶
Core keeps the interface; the vendor backend lives in the
backends/ tier.
A DEVICE-space backend registers the pair {backend, transfer hook} and tr::mem::transfer
routes that backend’s segments to it — the L0 mirror of
tr::net::transport_vertex_t::register_transport_type
(ADR-0024 Amendment 1).
The key is the backend object, not the space tag, so a second vendor (ROCm, an NPU, dmabuf)
plugs in without adding a name — or an enumerator — to core. A backend nobody registered gets a
clean false, exactly as every unrecognized device segment did before the registry existed.
-
using tr::mem::device_transfer_fn_t = bool (*)(view::segment_t *seg, std::span<std::byte> host, io_dir_t dir) noexcept¶
The device byte-move a
DEVICE-space backend registers with register_device_backend — transfer’s out-of-core arm.Same contract as transfer, narrowed to one backend’s segments: move
host.size()bytes betweensegandhostin directiondir,falseon refusal. A plain function pointer, not astd::function: the seam must cost a pointer and never allocate (ADR-0047 §2).
-
bool tr::mem::register_device_backend(const mem_backend_t &backend, device_transfer_fn_t fn) noexcept¶
Register
fnas the byte-mover transfer routesbackend'sDEVICE-space segments through.The L0 mirror of
tr::net::transport_vertex_t::register_transport_type: a module outside core supplies the value, the composition root wires it in, and core never names the module (docs/adr/0024 Amendment 1; the module seam is docs/adr/0043 §1). Thebackends/tier holds the first in-tree caller.Keyed by the backend object, not by mem_space_t —
DEVICEis one enumerator shared by every accelerator, and the segment’sbackendpointer is already the identitydestroyroutes on — so a second vendor plugs in without adding a name to core.Bounded and allocation-free: the table holds tr::mem::kDeviceBackendSlots entries (
config.hpp), so registration cannot fail for lack of heap, only for lack of a slot. Registering the samebackendtwice replaces its hook (insert_or_assignsemantics), so a backend and its hook can never disagree.Note
Call at setup, before frames flow, from one thread — the same contract
register_transport_typecarries. Concurrent lookups by transfer are safe against a completed registration.- Return values:
false –
fnwas null, or the table is full — nothing was registered.
The GPU module’s own entry points (built only in the tier):
-
mem_backend_t &tr::mem::cuda_backend() noexcept¶
The process-wide CUDA device backend (
cudaMalloc/cudaFree;space()== DEVICE).Constructing it also registers it (see register_cuda_backend), so a caller that only ever allocates —
tr::view::cuda_alloc— can never meet atr::mem::transferthat does not know where to route its segments.
-
bool tr::mem::register_cuda_backend() noexcept¶
Register cuda_backend with core’s device-backend registry, so
tr::mem::transferroutes itsDEVICEsegments to cuda_transfer.The
tr::net::quic_transport_factoryof this tier: the module supplies the value, the composition root (or, here, cuda_backend’s own first construction) wires it in, and core never names CUDA. Idempotent — calling it twice replaces the same slot’s hook.- Return values:
false – Core’s bounded table is full (
tr::mem::kDeviceBackendSlots).
-
bool tr::mem::cuda_transfer(view::segment_t *seg, std::span<std::byte> host, io_dir_t dir) noexcept¶
The device byte-move behind
tr::mem::transferfor a CUDA (DEVICE) segment:cudaMemcpyin directiondir, bracketed by the backend’s cache hooks (after_io== the CUDA stream barrier).Declared here but defined in mem_cuda.cpp so
cudaMemcpystays TU-local. It is what register_cuda_backend hands core. Not called directly — usetr::mem::transfer, which routes this backend’s segments here.
The seam¶
classDiagram
class mem_backend_t { <<interface>> +alloc() +destroy() +before_io() +after_io() +alignment() }
mem_backend_t <|-- heap_backend_t
mem_backend_t <|-- borrowed_backend_t
mem_backend_t <|-- pool_t
mem_backend_t <|-- YourBackend
segment_t --> mem_backend_t : backend*
note for YourBackend "bind a DMA ring,\nlwIP pbuf, MMIO, …"
Consequences¶
The same protocol runs against a heap, a caller-sized MCU slab or a live register, because the substrate is selected by binding a backend rather than by a build variant of the core.
Memory use with
mem_poolis exactly the caller’s slab: the free list is threaded through the slab, so there is no auxiliary heap allocation, and exhaustion is anallocreturningnullptrrather than an OOM.mem_borrowedputs a segment over bytes the caller already holds, so live data reaches the wire with no copy and no CRC imposed; the cost is that those bytes are outside libtracer’s lifetime control.A substrate that cannot allocate at all is still bindable, because
allocis permitted to returnnullptrunconditionally — MMIO windows and hardware FIFOs bind as read-only borrowed segments.Two seams rather than one means two failure contracts to hold in mind: a
mem_backend_tfailure is a refcounted-segment allocation that failed, ablock_source_tfailure is a single-owner block that failed. Neither throws.
Pitfalls¶
A raw
segment_t*that is never adopted leaks.allochands back a pointer at refcount 1 and the backend does not track it; the value is only safe oncesegment_ptr_t::adoptowns it. Thetr::viewhelpers (heap_alloc,borrow,borrow_const) exist so that the common paths cannot get this wrong.Borrowed bytes must outlive every segment over them.
borrowed_backend_t::destroydeletes the control block and nothing else (core/include/libtracer/mem_borrowed.hpp:39), so a borrow over a stack buffer or a scratch frame becomes a dangling read the moment that storage goes away. Durable storage of a value wants an owning backend.bump_source_tis scope-lifetime only. Blocks carved from its buffer are never individually reclaimed, so a bump source wired as a long-lived seam fills monotonically and then refuses everything. Construct it per operation, orresetit between operations; a long-lived bounded seam wantspool_source_t, which recycles.A
bump_source_tbuffer is not a hard bound by default. Its upstream defaults toheap_source(), so overflow spills to the platform heap. Passingnull_source()as the upstream is what makes the buffer the limit and turns overflow into a rejection.source_resource_tis placement, not failability. Thestd::pmradapter draws its bytes from an injectedblock_source_t, butstd::pmr’s only exhaustion signal is a throw — so its boundary isstd::bad_alloc, andstd::abort()under-fno-exceptions. Do not move a peer-provoked store onto astd::pmrcontainer because the adapter exists; migrate it ontoblock_array_tinstead. The adapter runs one direction only, and the reverse (amemory_resourceused as ablock_source_t) must never be added.block_source_t::releaseis sized. Thebytesandalignpassed toreleasemust match the originatingtry_alloccall — that is what lets a bump or pool source carry no per-block header. A mismatched pair corrupts the source’s accounting rather than failing loudly.
See: segment, views, interface map, failable allocation and backpressure.