In short: recoverable failures return Status or Expected<T>, and bugs
abort through RML_CHECK; nothing throws. Tensor buffers come from AlignedAlloc,
which hands out 64-byte-aligned blocks and keeps four process-wide counters. Arena is a bump
allocator over one such block, and a thread-scoped BufferPool recycles freed tensor buffers
by size. ThreadPool::ParallelFor cuts a range into chunks whose
boundaries depend only on the range, the grain and the thread count. Threads claim chunks from an atomic
counter, idle workers spin briefly before sleeping, a dispatch allocates nothing, and a nested call runs
inline.
The design notes are the Errors, Memory and Threads sections of
docs/architecture.md. RML_CHECK itself is
covered on Tensors and dtypes. For background, see
GPU execution (which thread_pool.h links to) and
Reproducibility in the ML models reference.
The error model
rml splits failures in two. A failure caused by something outside the caller's control, such as a missing file, a malformed header, an unsupported config or a failed large allocation, is returned as a value. A failure caused by the caller breaking a contract, such as a shape mismatch, is a bug and aborts. The header comment states the rule (L4–6): hot paths never throw.
| Situation | Mechanism | What the caller sees |
|---|---|---|
| Bad input from outside: file, config, out of memory | Status, Expected<T> | A value to check. Both types are [[nodiscard]], so ignoring one is a compiler warning. |
| Contract violation by the calling code | RML_CHECK | The condition, message, file and line on stderr, then abort(). |
| Contract check too costly for release builds | RML_DCHECK | Same as RML_CHECK in debug builds; nothing under NDEBUG. |
Status
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
StatusCode | enum class | Eight codes: kOk, kInvalidArgument, kOutOfRange, kNotFound, kIoError, kUnsupported, kOutOfMemory, kInternal. | L17 |
Status | class [[nodiscard]] { StatusCode code_; string message_; } | A default-constructed status is OK. ok(), code(), message(). | L30 |
operator== | bool (const Status&, const Status&) | Compares codes only. Two errors with different messages are equal, which keeps test expectations from depending on message wording. | L40 |
ToString | string ToString() const | "INVALID_ARGUMENT: message", or just the code name when the message is empty. | L30 |
OkStatus, InvalidArgumentError, …, InternalError | Status (string message) | One constructor function per code, in the Abseil style. | L47 |
Expected<T>
std::expected is C++23 and rml targets C++20, so
Expected<T> is the subset rml needs, built on
std::variant<T, Status>. Index 0 is the value and index 1 the error.
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
| Constructors | Expected(T), Expected(Status) | Both implicit, so a function returning Expected<T> can return value; or return SomeError(...);. Building one from an OK status aborts: an Expected always holds a value or a real error. | L73 |
has_value, operator bool | bool | True when the variant holds a value. | L78 |
value, *, -> | lvalue, const and rvalue overloads | Reading the value of an error aborts with the status string, so a missed check fails loudly instead of reading garbage. The rvalue overload moves out, so move-only types such as unique_ptr work. | L81 |
status | Status status() const | The error, or OkStatus() when there is a value. | L100 |
value_or | T value_or(U&&) const& | The value, or the fallback converted to T. | L105 |
Propagation macros
Without exceptions, each fallible call needs a check and an early return. Two macros write that out.
| Macro | Expands to | Source |
|---|---|---|
RML_RETURN_IF_ERROR(expr) | Evaluate expr into a Status; return it if it is not OK. | L119 |
RML_ASSIGN_OR_RETURN(lhs, expr) | Evaluate expr into a temporary named rml_expected_<line>; return its status if it has no value, otherwise move the value into lhs. lhs can be a declaration. | L125 |
Loading a model is the heaviest user. DecoderLM::FromHf
runs, in order, RML_ASSIGN_OR_RETURN(ModelConfig config, LoadHfConfig(...)) and
RML_ASSIGN_OR_RETURN(io::SafetensorsModel model, io::SafetensorsModel::Open(directory)). Either
failure returns to the caller unchanged, with its code and message. See
The decoder and Model files.
Aligned allocation
Every tensor buffer is 64-byte aligned: one cache line on x86 and Apple Silicon, and enough for any SIMD
load (L6–9). Storage::Allocate, which backs every
Tensor rml allocates itself, calls
AlignedAlloc, unless a
BufferPool is installed on the thread.
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
kDefaultAlignment | inline constexpr size_t = 64 | The alignment for tensor storage and arenas. | L17 |
AlignedAlloc | void* AlignedAlloc(size_t bytes, size_t alignment = 64) | Returns nullptr on failure, which Tensor::TryEmpty turns into kOutOfMemory. A non-power-of-two alignment aborts. A zero-byte request still returns a pointer that must be freed. | L37 |
AlignedFree | void AlignedFree(void*) | Reads the header just below the pointer, updates the counters, and frees the original malloc block. Null is a no-op. | L57 |
AlignedAlloc over-allocates with malloc and rounds up inside the block. Just
below the pointer it returns, it stores a two-word
Header holding the original malloc pointer and
the requested size. AlignedFree needs nothing else, so there is no lookup table and no
lock. Before calling malloc, it checks that bytes plus the overhead can't
overflow (L40–41).
HugeRequestFails asks for
SIZE_MAX − 8 bytes and expects nullptr.
The counters
AllocationStats is four process-wide numbers:
successful allocations, frees, live bytes and peak live bytes. They are relaxed atomics and always on.
Only the order of each counter's own updates matters, so no stronger memory order is needed. The peak is
raised with a compare-and-swap loop that retries only while the new
live total is still above the stored peak. ResetPeakAllocation
sets the peak back to the current live bytes, so a test can measure the peak of one region.
These counters only see AlignedAlloc. Memory that is not tensor storage, like a
std::vector or mmapped weights wrapped with Tensor::FromBuffer, is not counted.
So the decoder's zero-allocation test uses a different tool:
DecodingAllocatesNothing
replaces the global operator new with a counting one. It loads a model, runs a prompt and one
warm-up step, and then expects 16 more decode steps to add no allocations, with and without a thread
pool. See Models and memory for the memory budget this protects.
Arena
Arena is a bump allocator over one block from
AlignedAlloc. Allocating moves an offset forward, and memory comes back all at once.
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
| Constructor | explicit Arena(size_t capacity_bytes) | One AlignedAlloc; aborts if it fails. Move-only. | L75 |
Allocate | void* Allocate(size_t bytes, size_t alignment = 64) | Rounds the current position up to alignment and moves past bytes. Returns nullptr when the arena is full; arenas are meant to be sized up front. Alignments above 64 align the absolute address, not the offset. | L99 |
AllocateArray<T> | T* AllocateArray(size_t count) | Typed form, aligned to at least 64. | L52 |
Mark, Rewind, Reset | Marker = size_t | A marker is the current offset. Rewinding frees everything allocated since it; rewinding forward aborts. Reset rewinds to zero. | L58 |
used, peak, capacity | size_t | The high-water mark is how an arena's size is meant to be chosen. | L62 |
AllocatingFromArenaDoesNotTouchHeap
resets and allocates 100 times and checks that the allocation counter does not move. The design notes say
per-step scratch comes from an arena reset at each step (L66–67).
At this commit, though, no code outside rml/core uses Arena, so it is
available rather than wired into decoding. The graph executor reuses memory through a
BufferPool instead.
BufferPool
A model run with static shapes allocates the same sizes every time.
BufferPool caches freed tensor buffers keyed by exact
size, so later runs reuse warm memory instead of mapping and faulting in fresh pages for every op
(L4–11). It is opt-in per thread: while a
ScopedBufferPool is alive, every Storage::Allocate on that thread draws from
the pool.
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
Acquire | void* Acquire(size_t bytes) | Pops a cached block of exactly bytes under the pool's mutex, or falls through to AlignedAlloc; null if that fails. A block is never handed out for a different size. | L24 |
Release | void Release(void* ptr, size_t bytes) | Pushes the block onto that size's free list. Thread-safe, so a tensor can die on any thread. | L38 |
cached_bytes, cached_blocks | size_t | What is sitting in the free lists. The destructor AlignedFrees all of it. | L18 |
CurrentBufferPool, CurrentBufferPoolShared | BufferPool*, shared_ptr<BufferPool> | The pool installed on this thread, a thread_local shared_ptr, or null. | L14 |
ScopedBufferPool | explicit ScopedBufferPool(shared_ptr<BufferPool>) | Swaps the pool in and restores the previous one on destruction, so scopes nest. | L59 |
The hand-back happens in tensor.cc. A pooled
Storage::Allocate wraps the block
with a PooledBlock owner that holds a
shared_ptr to the pool and calls Release in its destructor. So a tensor that
outlives the scope still returns its buffer when it dies, and it keeps the pool alive until then.
Blocks come from AlignedAlloc, so a cache miss shows up in the allocation counters and a
reused block does not. The one caller at this commit is the graph executor, which keeps a
shared_ptr<BufferPool> and opens a ScopedBufferPool at the start of each
run (executor.cc L156); see
Executing a graph.
ThreadPool
ThreadPool starts n − 1 worker threads that
spin for a short while after each loop and then sleep on a condition variable. The thread calling ParallelFor works too, so
ThreadPool(1) has no workers and runs everything inline.
ThreadPool::Default() is a process-wide pool sized by
RML_NUM_THREADS, or else by hardware_concurrency(). It is created on first use and
intentionally never destroyed.
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
ThreadPool | explicit ThreadPool(int num_threads) | num_threads counts the caller. Zero aborts. The destructor sets the atomic stop_, wakes any sleepers and joins. | L49 |
ParallelFor | void ParallelFor(int64_t begin, int64_t end, int64_t grain, Fn&& fn) | Calls fn(lo, hi) on disjoint chunks covering [begin, end) and returns when all have run. | L47 |
free ParallelFor | same signature | Runs on ThreadPool::Default(). | L96 |
RML_THREAD_SPIN_US | environment variable, microseconds | How long an idle worker spins before it sleeps; default 50, and 0 sleeps at once. The source comment measures spinning as 15–25% faster for models of many small ops on an idle machine and up to 25% slower on one whose cores are busy with other work. | L27 |
kChunksPerThread | constexpr int64_t = 4 | At most four chunks per thread. More would smooth out uneven chunk costs; fewer would mean less per-chunk overhead. | L19 |
How a range is chunked
NumChunksreturns 1 when the pool has no workers or the calling thread is already running a chunk. Otherwise it returnsn / grain, clamped to between 1 and4 × num_threads.- With one chunk,
ParallelForcallsfn(begin, end)directly and never touches the pool (L54–57). - Otherwise
chunk_size = ⌈n / num_chunks⌉, and the job runs⌈n / chunk_size⌉chunks. Chunkicovers[begin + i·chunk_size, min(that + chunk_size, end)). Only the last chunk can be short. Sincenum_chunks ≤ n / grain, every other chunk has at leastgrainelements.
For example, ParallelFor(0, 1000, 10, fn) on a 4-thread pool gives 1000 / 10 = 100,
clamped to 16 chunks. chunk_size is ⌈1000 / 16⌉ = 63, so chunks 0 to 14 are 63
elements and chunk 15 is [945, 1000), 55 elements. With (0, 100, 1), the
same pool computes 16 chunks of ⌈100 / 16⌉ = 7, but 7 × 15 is already 105, so it runs 15
chunks and the last is [98, 100).
What is deterministic
Chunk boundaries depend only on begin, end, grain and the thread
count (L8–11). A kernel that writes each output element from inside one
chunk, and reduces in a fixed order within it, gets bit-identical results however the chunks are
scheduled. Changing the thread count changes the boundaries, so a reduction split across chunks can round
differently on 4 threads than on 8. RML_NUM_THREADS pins it. See
Reproducibility for why floating-point order matters.
Dispatch without allocation
ParallelFor takes any callable, but the pool can't store a template type. Instead it builds a
Job on the caller's stack: a plain function pointer that
casts a void* back to the callable, plus the chunk layout. No std::function, so no
heap allocation. Run then:
- Locks
call_mutex_, so two threads callingParallelForon the same pool take turns rather than sharing workers. - Publishes the job, resets
next_chunk_to 0 and setspending_workers_to the worker count, all atomics, then bumpsgeneration_. Spinning workers see the new generation on their own; only ifsleeping_workers_is above zero does it takesleep_mutex_and notify the condition variable. - Works on chunks itself through
WorkOn, which claims the next index withfetch_adduntil none are left. - Spins until
pending_workers_reaches zero, yielding its core afterkSpinsBeforeYield= 256 polls (L23–25). It waits for every worker to leave the job, even ones that found no chunk left: the job lives in this stack frame, so returning earlier would leave a worker holding a dangling pointer.
A worker first spins on generation_ for up to
RML_THREAD_SPIN_US, checking the clock every 64 polls, since in a model the next parallel
loop usually follows within microseconds. If nothing arrives it increments
sleeping_workers_ and waits on the condition variable until generation_ changes
or stop_ is set. It then runs WorkOn and decrements
pending_workers_ with release order, which the caller's acquire load pairs with. Each worker
remembers the last generation it ran, so a wakeup without a new job sends it back to waiting. The
generation_ bump and the sleeper's increment are sequentially consistent, so either the
sleeper sees the new job or the caller sees it asleep and wakes it
(L79–88).
Nested calls
A thread_local flag, in_parallel_region, is set
while a thread runs chunks. A ParallelFor called from inside a chunk sees it, gets one chunk
from NumChunks, and runs inline. So a parallel kernel can call another parallel kernel, for
a kernel that calls GEMM from inside its own chunks, without deadlocking on call_mutex_ or
oversubscribing the cores. The outer loop gets the parallelism.
Who uses it
| Caller | What is split | Source |
|---|---|---|
| GEMM | Units of one row block by one column chunk. The grain gives each task at least kMinTaskMacs = 220 multiply-adds, which keeps small products on the calling thread; packing B is threaded only when its panel has at least 218 elements. With fewer than four units per thread the column chunk is widened, up to four times, so threads finish together. The few-row and one-row paths split column tiles the same way. See Linear algebra. | gemm.cc L307 |
| Batched matmul | When every product in a batch is under 222 multiply-adds (attention heads, say), the batch is split across threads with about 220 multiply-adds per task, and each product runs on one thread. See Linear algebra. | matmul.cc L101 |
| Strided copies | Outer blocks of a copy once it has 216 elements, about 64K elements per task, on the default pool. See Tensors. | tensor.cc L139 |
| Graph kernels | Elementwise ops in chunks of 216 elements; softmax, norms and reductions by rows, enough rows per task to cover a chunk. See Executing a graph. | kernels.cc L112 |
| Fused programs | Row tiles of 512 elements, about 64K element-steps per task. See Lowering and fast CPU. | fusion.cc L302 |
| Attention | (query position, KV head) pairs, grain 1. See The decoder. | attention.cc L162 |
| LLM kernels | Rows, grain 16. See Kernels. | kernels.cc L154 |
| Classical models | Trees and prediction rows in a random forest, features (histogram building) and prediction rows in gradient boosting, query points in k-NN. See Classical ML. | rml/classical |
Tests
| File | Tests | What they pin down |
|---|---|---|
status_test.cc | 7 | Default status is OK; codes and messages round-trip; Expected holds a value or an error; the macros propagate; move-only types work; RML_CHECK aborts with its message. |
allocator_test.cc | 13 | Alignment, default of 64, null free, a huge request returning null, a bad alignment aborting, live and peak counters, arena bumping, large alignment, a full arena returning null, mark/rewind/reset, rewinding forward aborting, moves, and an arena that never touches the heap. |
buffer_pool_test.cc | 6 | A block comes back for the same size and never for another; tensors in a scope draw from the pool and reuse the same warm block; a tensor outliving the scope, or the pool handle, returns its buffer later; scopes nest; releases from four threads all land in the pool. |
thread_pool_test.cc | 9 | Run with 1, 2, 4 and 8 threads (L144): every index exactly once, offset ranges and grains, identical chunks across runs, nested calls inline, many loops back to back, concurrent callers, and a dispatch that allocates nothing, counted by a replaced operator new. Plus the default pool and a zero-thread pool aborting. |
thread_pool_benchmark.cc | benchmark | BM_ParallelForEqualTasks: one ParallelFor of one equal compute-only task per thread, 0, 5,000 or 50,000 dependent multiply-adds each, on 1 and 4 threads; the time above one task's is the cost of handing work to the pool (L16–34). rml commits no results for it. |
How these run under sanitizers is on Testing.