Benchmark & measure memory
All of Execution & Memory has rested on a claim: this is cheaper.
A 24-byte FieldStr saves an allocation; the arbitrator keeps memory bounded; spilling trades RAM
for disk. None of that is worth anything
unless you can measure it. This final lesson is about clinker’s measurement tools, and they
come in two flavours that are often conflated: a fast estimate the runtime uses to budget
memory, and an exact count the benchmarks use to verify it.
What you’ll be able to do
Section titled “What you’ll be able to do”- Explain what
heap_sizeestimates, why scalars and short inline strings cost0, and why an estimate (not a real allocator probe) is the right tool for the live budget. - Read a criterion benchmark and name what
black_boxdoes and why omitting it would time nothing. - Distinguish the two measurement tools by job: a cheap estimate for the live budget versus an exact count for offline verification.
- Write a custom
GlobalAllocthat counts allocated bytes and forwards to the system allocator, and predict the byte delta a single allocation produces.
New terms in this lesson (each is also expanded inline at the point you first need it):
- benchmark: a repeatable timing experiment, run many times for a stable number.
- criterion: clinker’s benchmarking library; reports a timing distribution, not one noisy number.
- black_box: the fence that stops the optimizer deleting the work you are timing.
- peak RSS: the high-water mark of physical RAM the process holds.
- throughput: records processed per unit time; the thing the budget must not wreck.
Predict first
Section titled “Predict first”The cost model: heap_size
Section titled “The cost model: heap_size”Recall the arbitrator and bounded memory in action:
operators charge bytes against the memory budget. What is a record’s byte cost? The engine estimates it with heap_size: owned heap bytes, accounted per Value variant:
clinker-record ·value.rs ·heap_size fn @19acdcb4
/// Estimated heap bytes owned by this value (excludes the enum itself).pub fn heap_size(&self) -> usize { match self { Value::String(s) => s.heap_size(), // 0 if inline, else byte length Value::Array(arr) => { arr.capacity() * std::mem::size_of::<Value>() + arr.iter().map(Value::heap_size).sum::<usize>() } Value::Map(m) => m.iter().map(|(k, v)| k.len() + v.heap_size()).sum(), _ => 0, // scalars live inline — no heap }}Two things to read here. Scalars (Integer, Bool, dates) return 0: they live inline in the
32-byte Value, owning no heap. And a short inline FieldStr also returns 0, which is the
24-byte type from unsafe & FieldStr paying off in the cost model directly. Crucially, this is an
estimate, computed by walking the value, not a real allocator measurement. It has to be:
charging runs on the hot path, per record, per operator, and you cannot attach an allocator probe to
every value without wrecking the
throughput
you’re trying to bound. A cheap, consistent estimate is the right tool for a runtime budget.
The benchmark suite: criterion
Section titled “The benchmark suite: criterion”For the other job, proving a change actually made things faster or smaller, clinker uses
criterion
benchmarks.
They live in each crate’s benches/ directory:
record_ops (record create/get/set/clone, value_heap_size), arbitration_poll, and more.
clinker-exec ·arbitration_poll.rs ·bench_should_spill bench @19acdcb4
// crates/clinker-record/benches/record_ops.rs — the criterion shapefn bench_value_heap_size(c: &mut Criterion) { let string = Value::String("a medium length string".into()); c.bench_function("value_heap_size/string", |b| { b.iter(|| black_box(string.heap_size())); // black_box stops the optimizer });}black_box is the load-bearing detail: it hides its argument from the optimizer, so the compiler
can’t “see through” the benchmark and delete the work you’re trying to time. You run these with
cargo bench -p clinker-record --bench record_ops (or --bench arbitration_poll for the
arbitrator-poll cost). The arbitration_poll suite, for instance, times should_spill across
consumer-registry sizes to keep the arbitrator’s per-poll cost from regressing as pipelines deepen.
The exact count: a custom global allocator
Section titled “The exact count: a custom global allocator”An estimate budgets; a count verifies. To measure the real bytes a section allocates, clinker
ships a counting global allocator behind the bench-alloc feature:
clinker-bench-support ·alloc.rs ·AccountingAlloc type @19acdcb4
pub struct AccountingAlloc { allocs: AtomicUsize, bytes_alloc: AtomicUsize, /* ... */ }
// SAFETY: every call forwards to the System allocator after counting.unsafe impl GlobalAlloc for AccountingAlloc { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { self.allocs.fetch_add(1, Ordering::Relaxed); self.bytes_alloc.fetch_add(layout.size(), Ordering::Relaxed); unsafe { System.alloc(layout) } // delegate the real work } unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { /* count, then */ unsafe { System.dealloc(ptr, layout) } }}Install it with #[global_allocator] and every allocation in the process flows through it, bumping
atomic counters (the interior-mutability pattern from the arbitrator, and
the unsafe impl discipline from unsafe & FieldStr). A scoped Region
snapshots the counters before and after a block to report exactly how many
bytes it allocated; that’s how the executor’s per-stage heap_delta_bytes metric is captured under
the feature. The cost is real (it adds contention per allocation), so it’s a measurement build, not
the production path.
So the two tools divide the labour cleanly: heap_size estimates, cheaply, for the live budget;
AccountingAlloc counts, exactly, for offline verification. Confusing the two, whether by budgeting off real
allocation or benchmarking off the estimate, would get you the worst of each.
Worked → completion → faded
Section titled “Worked → completion → faded”You now build the counting allocator yourself, scaffolded down to your own. One new idea per rung: first read a complete one, then close a single gap, then write the counting from scratch.
Worked: a counting allocator you can run
Section titled “Worked: a counting allocator you can run”The Rust playground lets you install a global allocator, so you can build a miniature
AccountingAlloc right here: same unsafe impl GlobalAlloc, same forward-to-System pattern.
Run it and watch the byte delta land on exactly the capacity you asked for.
> output appears here — press Run
The Vec::with_capacity(1024) shows up as a 1024-byte allocation in the delta, an exact count,
not an estimate. This is clinker’s AccountingAlloc in miniature: a GlobalAlloc impl that counts
and forwards.
Completion: close the counting gap
Section titled “Completion: close the counting gap”Here the dealloc side is written; the alloc side counts allocations but forgets to add the
bytes. Fill the one missing line so BYTES tracks the real allocated size. Predict the printed
delta before you run it.
> output appears here — press Run
💡 Hint 1
dealloc’s sibling: a fetch_add on the atomic. You want BYTES.fetch_add(layout.size(), Ordering::Relaxed);, where layout.size() is the byte count of this request.Show solution
unsafe fn alloc(&self, layout: Layout) -> *mut u8 { ALLOCS.fetch_add(1, Ordering::Relaxed); BYTES.fetch_add(layout.size(), Ordering::Relaxed); // the missing line System.alloc(layout)}String::from("0123456789") requests at least 10 bytes, so the delta prints 10 (it can be larger
if the allocator rounds the request up; the count is exact for what was requested, which is the
point). Without the missing line, BYTES never moves and the delta is 0 even though a real
allocation happened: a silent measurement that always reports zero. That is the failure mode the
fetch_add exists to prevent.
Faded: write the counting yourself
Section titled “Faded: write the counting yourself”Now the scaffolding is gone from the body. The dealloc side is given; write the whole alloc
body so it counts both the allocation event and the byte size, then forwards to System. The
#[global_allocator] wiring and main are done; only the alloc body is yours.
> output appears here — press Run
💡 Hint 1
ALLOCS.fetch_add(1, ...), BYTES.fetch_add(layout.size(), ...), then System.alloc(layout) as the final expression (no semicolon, so it’s the return value). Count before you delegate so the counters never lag.Show solution
unsafe fn alloc(&self, layout: Layout) -> *mut u8 { ALLOCS.fetch_add(1, Ordering::Relaxed); BYTES.fetch_add(layout.size(), Ordering::Relaxed); System.alloc(layout) // delegate the real work, return its pointer}vec![0u8; 100] is one heap request of 100 bytes, so the delta prints 100 (the ALLOCS total is
higher, because println! and friends allocate too, and that is exactly why a real measurement uses a
scoped Region to subtract the surrounding noise). You’ve now written clinker’s AccountingAlloc
in miniature: count, then forward. Swap vec![0u8; 100] for String::from("...") or
Vec::with_capacity(512) and watch the bytes track precisely, an exact count, never an estimate.
Retrieval checkpoint
Section titled “Retrieval checkpoint”Connections
Section titled “Connections”That’s the end of Execution & Memory. You’ve gone from “the plan is validated” to the live machine that runs it: closed-enum dispatch, one thread per source feeding bounded channels, buffers that spill, an arbitrator that bounds memory through interior mutability, the unsafe core of the field string, and the tools that measure all of it. The thread tying Planning & Expressions and Execution & Memory together: push proof to the boundary, then run hard inside it, with the type system, the budget, and the benchmarks each enforcing a different guarantee. Extending & Contributing turns you from reader to contributor: adding an operator, a format, a CXL builtin, and passing the review gauntlet.