rml / Errors, memory and threads

Errors, memory and threads

A few small files in rml/core set the rules the rest of the library runs under: how a function reports failure without throwing, where tensor memory comes from and how it is counted, and how a loop is split across threads so the result is the same on every run.

On this page
  1. The error model
  2. Status
  3. Expected<T>
  4. Propagation macros
  5. Aligned allocation
  6. The counters
  7. Arena
  8. BufferPool
  9. ThreadPool
  10. How a range is chunked
  11. What is deterministic
  12. Dispatch without allocation
  13. Nested calls
  14. Who uses it
  15. Tests

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.

SituationMechanismWhat the caller sees
Bad input from outside: file, config, out of memoryStatus, Expected<T>A value to check. Both types are [[nodiscard]], so ignoring one is a compiler warning.
Contract violation by the calling codeRML_CHECKThe condition, message, file and line on stderr, then abort().
Contract check too costly for release buildsRML_DCHECKSame as RML_CHECK in debug builds; nothing under NDEBUG.

Status

ItemType/SignatureMeaningSource
StatusCodeenum classEight codes: kOk, kInvalidArgument, kOutOfRange, kNotFound, kIoError, kUnsupported, kOutOfMemory, kInternal.L17
Statusclass [[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
ToStringstring ToString() const"INVALID_ARGUMENT: message", or just the code name when the message is empty.L30
OkStatus, InvalidArgumentError, …, InternalErrorStatus (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.

ItemType/SignatureMeaningSource
ConstructorsExpected(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 boolboolTrue when the variant holds a value.L78
value, *, ->lvalue, const and rvalue overloadsReading 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
statusStatus status() constThe error, or OkStatus() when there is a value.L100
value_orT 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.

MacroExpands toSource
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.

ItemType/SignatureMeaningSource
kDefaultAlignmentinline constexpr size_t = 64The alignment for tensor storage and arenas.L17
AlignedAllocvoid* 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
AlignedFreevoid 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 layout of one AlignedAlloc block malloc returns a raw block of bytes plus 16 plus alignment minus 1. The returned pointer is the first 64-byte boundary at least 16 bytes into the block. The 16 bytes just below it hold a Header with the raw pointer and the size. The bytes before the header are unused padding, and the user's bytes follow the pointer. malloc(bytes + sizeof(Header) + alignment − 1) padding Header raw, bytes bytes the caller asked for raw returned pointer (a multiple of 64) AlignedFree(p) reads ((Header*)p)[-1]
The header sits in the bytes the alignment would have wasted anyway, at most 63 + 16 per block.

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.

ItemType/SignatureMeaningSource
Constructorexplicit Arena(size_t capacity_bytes)One AlignedAlloc; aborts if it fails. Move-only.L75
Allocatevoid* 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, ResetMarker = size_tA marker is the current offset. Rewinding frees everything allocated since it; rewinding forward aborts. Reset rewinds to zero.L58
used, peak, capacitysize_tThe 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.

ItemType/SignatureMeaningSource
Acquirevoid* 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
Releasevoid 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_blockssize_tWhat is sitting in the free lists. The destructor AlignedFrees all of it.L18
CurrentBufferPool, CurrentBufferPoolSharedBufferPool*, shared_ptr<BufferPool>The pool installed on this thread, a thread_local shared_ptr, or null.L14
ScopedBufferPoolexplicit 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.

ItemType/SignatureMeaningSource
ThreadPoolexplicit ThreadPool(int num_threads)num_threads counts the caller. Zero aborts. The destructor sets the atomic stop_, wakes any sleepers and joins.L49
ParallelForvoid 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 ParallelForsame signatureRuns on ThreadPool::Default().L96
RML_THREAD_SPIN_USenvironment variable, microsecondsHow 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
kChunksPerThreadconstexpr int64_t = 4At 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

  1. NumChunks returns 1 when the pool has no workers or the calling thread is already running a chunk. Otherwise it returns n / grain, clamped to between 1 and 4 × num_threads.
  2. With one chunk, ParallelFor calls fn(begin, end) directly and never touches the pool (L54–57).
  3. Otherwise chunk_size = ⌈n / num_chunks⌉, and the job runs ⌈n / chunk_size⌉ chunks. Chunk i covers [begin + i·chunk_size, min(that + chunk_size, end)). Only the last chunk can be short. Since num_chunks ≤ n / grain, every other chunk has at least grain elements.

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).

ParallelFor(0, 1000, 10) on four threads The range 0 to 1000 is cut into 16 chunks: fifteen of 63 elements and a last one of 55. The boundaries are fixed. Four threads, the caller and three workers, each take the next chunk number from an atomic counter until it passes 15. Which thread runs which chunk changes from run to run. [0, 1000), grain 10, 4 threads → 16 chunks of 63 (last 55) 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 0 315 630 945 next_chunk_.fetch_add(1): one possible run caller 0 4 9 13 worker 1 1 5 8 12 worker 2 2 6 10 14 worker 3 3 7 11 15 each thread loops: i = next_chunk_++ until i ≥ 16
The boundaries above never change for a given range, grain and thread count. Only the assignment of chunks to threads does.

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:

  1. Locks call_mutex_, so two threads calling ParallelFor on the same pool take turns rather than sharing workers.
  2. Publishes the job, resets next_chunk_ to 0 and sets pending_workers_ to the worker count, all atomics, then bumps generation_. Spinning workers see the new generation on their own; only if sleeping_workers_ is above zero does it take sleep_mutex_ and notify the condition variable.
  3. Works on chunks itself through WorkOn, which claims the next index with fetch_add until none are left.
  4. Spins until pending_workers_ reaches zero, yielding its core after kSpinsBeforeYield = 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

CallerWhat is splitSource
GEMMUnits 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 matmulWhen 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 copiesOuter blocks of a copy once it has 216 elements, about 64K elements per task, on the default pool. See Tensors.tensor.cc L139
Graph kernelsElementwise 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 programsRow 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 kernelsRows, grain 16. See Kernels.kernels.cc L154
Classical modelsTrees 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

FileTestsWhat they pin down
status_test.cc7Default 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.cc13Alignment, 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.cc6A 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.cc9Run 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.ccbenchmarkBM_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.