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 |
The 1-D detector's 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.
9. The framed face costs what the hand loop costs (2026-10-08)¶
Question. The framer is the drain loop every consumer used to write, written
once. Owning the loop must not make the stream slower (design goal G6 of
docs/design/spectrogram.md).
Method. bench_buffer_core, the two pairs of rows added beside the
streaming row. Both members of a pair run in the same round, back to back, on
the same ring: the hand-written loop (write_some, then peek a frame and
consume the hop while one is there) and the framer (framer_feed, then
framer_next). Input arrives in 3000-sample chunks, the frame is 1024 samples,
and each frame is summed so the read is not optimised away. The numbers are the
minimum over the rounds, as the benchmark reports.
| frame | hop | hand-written loop | framer | ratio |
|---|---|---|---|---|
| 1024 | 1024 (no overlap) | 0.356 ns/sample | 0.356 ns/sample | 1.00x |
| 1024 | 256 (75% overlap) | 1.190 ns/sample | 1.190 ns/sample | 1.00x |
At 75% overlap each sample is read four times, so the absolute cost rises; the point is that the pair does not move. Five further paired runs at the second hop gave ratios of 1.00x to 1.01x.
Caveats. Unpinned to a core, a Release build without -march=native, and
the machine was not quiet: load average 6.1 during the last runs. The ratio of
two members of one round is what survives that; the absolute figures do not,
and are not the claim. The first measurement of this entry (hop = frame only)
was taken at load 2.8 and read 0.355 against 0.356.
What it does not say. Nothing about nfft other than 1024, nor about
chunks smaller than the frame, where the carry rather than the loop dominates
(the spectrogram's U1 and U2 are about exactly that).
10. A harness that could not see the bound it certifies (2026-10-08)¶
What happened. The framer's certification measures at scale what
test_framer_core.c pins at a readable size. It was proved the way every pin
is, by breaking the code under it and requiring a count to move. Six of the
sabotages moved one. The seventh did not: with feed's bound off by one
(a_max = n + max_frames * hop instead of n - 1 + ...) every number the
report asserts stayed zero, and the certification would have passed a
framer that the unit test, in the same run, failed.
Why. The claim is "feed admits input only as far as the frames it yields
fit the room it was given". The harness drained with while (next), which
takes every frame that is available whatever the room. Frames, order, carry
after the drain and samples accepted were all still correct; only a comparison
of the frames one feed made available with the room that feed was given
could tell, and nothing in the harness made it. The unit test did
(got <= 1), which is the only reason the hole was findable by comparison.
The fix. The harness counts, per feed, the frames drained against the room
offered (over_room), the report prints it, and the report has a limit for it.
The same sabotage now reads 636 feeds over room, and ignoring the room
entirely reads 298.
What the next validator author should take from it. A sweep that drains unconditionally is blind to every claim about when something is admitted. A harness is an instrument, and an instrument is proved the same way a test is: sabotage the code and require the number to move. Do it for every limit, not for a sample of them, and expect the first one that stays at zero to be about a promise the instrument's own loop keeps on the code's behalf.