The ring buffer — measurements and the record¶
The dated companion to The Ring Buffer, which states what
is. This page keeps what was measured, in the order it was measured, and
the guesses that were wrong — section numbers are shared between the two, so
§4 means the same thing on both.
All timings: AMD Ryzen AI 9 465, GCC 16.2.1, -O3 -march=x86-64-v2 -ffast-math, bench_buffer_core, pinned to a Zen 5 core
(taskset -c 0-3,10-13; unpinned, this part's two core classes differ 1.6×
and the rows below are not comparable — see make bench-interleaved).
0. How the non-blocking surface came to exist (2026-09-20)¶
The work started as a binding migration and three of its premises were wrong.
| assumed | measured |
|---|---|
"The C API returns a bare NULL, so the reasons must move into C." |
They were already there: wait() returns NULL for n > capacity (checked first, #1335), for closed-and-drained, and for an interrupt, and a caller tells them apart with closed(), dp_interrupted() and n against capacity. The Python binding merely re-derived that, three times. What was missing was one owner of the precedence — wait_status(). |
"The blocking wait() is the ring's main interface." |
No C consumer calls it. Only the tests and Python do. |
| "Migrating the binding is the work." | The larger defect was in C: the surface every real consumer needs did not exist. |
Every C consumer is single-threaded and rebuilt that surface from the struct:
| private copy of… | acq | detector | detector2d | burst_capture |
|---|---|---|---|---|
free space (capacity - (h - t)) |
✔ | ✔ | ✔ | — |
| clamped partial write | ✔ | ✔ | ✔ | ✔ |
frame pointer data + (t & mask) * 2 |
✔ | ✔ | ✔ | ✔ |
reset by storing head / tail |
✔ | ✔ | ✔ | ✔ |
direct head / tail loads + stores |
10 | 10 | 10 | 9 |
detector_push() is write_some() + while (peek (n)) … consume (n),
written out by hand. Moving the four onto the API, with a lint rule against
struct access, is #1426.
1. Wrapping is free¶
write_wait_consume[f32,chunk=1024] 0.328 ns/sample 3050 Msample/s never straddles
write_wait_consume[f32,chunk=1000] 0.329 ns/sample 3043 Msample/s usually does 1.00x
write_wait_consume[f64,chunk=1024] 0.690 ns/sample 1450 Msample/s
write_wait_consume[f64,chunk=1000] 0.710 ns/sample 1408 Msample/s 1.03x
write_wait_consume[i16,chunk=1024] 0.218 ns/sample 4584 Msample/s (first i16 row)
write_wait_consume[i16,chunk=1000] 0.222 ns/sample 4508 Msample/s 1.02x
f64 costs 2.1× f32 and i16 0.66×: the ring's cost is per byte, which
is what a memcpy plus a read should be. Adding the five functions did not
move the existing rows (they are separate static inlines; nothing on the
write / wait / consume path changed).
4. What the non-blocking surface costs¶
write_some_peek_consume[f32,in=3000,frame=1024]: 0.354 ns/sample, 1.08×
the write + wait reference. The bench's first comment said it "should cost
nothing". It does not, and the 8% is two effects, separated by changing one
variable (three runs each, identical to the digit):
| input chunk | frame | ratio | what it is |
|---|---|---|---|
| 1024 | 1024 | 1.03× | the loop: each drain ends on one peek() that returns NULL — ~10 ns against a ~340 ns frame. The price of not knowing the chunk size. |
| 3000 | 1024 | 1.08× | the other 5% is cache geometry: 24 KB is written before any is read back, where the reference reads each 8 KB while it is hot. No API removes that. |
5. The tests, and the sabotage that was missed¶
Eight sabotages of the new functions, run against a scratch copy of the
header: space off by one, write_some ignoring the room, peek returning
without checking, wait_status with closed and interrupted swapped, with
no too-large case, reset keeping closed, reset clearing dropped — all
red. One survived the first pass: write_some counting a drop on a full
ring left the suite green, because dropped was asserted before the
full-ring call rather than after it. The assertion was added and the sabotage
then failed at test_buffer_core.c:441.
6. consume() is bounded after all, and two threads (2026-09-21)¶
§6 of the design page used to list "no bounds check in consume()" among the
things the ring deliberately does not do. This is why it no longer does.
Both were listed as unknown. They were one question: the bound reads the producer's index on the consumer's release path, which is a cross-core cache line — if the consumer does not already hold it.
It does. The wait() or peek() that precedes every release has just loaded
head, so the bound re-reads a line the core pulled a few instructions ago.
Measured as an A/B on one header, producer and consumer on separate cores
(taskset 4 and 2), wait + consume at a 64-sample frame — the
release-heaviest shape, where memcpy cannot hide a per-call cost — 200 M
samples per run, alternating builds, five each:
consume() |
min ns/frame | the five runs |
|---|---|---|
| unbounded | 53.30 | 56.36, 53.30, 54.85, 54.84, 53.88 |
bounded (n <= available) |
53.32 | 53.46, 53.32, 53.72, 53.64, 53.39 |
No difference; the run-to-run spread (±1.5 ns) is fifty times the gap. One
thread says the same: write_wait_consume[f32,chunk=1024] 85.9 → 85.9 µs over
six alternations. So consume() is bounded, in the header, as the one
implementation (#1424).
It was not an optimisation question in the end. Unbounded, the tail could pass
the head; space() then exceeds capacity, write() believed it, and copied
past the mapping. From the Python face that was two lines —
buf.consume(1_000_000) then a large write() — and a SIGSEGV. write() and
write_some() now also refuse to copy more than capacity whatever the
indices claim: one compare against a field already loaded, measured at no
cost over five alternations.
The two-thread row is two_thread_write_wait_consume[f32,frame=64] in
bench_buffer_core: 17.9 ns/frame, ~3.6 GSa/s unpinned. That is three
times faster than the pinned A/B above, and the difference is placement:
left alone the scheduler puts the pair on SMT siblings that share a cache,
while cores 2 and 4 do not. Quote either number only with its placement.
7. Any capacity, and what it costs (2026-09-21)¶
The ring required a power-of-two capacity and rounded a sub-page one up, reporting the larger number. Both were the mapping's constraints leaking into the contract: a mask needs a power of two and a page mirror needs whole pages, but neither is about how many samples the caller wants held.
capacity and mask were already separate fields and every guard already
used capacity, so honouring the request needed no change to the index
arithmetic — only create() deciding the two numbers separately, and
sync() / destroy() sizing the mapping from mask + 1 rather than from
capacity.
Measured before writing it, with a 1024 mapping whose capacity was set to 1000 by hand:
model walk, 2 M write_some / peek + consume, 273,388 wraps |
0 disagreements |
| two threads, frame 250, capacity 1024 (min of 4) | 98.77 ns/frame |
| two threads, frame 250, capacity 1000 over the same mapping | 98.70 ns/frame |
So the cost is address space alone: under 2× in the worst case (a capacity one
past a power of two), 2.4% for 1000, none for a power of two that spans a
page. next_pow_two is doppler's own (util/util_core.h), called once in
create().
Two consequences worth recording. A file-backed ring is recognised by its
mapped size and the file does not record the capacity, so two requests that
round to one mapping re-attach the same file — the caller knows how much of it
was in use. And detector's state blob is sized from ring->capacity, so it
used to depend on the page size of the machine that wrote it; it no longer
does.
The unmap was the one place this could go wrong in silence — sized from
capacity it frees about half of a non-power-of-two ring and nothing fails —
so the C test cycles a 65,537-sample ring 200 times and checks the process did
not grow. That sabotage was invisible to every other test.