The Spectrogram — the measurement record¶
The dated record behind the design page: what each step of
its §5 measured, in the order it was measured, with the method, the numbers,
the caveats and the wrong guesses. The design page states what is; this page
is why. Section numbers are shared — a §5.1 cited in an issue, a harness or
a code comment is the same entry here and there — and nothing is rewritten
after the fact: a later entry corrects an earlier one in its own words.
Every number comes from something re-runnable: a program, a harness under
native/, or a script the entry names. Where a number was taken on a machine
that was not quiet or not pinned, the entry says so beside the number.
All timings: AMD Ryzen AI 9 465, GCC 16.2.1, a Release build with
DOPPLER_NATIVE=OFF (no -march=native), not pinned to a core, load
average 1.8–2.8 while they ran. This part's two core classes differ, so an
unpinned number is a statement about this run, not a ceiling — the
comparisons below are therefore between builds measured interleaved in the
same minutes, and read as ratios and medians, never as absolutes.
5.1 What was measured (2026-10-08) — promoting PSD's kernel changed nothing¶
Question. Splitting psd_fold_frame into a shared transform and a shared
dB conversion, so that dp_psd_frame_power / dp_psd_frame_db run the code
accumulate runs, must not change one bit of what PSD returns. The
existing PSD tests are not enough evidence for that: they check tolerances and
shapes, not bytes.
Method. A small C program linked against two builds of the library —
the one before the change and the one after — that dumps every readout to
stdout. For three windows (Hann, Kaiser β=7.5, Blackman-Harris), three lengths
(64, 100, 257 — the last two not powers of two), two zero-pad factors, and all
four averaging modes, it feeds a seeded stream of complex frames and then the
same length of real frames, and writes psd_db, psd_dbhz, power_twosided,
power_onesided and noise_floor. 72 configurations, 357,984 bytes. Run
against each library; compared with cmp.
Result. Byte-identical.
The check on the check. A comparison between two builds proves nothing if
they are the same build. nm finds dp_psd_frame_db in the second library
and not in the first, so they differ in exactly the symbols the change added.
What this does not cover. The new functions have no "before" to compare
with; they are pinned instead by C tests that require
dp_psd_frame_db to equal accumulate-then-psd_db bit for bit for every
window, with and without zero-padding, and each of those tests was shown to go
red when the code under it was broken (a frame that skips the zero-pad, a dB
conversion that divides by the gain instead of its square, a rectangular window
that is really Hann, a kernel that touches the average).
5.2 What was measured (2026-10-08) — accumulate is bimodal, before and after¶
Question. Did the refactor slow the PSD hot path?
Method. bench_psd_core, row accumulate[nfft=1024], 12 runs of each
library, alternating, minimum over the run's own rounds as the bench reports.
Result.
| build | min | median | max |
|---|---|---|---|
| before the change | 1.78 ns/sample | 1.79 | 2.70 |
| after the change | 1.77 ns/sample | 1.79 | 2.72 |
Both builds fall into one of two modes, about 1.79 and about 2.71 ns/sample, from run to run, in the same proportions. That is a memory-layout effect (which of the working buffers share cache sets depends on where the allocator put them in that process), and the unchanged library shows it, so it is not the change. The first three comparisons of this entry happened to land the new build in the slow mode twice and the old in the fast mode every time, which read as a 50% regression until twelve interleaved pairs were taken.
Lesson kept. Three runs of a bimodal benchmark is a coin toss. A claim about a hot path needs enough interleaved pairs to see both modes in both builds, and is stated as medians.
5.3 The prototype (2026-10-08)¶
What it was. A numpy implementation in a scratch directory, not committed:
a one-shot reference (rows_oneshot), an incremental object with a carry of
fewer than nfft samples (Inc), and its flush. It ran before any C.
Questions and answers.
- Does the carry reproduce the one-shot rows for every split? Seven shapes
(
nfft/hop: 8/8, 8/3, 8/1, 16/5, 64/16, 7/7, 1/1) × seven input lengths (0, 1,nfft−1,nfft,nfft+1, 3nfft+5, 1000) × seven chunkings (1, 7,nfft−1,nfft,nfft+1, 3nfft+5, and random), compared for row count and bit-exact values. Zero disagreements. - Is the flushed row on the hop grid? The row it emits equals row k of a one-shot run over the input zero-padded to that row's end, and exists exactly when the input holds a sample the earlier rows did not cover. 49 shape × length cases. Zero disagreements. (392 cases in all, with the chunking half.)
- How wrong is a spectrogram built from
fftplus10·log10|X|²? A unit tone on a bin, 1024 points: it reads 54.18 dB with a Hann window and 60.21 dB with a rectangular one, against 0.00 dB under the PSD convention. The gap is exactly20·log10(Σw)in both. A second kernel would not be a rounding disagreement withPSD; it would be the wrong level by the window's coherent gain, and different for every window.
The wrong guess. The first run of the prototype printed 140
disagreements out of 392 and the first reading was that the contract had a
subtlety — a carry that was off for some split. Classifying them showed the
carry was right in every case: the comparison treated an empty result of
shape (0,) and one of shape (0, nfft) as different, and the flush check
indexed a one-shot run that had no row k when the input held none. Both were
faults in the prototype's comparison, not in the idea. They were fixed in the
prototype, not argued away, and the corrected run is the 392-and-zero above.
That is also what a throwaway prototype is for: it found a bug in its own
test before a C test could inherit it.
What it settled. The contract in the design page's G1 and G2 is
satisfiable by a carry of fewer than nfft samples; and G3 is a requirement,
not a nicety, so PSD's kernel is promoted rather than copied.
What it did not touch. Speed, memory behaviour, threading and the ring: it was numpy. The unknowns U1–U6 on the design page are all about those, and none of them has a number yet.
5.4 The dB floor, measured (2026-10-10) — U5¶
Question. The design's U5. PSD's dB conversion clamps power at 1e-20
before the logarithm (PSD_FLOOR, psd_core.c), so no row reads below
−200 dB. How do an all-zero row and a near-zero one read, and does a caller
need to tell them apart?
Method. native/validation/spectrogram_certify.c:
make build BUILD_TARGET=validate_spectrogram_certify, then
./build/native/validation/validate_spectrogram_certify (--emit writes the
same numbers as CSV). Rows at nfft 1024, under each of the four windows
(Kaiser at β 8), of three inputs. The first is an on-bin tone (bin 37) of
power L dBFS, one row per level. The second is an all-zero frame. The third
is seeded complex Gaussian noise of total power L dBFS, 256 frames per
window and level, because one frame's median scatters by about 0.2 dB. The
run is seeded and untimed, so a re-run of the same build reads the same
values. Intel Core Ultra 7 355 under WSL2, GCC 15.2.0, on main 1ea1cca0b plus
this entry's commits. The harness measures and decides nothing; until the
certification (#1941's A4, step 5) turns these numbers into limits, nothing
asserts them.
A tone reads its level down to the floor, then the floor. The tone's bin reads L to four decimals under every window, from −120 to −200. At −201, −205, −210 and −250 it reads −200.0000, and so does every bin of the all-zero frame (1024 of 1024, under every window). A tone below −200 dBFS and digital silence make the same row, bit for bit.
What else a tone row shows is the window's leakage. PSD's windows are symmetric (length N − 1 in the cosine), so they are not orthogonal on the N-point grid (#2053): an on-bin tone under Hann leaks −6.0 dBc into bins ±1, then −69.7, −78.2 and −83.7 dBc into ±2, ±3 and ±4. At −120 dBFS the first three pairs are above the clamp and ±4 is under it, so exactly 7 bins of 1024 rise off the floor (1017 at −200). Blackman-Harris's main lobe spans ±3 bins, so it raises the same 7, and Kaiser β 8's sidelobes on the grid raise 25: both counts are what a symmetric window's DFT predicts above −80 dBc. Only the rectangular window is orthogonal on the grid, and only its tone bin rises (1023 at −200).
Wideband noise reaches the floor sooner, by about 10·log10(nfft). A
frame of complex noise spreads its power over the bins. Against PSD's tone
reference each bin's power is exponential with mean μ = P·ENBW/n, so its
median is L + 10·log10(ENBW/n) + 10·log10(ln 2) dB. From each window's own
ENBW at nfft 1024, that is L − 31.7 for rect, L − 29.9 for Hann, L − 29.5 for
Kaiser β 8 and L − 28.7 for Blackman-Harris. Where the median sits clear of
the clamp (expected above −199.5 dB), the mean of 256 frames' medians is
within 0.02 dB of the formula for every window. That is well inside the
0.46 dB that separates Hann from Kaiser, so the windows' ENBW are separately
confirmed. The same model gives the share of bins at the clamp,
n·(1 − exp(−10⁻²⁰/μ)), and the measured mean is within 2.3 bins of it
everywhere:
| L, dBFS | Hann | Kaiser β 8 | Blackman-Harris | rect |
|---|---|---|---|---|
| −150 | 7.2 (7.0) | 6.4 (6.3) | 5.0 (5.2) | 10.8 (10.4) |
| −160 | 67.1 (67.5) | 60.7 (61.0) | 51.0 (51.0) | 99.8 (99.7) |
| −170 | 505.9 (506.3) | 470.9 (469.9) | 411.1 (409.3) | 658.5 (656.2) |
| −175 | 905.3 (905.5) | 876.1 (877.2) | 821.1 (820.1) | 983.4 (983.8) |
| −180 | 1022.8 (1022.9) | 1021.9 (1021.8) | 1017.8 (1017.8) | 1024.0 (1024.0) |
Bins of 1024 reading exactly −200 dB: the mean of 256 frames, and the model's expectation in brackets.
So noise of total power −180 dBFS leaves, on average, about 1 bin of 1024
above the floor under Hann, 2 under Kaiser, 6 under Blackman-Harris and none
under rect, and at −190 none under any window. These are averages, not
bounds: one frame scatters around them (up to 15 bins at the clamp at
−150, where the mean is 7). The offset is 10·log10(ENBW/n), so the level at
which noise vanishes rises 3 dB with each doubling of nfft. That follows
from the formula; this entry does not measure other sizes.
The falsifier fired. U5 rested on the guess that nothing a caller feeds
a spectrogram is that small. Float sources in the tree make such samples. A
wfm source's level is in dBFS with only an upper bound (wfm_compose.h,
the source's level, <= 0), and the composer applies it as a gain of
10^(level/20) (wfm_compose.c, level → gain). So a wfm noise source at
level −190 is a valid input, and its rows at nfft 1024 can't be told from
silence. float32 holds such samples as ordinary normal numbers (its smallest
normal is an amplitude of about −759 dBFS), so nothing upstream rounds them
to zero first.
The answer to U5. By design, for the dB face. A logarithm has to stop
somewhere, and the clamp is where it stops; what was missing was a statement
of it. A caller that has to tell digital zero from a signal under the floor
reads the samples, or the linear row once mode = power is enabled (#1968).
The clamp lives in the dB conversion alone: dp_psd_frame_linear, power
mode's path, applies none. The linear row has float32's own floor instead: a
bin's power goes subnormal below about −379 dB and rounds to zero below about
−449 dB, so it keeps zero and tiny apart down to there.
5.5 What callers want at the end of a stream (2026-10-10) — U6¶
Question. The design's U6. flush is explicit; does any caller want it
implied by closing the source?
Method. A survey of main at 1ea1cca0b, with nothing timed. It covers
every call of dp_spectrogram_push or dp_spectrogram_flush outside the
tests and the benchmarks, every loop #1894 plans to port onto the object,
the stream readers in the tree, and what each transport promises about its
end-of-stream marker.
Callers. One: native/examples/spectrogram_demo.c. Its stream is its
own buffer, so it knows where the stream ends. It flushes explicitly, then
checks that a second flush owes nothing. The loops #1894 lists for porting
don't flush at all. wfm_composition_demo.py and dsss_burst_demo.py each
build their rows over a whole capture with range(0, len(x) - nfft, hop),
which stops before the last frame. specan's two re-blockers feed a live
display.
What the transports promise. End of stream is a marker, and how
reliable it is depends on the transport (stream.h, dp_stream_send_eos,
"What it does NOT promise"; io-termination.md, on PUSH/PULL):
| transport | the end-of-stream marker |
|---|---|
PUB/SUB (dp_sub_recv) |
at-most-once: it can be dropped like any frame, so the reader never sees it |
PUSH/PULL (Pull.recv) |
at-least-once: it can arrive twice, and in a pool it reaches ONE consumer |
What the readers do with it.
| reader | on the marker |
|---|---|
C dp_sub_recv (stream.h) |
returns DP_ERR_EOF |
Python Subscriber.recv, Pull.recv |
raise EOFError |
native/examples/spectrum_analyzer.c |
if (rc != DP_OK) continue;: carries on as after a timeout |
specan's SocketSource.read, PullSource.read |
catch the EOFError and return what is buffered, as on a timeout |
specan's FileSource.read |
none: at end of file it seeks to the start and loops |
The answer to U6. flush stays explicit, because only the caller knows
where its own stream ends, and the transports' marker is not reliable enough
to imply it. An implied flush behind a dropped PUB/SUB marker would never
come, losing the last row; behind a duplicated PUSH/PULL marker it would
come twice, the second time a zero-padded row in the middle of the next
producer's stream. The two live readers above already treat the marker as
a pause, which suits a display that has no use for a last row. "Implied by
closing the source" also has no home in the object: the Spectrogram never
learns where its samples came from (§7 of the design), so only the code that
composes a source with it could join the two, and that code is the caller.
5.6 What the carry's copy costs (2026-10-10) — U1¶
Method, shared by 5.6 to 5.9. One run of
make bench-interleaved VERSION=0.66.0-a4 K=5 at c1ae84190, a commit
on main, on cachyos-x8664-ai465:
- The machine: AMD Ryzen AI 9 465, kernel 7.2.9-2-cachyos, governor
performance, boost on, platform profileperformance, GCC 16.2.1. - Pinning: every measurement ran on cpus 0–3 and 10–13, the fastest
core class (
bench_report.fastest_cpus). The builds ran unpinned. - Two Release builds, run alternately, five passes each: portable
(
-O3 -march=x86-64-v2 -ffast-math) and native (-O3 -march=native -mprefer-vector-width=256 -ffast-math). Every pair of cells below is portable / native. - When: 2026-10-10, 09:15 to 10:24 UTC. No other job was started on the
machine. One
pssample during the first pass showed the bench as the only process using a measurable share of a core. Load was not logged through the run. - The rows are
bench_spectrogram_core'spush,directandchunkrows andbench_psd_core'sfft,frame_powerandframe_dbrows (#2061).
The data and the arithmetic are committed. bench_interleaved.py keeps
only each row's lowest-mean pass and deletes the per-pass snapshots with its
worktrees, so the ten were saved as they were written. They are in
docs/design/spectrogram-measurements/u1u4/, filtered to the spectrogram
and PSD rows. Every table in 5.6 to 5.9 is the output of
docs/design/spectrogram-measurements/u1u4.py, run on them. Its --check
is in make docs-invariants, so a cell cannot be edited by hand.
- A row's cost in one pass is the bench's per-pass minimum per unit (sample or frame).
- A cell is the median of that over the five passes.
- A spread is (max − min) / median over the same passes.
The decision rules were set before the run, in A4's plan as agreed with the coordinator, but were not committed to the tree until now:
- U1 closes if push/direct is at most 1.05 at every shape, and is falsified if the ratio scatters more than 5% across passes.
- U2's model gets no refit and must hold to 10%.
- U3's dB conversion "dominates" above 50% of a row.
- U4 must agree with U3 times U1 to within 10%.
What was measured. push is the object, fed the whole block. direct
is a hand-written loop that calls dp_psd_frame_db on x + k·hop, with no
ring, carry or copy, and it refuses to run unless its rows equal push's bit
for bit. Each pair runs adjacently, in an order swapped every round.
| nfft | hop | push, ns/sample | direct, ns/sample | push − direct | push/direct | worst pass | ratio spread |
|---|---|---|---|---|---|---|---|
| 256 | 64 | 27.319 / 26.025 | 27.222 / 25.967 | 0.078 / 0.117 | 1.003 / 1.005 | 1.004 / 1.007 | 0.3% / 0.5% |
| 256 | 256 | 6.904 / 6.617 | 6.856 / 6.529 | 0.087 / 0.087 | 1.013 / 1.013 | 1.015 / 1.016 | 0.8% / 1.2% |
| 1,024 | 256 | 27.787 / 26.391 | 27.646 / 26.282 | 0.141 / 0.109 | 1.005 / 1.004 | 1.010 / 1.006 | 0.6% / 0.3% |
| 1,024 | 1,024 | 7.111 / 6.786 | 7.019 / 6.718 | 0.106 / 0.086 | 1.015 / 1.013 | 1.017 / 1.015 | 1.3% / 0.7% |
| 4,096 | 1,024 | 27.540 / 26.002 | 27.437 / 25.919 | 0.103 / 0.058 | 1.004 / 1.002 | 1.005 / 1.003 | 0.7% / 0.3% |
| 4,096 | 4,096 | 7.406 / 7.038 | 7.276 / 6.887 | 0.138 / 0.123 | 1.019 / 1.017 | 1.023 / 1.023 | 0.8% / 0.6% |
| 16,384 | 4,096 | 27.003 / 25.455 | 26.837 / 25.314 | 0.166 / 0.144 | 1.006 / 1.006 | 1.008 / 1.008 | 0.5% / 0.4% |
| 16,384 | 16,384 | 7.756 / 7.333 | 7.595 / 7.249 | 0.178 / 0.119 | 1.024 / 1.017 | 1.025 / 1.027 | 1.0% / 2.4% |
| 65,536 | 16,384 | 28.268 / 26.465 | 28.141 / 26.307 | 0.108 / 0.158 | 1.004 / 1.006 | 1.005 / 1.010 | 0.3% / 0.6% |
| 65,536 | 65,536 | 8.106 / 7.711 | 8.005 / 7.433 | 0.101 / 0.243 | 1.013 / 1.033 | 1.018 / 1.037 | 0.6% / 2.2% |
push − direct is the median of each pass's difference, not the difference of the two medians beside it. Worst pass is the largest single pass's ratio.
The answer to U1: closed, no bypass. The carry's copy costs 0.06 to 0.24 ns per sample. The median ratio is at most 1.024 in the portable build and 1.033 in the native one. The worst single pass anywhere is 1.037 (native, 65,536/65,536), so the 1.05 rule holds at every shape in every pass. The ratio scatters at most 2.4% across passes, so the falsifier did not fire.
The copy weighs more at hop = nfft (1.3–3.3%) than at nfft/4
(0.2–0.6%). A quarter hop computes four rows per nfft samples, while each
sample is copied once either way.
The absolute rows move more than their ratio: up to 4.2% and 5.6% across passes. That is the machine drifting under both rows of a pair at once, and it is why the pairs are adjacent and the ratio is the quantity decided on.
5.7 Row latency, against the chunk size (2026-10-10) — U2¶
In samples, there is none. A row comes back with the push that delivers
its last sample. The C test pins that (test_spectrogram_core.c §15), and
the certification asserts it over every one-sample partition.
In time, it is the cost of that push. The model, fixed before the run, has two parameters:
- c_s, the per-sample cost of the one-block push;
- c_p, the per-push overhead, read from the chunk-1 row as its per-sample cost minus c_s.
A chunk of C samples should then cost c_s + c_p / C per sample.
| c_s, ns/sample | spread | c_p, ns per push |
|---|---|---|
| 27.787 / 26.391 | 3.7% / 3.1% | 4.640 / 4.607 |
| chunk | measured, ns/sample | predicted c_s + c_p/C |
error |
|---|---|---|---|
| 1 | 32.427 / 30.998 | (defines c_p) | — |
| 256 | 27.812 / 26.443 | 27.805 / 26.409 | +0.0% / +0.1% |
| 1,024 | 27.875 / 26.492 | 27.792 / 26.396 | +0.3% / +0.4% |
| 16,384 | 27.828 / 26.374 | 27.788 / 26.391 | +0.1% / -0.1% |
The answer to U2, at hop 256. The model holds to 0.4% with no refit,
inside its 10%. Read that for what it tests:
- The 0.4% tests only c_s. From 256 samples on,
c_p / Cis at most 0.018 ns, under 0.07% of a sample's cost. - The chunk-1 row alone carries c_p, about 4.6 ns per push, and that row also defines it. So one sample per push costs 17% more per sample than one block, and from 256 samples on the chunk size is invisible.
A row is ready when the push carrying its last sample returns. For a chunk
that completes one row, that push is mostly the row itself: about 7 µs at
nfft 1024 (entry 5.8's frame_db).
What this does not cover. This run had chunk rows at nfft 1024 and
hop 256 only (#2061), so U2 against hop is unmeasured. The bench now
has them at hop 64 and 1024 too, beside U1's nfft/2 rows. The 32-row cap
that kept them out went with the jm_bench.h re-vendor. They are measured in
2062's run on this machine: entry 5.15.¶
5.8 Where a row's time goes (2026-10-10) — U3¶
What was measured. bench_psd_core times PSD's per-frame kernel three
ways per nfft, in one interleaved loop:
fft: PSD's owndp_fft_create (nfft, -1, 1)anddp_fft_execute_cf32.frame_power: the window, the FFT and the power fold.frame_db: all of that, then the dB conversion, which is the Spectrogram's row.
The shares below are per-pass differences, as fractions of frame_db.
| nfft | fft, µs | frame_power, µs | frame_db, µs | the FFT | window + power | dB conversion | dB, ns per bin | frame_db spread |
|---|---|---|---|---|---|---|---|---|
| 256 | 0.244 / 0.201 | 0.355 / 0.294 | 1.707 / 1.696 | 14.5% / 11.8% | 6.6% / 5.5% | 78.9% / 82.7% | 5.27 / 5.47 | 4.0% / 1.7% |
| 1,024 | 1.182 / 0.922 | 1.614 / 1.301 | 6.990 / 6.911 | 16.9% / 13.4% | 6.1% / 5.5% | 76.9% / 81.2% | 5.25 / 5.48 | 3.4% / 1.4% |
| 4,096 | 5.572 / 4.343 | 7.232 / 5.776 | 28.630 / 27.885 | 19.5% / 15.6% | 5.8% / 5.2% | 74.7% / 79.3% | 5.22 / 5.40 | 3.2% / 1.0% |
| 16,384 | 25.841 / 20.195 | 32.639 / 26.384 | 117.832 / 114.521 | 22.1% / 17.6% | 5.7% / 5.4% | 72.2% / 77.0% | 5.20 / 5.38 | 3.2% / 1.0% |
| 65,536 | 119.132 / 95.749 | 149.840 / 124.383 | 492.000 / 478.422 | 24.2% / 20.0% | 6.1% / 6.0% | 69.6% / 74.0% | 5.21 / 5.41 | 2.9% / 1.6% |
The answer to U3: the dB conversion dominates. It is 69.6–82.7% of a
row at every size, at a nearly constant 5.2–5.5 ns per bin. Per bin, the
conversion (psd_read_power, psd_core.c) is a divide by the reference, a
clamp at the floor, a double-precision log10 and a cast to float. The
log10 is the likely bulk, but this bench does not separate it from the
other three.
The FFT is 12–24% of a row. The native build makes the FFT faster but not the conversion, so the conversion's share grows there.
By the design's own rule, a cheaper conversion would change what the default output mode should be. That is a decision about PSD's dB face, which every PSD reading and both certifications pin byte for byte, so it is filed rather than made here: #2074. Power mode (#1968) skips the conversion altogether.
What this cannot separate. frame_power − fft is the window and the
power fold together. As built, the bench cannot split them, and the
difference understates them. frame_power's FFT runs on PSD's scratch,
which its own window loop has just written, while the fft row reads the
caller's frame cold. That matters most at 65,536, where the frame is 512 KB.
5.9 What one core sustains (2026-10-10) — U4¶
At nfft 1024 and hop 256 (75% overlap), one core of the fastest class
sustains this. Rates are rounded to the push row's spread.
| build | push, ns/sample | samples per second | rows per second | frame_db/hop × push/direct |
push against it |
|---|---|---|---|---|---|
| portable | 27.79 (±3.7%) | 36 M | 141,000 | 27.44 | +1.3% |
| native | 26.39 (±3.1%) | 38 M | 148,000 | 27.11 | -2.6% |
About the cross-check. It is U3's frame_db per row, divided by the hop
and multiplied by U1's push/direct. The measured push is within +1.3% /
−2.6% of it, inside the 10% the plan set. But it is not independent:
push / (frame_db/hop × push/direct) reduces to direct·hop / frame_db.
That is two benches timing one kernel, bench_spectrogram_core's hand loop
and bench_psd_core's row. What it shows is that the two benches agree, not
that the push is right.
What that leaves a display. At a stream of f samples per second, the Spectrogram takes about f × 28 ns of one core: 28% at 10 MSa/s, and all of it near 36 MSa/s. Three quarters of that is the dB conversion (U3), so a display that reads power rows, or converts only the bins it draws, keeps most of the core.
Kept in mind when these rows are compared next. The existing spectrogram
rows (push[nfft=…,hop=…]) are measured here in a new setting: 29
configurations interleaved, push_max_out outside the timer, and each chunk
given exact room. So a step on unchanged row names at the next release can
be the method, not the code. This run's merged set
(benchmarks/published/v0.66.0-a4/) stays on the measuring machine. It is a
characterization, not a release's published numbers.
5.10 The Spectrogram holds the kernel, not a PSD (2026-10-12)¶
Question. Moving PSD's per-frame state into dp_psd_kernel_t, and having
the Spectrogram hold only that, must change no output and no state blob of PSD
or of anything built on it, and should free what the Spectrogram held and
never used.
Method. As §5.1: one C program, built against two libraries exported with
git archive -- before (7971912e2, the release this change follows) and after
(a5bfe78b3; the commit after it changes docs and a header comment only) --
Release, once at the default flags and once with DOPPLER_NATIVE=ON. It
writes, for PSD, 4 windows × n {64, 100, 257, 1024} × pad {1, 2} × 4
averaging modes × complex and real input (real at bits 12): the state blob
mid-stream and at the end, every readout (psd_db, psd_dbhz, both power
spectra, band power, occupied bandwidth, noise floor, SNR, SFDR) and the three
frame calls. For the Spectrogram, 4 windows × nfft {64, 256, 1024} × hop {0,
nfft/4, nfft−3} × both modes, in ragged chunks: its rows, its flush and its
blobs. Then carrier_acq (3 windows, sequential or not; its blob mid-stream and
at the end), specan (two navg; its display and its blob), the async DSSS
receiver (carrier_acq inside; its symbols and its blob) and the three meters'
results (they keep no state, so they have no blob). 11,827,950 bytes per run,
compared with cmp. The frame calls are spelled (p) against the first
library and (&p->k) against the second; nothing else differs. The whole of it
is one self-contained script, attached to the pull request that made the
change: bash repro.sh REPO 7971912e2 a5bfe78b3 builds both, runs both, and
prints the comparison and the memory below.
Result. Byte-identical, at both flag sets. The native build's output differs from the default build's, so the second comparison is not the first one twice.
The check on the check. nm finds dp_psd_kernel_init in the second
library and not in the first.
Memory. Three counts across one dp_spectrogram_create, after a warm-up
create and destroy at the same size, in bytes: the heap (mallinfo2's
uordblks + hblkhd, the method of async_dsss_pool_soak.c), the rings' memfd
(memfd:dp_shm in /proc/self/maps: a ring is mapped, not malloc'd), and all
address space (VmSize, both of those included):
| nfft | heap before | heap after | memfd before | memfd after | mapped before | mapped after |
|---|---|---|---|---|---|---|
| 256 | 41,792 | 39,728 | 16,384 | 8,192 | 16,384 | 8,192 |
| 4,096 | 704,720 | 655,536 | 262,144 | 131,072 | 868,352 | 720,896 |
| 65,536 | 11,272,208 | 10,485,744 | 4,194,304 | 2,097,152 | 15,466,496 | 12,582,912 |
| 1,048,576 | 180,354,896 | 167,771,952 | 67,108,864 | 33,554,432 | 247,463,936 | 201,326,592 |
From nfft 4,096 up the heap shrinks by 12·nfft: the averager's 8 and the
dB buffer's 4. The memfd mappings halve, 64·nfft to 32·nfft: PSD's ring,
16·nfft of memfd mapped twice, is gone, and the Spectrogram's own stays.
From 65,536 up, where every heap buffer is its own mapping too, all address
space shrinks by 44·nfft (at 4,096 it is 36·nfft). The 28·nfft fewer
allocated bytes is derived, not a fourth count: the heap's 12 plus the ring's
memfd file, 16·nfft, counted once though it is mapped twice. At 256 the
allocator's arenas blur the heap count. These are
allocated and virtual bytes, not resident ones: the dead buffers were never
touched, so their pages were never resident.
Speed. bench_psd_core and bench_spectrogram_core from both builds, run
alternately (before, after; then after, before) for 7 rounds, unpinned; each
row's minimum, and each side's round-to-round spread. No row of either moves
by more than both sides' spread. The largest moves are power_onesided at
nfft 4,096 (+4.7%, a call of about 1 µs whose spread is ±15%) and the
Spectrogram's direct at nfft 256 (+2.9%, spread 5.8%). The kernel's code
is the same apart from field offsets.
The meters' ring, measured (#2211 b, 2026-10-12). PSD's ring stayed where
the Spectrogram's went: the three meters dp_psd_create a PSD and feed it
through dp_psd_accumulate_real, which never touches it, though tonemeas's
complex path does frame through it. One throwaway C program (not committed,
by #2211's ruling), one n per fresh process, on the library at b4c470846
(after #2253), Release, WSL2 on x86-64, two runs agreeing. In kB from
/proc/self/status across one dp_psd_create, then the same PSD after the
meters' path and after one framed complex push; then the ring alone
(dp_f32_create (2n), as create makes it); then the create time, the minimum
of 200:
| n | create VmSize / VmRSS | after accumulate_real VmRSS |
after a framed push VmRSS | ring alone VmSize / VmRSS | create time |
|---|---|---|---|---|---|
| 4,096 | 708 / 576 | 672 | 736 | 128 / 0 | 116 µs |
| 65,536 | 12,564 / 4,756 | 6,920 | 7,944 | 2,048 / 0 | 2.28 ms |
The ring is address space and no resident memory. It is a memfd of 16·n
bytes (2n rounded up to a power of two, 8 bytes each) mapped twice, 32·n
of VmSize, and it stays unfaulted on the meters' path. The first framed push
adds exactly 16·n of RSS (64 kB, 1,024 kB), which is what proves it was not
resident until then. The rest of create's resident memory is the kernel
(window, frame, spectrum, power, plan), the dB buffer and the averager, which
every path needs.
Decision (#2211): the
meters keep the ring. A PSD that makes its ring on the first framed push was
rejected, because dp_psd_accumulate returns nothing. A failed first
allocation there would be a silent drop on the hottest entry point, and the
error convention puts allocation in create(). A restore of almost any used
PSD would allocate too, and DP_ERR_MEMORY would surface as DP_ERR_INVALID
through DP_R_CHILD and as ValueError in Python. A ring-less create mode
with a whole-frame complex twin was not built: it would grow PSD's surface on
every face for a saving of address space. Removing the ring outright means a
meter holds a kernel and an averager instead of a PSD, which belongs with
moving the kernel into a component of its own
(spectrogram.md §4.2).
What this does not cover. One x86-64 machine. On aarch64 and macOS, where
the compiler may contract differently, only the in-build oracles hold it
(#2187). Nor does the
dump reach every path the change touched: PSD is built through
dp_psd_create, so no PSD hop below n; no forged set_state blob; no
refusal path; no NaN input and none at the −200 dB floor; no Kaiser beta but
6; no pad above 2; and no DP_ERR_MEMORY. The unit tests' sabotage covers the
DP_ERR_INVALID refusals only
(#2211).
5.12 Where a PSD frame's time goes (2026-10-10) — the #2094 baseline¶
Method, shared by 5.12 to 5.14. One run of
make bench-interleaved VERSION=0.66.0-b2094 K=5 at c36a042e0, a commit
on main carrying #2113's rows and none of #2094's changes: no SIMD fold
(#2105), no fused passes, and #2117's dB conversion not yet in. It is the
BEFORE of all three, and runs once.
- The machine: cachyos-x8664-ai465, AMD Ryzen AI 9 465, kernel
7.2.9-2-cachyos, governor
performance, boost on, platform profileperformance, GCC 16.2.1. Each was read from sysfs before the launch; the run's mergeddoppler_meta, which records them with both builds' flags, is committed asb2094/meta.json. - Pinning: every measurement on cpus 0–3 and 10–13, as in 5.6. The builds ran unpinned.
- Two Release builds, portable and native, five alternating passes each, with the same flags as 5.6. Every pair of cells is portable / native.
- When: 2026-10-10, 16:46 to 17:56 UTC, under
systemd-inhibit --what=idle, in a detached worktree at exactly that commit. No other job ran on the machine. Each pass's snapshot was copied out as it was written, by a 1 s loop pinned to cpu 19, outside the measured class, and niced. - The data and the arithmetic are committed, as for 5.6:
docs/design/spectrogram-measurements/b2094/, filtered to thepsd::,acc_trace::andspectrogram::rows. Every table here is the output ofb2094.py, whose--checkis inmake docs-invariantsand refuses a pass not measured at c36a042e0 on a clean tree. A cell is the median over the five passes of each pass's minimum per unit, and a spread is (max − min) / median. Both are_record.py's, shared withu1u4.py.
A derived column, a difference or a ratio of two rows, is the median of each pass's difference or ratio, not the difference of the two medians beside it, so recomputing it from the table's own cells can differ in the last place.
accumulate_frame is one frame of dp_psd_accumulate: window, FFT, power,
and the fold into the running mean. frame_power is the same without the
fold. So the window and power pass is frame_power − fft, and the fold
inside the frame is accumulate_frame − frame_power, each per bin.
| nfft | fft, µs | frame_power, µs | accumulate_frame, µs | accumulate / fft | frame_power / fft | window + power, ns/bin | fold, ns/bin | accumulate spread |
|---|---|---|---|---|---|---|---|---|
| 256 | 0.245 / 0.202 | 0.354 / 0.294 | 0.408 / 0.322 | 1.66 / 1.60 | 1.45 / 1.46 | 0.428 / 0.362 | 0.207 / 0.114 | 1.4% / 3.0% |
| 1,024 | 1.180 / 0.920 | 1.616 / 1.300 | 1.828 / 1.395 | 1.55 / 1.51 | 1.37 / 1.41 | 0.424 / 0.370 | 0.210 / 0.093 | 0.9% / 0.9% |
| 4,096 | 5.585 / 4.352 | 7.233 / 5.782 | 8.072 / 6.123 | 1.45 / 1.40 | 1.30 / 1.33 | 0.404 / 0.350 | 0.206 / 0.083 | 0.6% / 0.7% |
| 16,384 | 25.896 / 20.238 | 32.719 / 26.435 | 35.414 / 27.276 | 1.36 / 1.35 | 1.26 / 1.31 | 0.414 / 0.380 | 0.163 / 0.051 | 0.7% / 0.5% |
| 65,536 | 119.147 / 95.756 | 149.955 / 122.737 | 163.072 / 129.554 | 1.36 / 1.36 | 1.26 / 1.29 | 0.469 / 0.423 | 0.200 / 0.101 | 0.9% / 6.3% |
#2094's measure is accumulate / fft: 1.36–1.66 portable, 1.35–1.60
native. The FFT is 60–74% of an accumulated frame. The window and power
pass is 0.35–0.47 ns per bin in both builds, and the fold by difference is
0.16–0.21 portable and 0.05–0.11 native. So the window and power pass is the
larger share of the overhead in both builds, in every pass. The native
fold's smaller cost is consistent with the compiler vectorizing the scalar
Welford loop under -march=native, which was not checked in the binary.
That bounds what each of #2094's changes can buy. A fold that cost nothing
would leave the frame_power / fft column: at 1024, 1.37 portable and 1.41
native (frame_power 1.616 / 1.300 µs), against today's 1.55 and 1.51.
Anything below that has to come out of the window and power pass, which is
PR-c's (fusion), and fusion moves the fft row too. The target #2094 asks
for is set from these numbers in the PRs that change them, not here.
The native 65,536 row's 6.3% spread is not one outlier: its five passes run from 124.8 to 133.0 µs, so that one cell carries about ±3%.
A reading's two steps after the power, at the three sizes #2113 gave a
frame_linear row. Normalisation is frame_linear − frame_power. The dB
conversion and floor are frame_db − frame_linear, and that is the
difference #2117 is judged by, so its own entry uses the same.
| nfft | frame_linear, µs | frame_db, µs | normalisation, ns/bin | dB (frame_db − frame_linear), ns/bin | dB, share of frame_db | frame_db spread |
|---|---|---|---|---|---|---|
| 256 | 0.394 / 0.316 | 1.708 / 1.684 | 0.155 / 0.084 | 5.132 / 5.328 | 76.9% / 81.2% | 3.4% / 3.3% |
| 1,024 | 1.777 / 1.393 | 6.991 / 6.865 | 0.157 / 0.091 | 5.091 / 5.342 | 74.6% / 79.7% | 2.8% / 2.8% |
| 65,536 | 158.897 / 128.963 | 491.389 / 475.657 | 0.138 / 0.092 | 5.066 / 5.306 | 67.6% / 73.0% | 3.1% / 4.1% |
The dB conversion is 5.1–5.3 ns per bin, two thirds to four fifths of a dB
frame, and the native build does not help it: the scalar double log10
gains nothing from -march=native. Normalisation is under 0.16 ns per bin.
Against 5.6's run. Every row the two runs share, by group, is compared
here as its median now over its median then. #2113 changed the timing context
of PSD's three kernel rows (their rotation gained accumulate_frame and
frame_linear) and of the three dB pushes now timed beside a power row, so
those are kept apart and are not like-for-like.
| rows | count | median move | largest move |
|---|---|---|---|
psd::accumulate_real |
3 | +0.04% / +0.00% | +0.05% / -0.07% |
psd::accumulate |
3 | +0.07% / +0.22% | -0.23% / +0.73% |
psd::fft (rotation changed, #2113) |
5 | +0.21% / +0.20% | +0.55% / +0.29% |
psd::frame_db (rotation changed, #2113) |
5 | +0.01% / -0.58% | -0.12% / -0.68% |
psd::frame_power (rotation changed, #2113) |
5 | +0.08% / +0.10% | +0.25% / -1.32% |
psd::power_onesided |
3 | +0.00% / +1.31% | -2.98% / +7.14% |
spectrogram::direct |
10 | -0.25% / +0.56% | -0.49% / +0.76% |
spectrogram::push |
16 | -0.19% / +0.43% | -0.42% / +0.81% |
spectrogram::push, dB beside a new power row (#2113) |
3 | -0.25% / +0.53% | -0.42% / +0.80% |
| all shared rows | 53 |
Every shared row reproduces within 0.81%, except two. frame_power at
65,536 native moves −1.3%. power_onesided, a short reader outside #2094's
rows, moves up to +7.1% native.
power_onesided's +7.1% is one step of the clock: not a changed context,
and not a real shift. Its context did not change. dp_psd_power_onesided
and its bench loop are the same at c1ae84190 and c36a042e0, and #2113's
rotation is the per-frame block that runs after it. The row times one call
per round, so each pass's minimum is a single interval between two clock
reads, and a row's passes differ by whole multiples of a 10 ns step, within
1 ns (the table refuses a row where they do not). At 1,024 bins native a
call takes 140 or 150 ns, so one step is 6.7% of it. In 5.6's run one pass
of five read 150; here three did. That moves the median from 140 to 150 ns,
which is the +7.14%. Portable 4,096's −2.98% is two steps. At 16,384 a step
is 0.4%, and the +1.3–1.5% moves sit inside each run's own 4–7% spread. The
row cannot resolve a move smaller than one step at its size, so it is no
evidence about #2094 either way.
| nfft | build | 5.6's passes, ns | these passes, ns | median move | in steps | one step / median | spread, 5.6 / here |
|---|---|---|---|---|---|---|---|
| 1,024 | portable | 190, 200, 170, 181, 200 | 170, 190, 170, 200, 200 | +0.00% | +0 | 5.3% | 15.8% / 15.8% |
| 1,024 | native | 140, 150, 140, 140, 140 | 150, 140, 150, 140, 150 | +7.14% | +1 | 6.7% | 7.1% / 6.7% |
| 4,096 | portable | 651, 722, 671, 661, 681 | 651, 661, 662, 651, 651 | -2.98% | -2 | 1.5% | 10.6% / 1.7% |
| 4,096 | native | 561, 561, 561, 561, 561 | 561, 561, 561, 551, 561 | +0.00% | +0 | 1.8% | 0.0% / 1.8% |
| 16,384 | portable | 2645, 2585, 2564, 2755, 2725 | 2695, 2605, 2685, 2725, 2675 | +1.51% | +4 | 0.4% | 7.2% / 4.5% |
| 16,384 | native | 2254, 2364, 2334, 2284, 2244 | 2254, 2354, 2354, 2254, 2314 | +1.31% | +3 | 0.4% | 5.3% / 4.3% |
5.13 The fold on its own (2026-10-10) — the #2094 baseline¶
The method is 5.12's. acc_trace::fold is dp_acc_trace_accumulate alone,
over splitmix64 frames, in each of the four modes. This is #2105's
before.
| mode, ns/bin | 256 | 1,024 | 4,096 | 16,384 | 65,536 |
|---|---|---|---|---|---|
mean |
0.205 / 0.081 | 0.206 / 0.078 | 0.200 / 0.077 | 0.199 / 0.080 | 0.199 / 0.085 |
exp |
0.161 / 0.107 | 0.157 / 0.103 | 0.153 / 0.101 | 0.153 / 0.100 | 0.153 / 0.100 |
maxhold |
1.490 / 0.802 | 1.451 / 0.772 | 1.480 / 0.767 | 1.460 / 0.737 | 1.465 / 0.735 |
minhold |
1.465 / 0.771 | 1.439 / 0.764 | 1.463 / 0.763 | 1.461 / 0.758 | 1.450 / 0.744 |
maxhold and minhold are the slow folds, about 1.46 ns per bin
portable and 0.76 native: 7.0–7.5 times mean portable and 8.6–10.0 times
native. mean and exp are flat across sizes.
The fold on its own and the fold by difference inside PSD's frame are two different measurements. The bench header at this commit says the isolated row "is not PSD's accumulate_frame minus its frame_power, and is not meant to be". They are shown side by side, each with its own spread:
| nfft | fold[mean], ns/bin |
its spread | accumulate_frame − frame_power, ns/bin |
its spread |
|---|---|---|---|---|
| 256 | 0.205 / 0.081 | 0.1% / 5.7% | 0.207 / 0.114 | 5.0% / 20.4% |
| 1,024 | 0.206 / 0.078 | 0.3% / 0.8% | 0.210 / 0.093 | 7.4% / 12.7% |
| 4,096 | 0.200 / 0.077 | 0.0% / 0.4% | 0.206 / 0.083 | 1.6% / 11.0% |
| 16,384 | 0.199 / 0.080 | 0.1% / 1.1% | 0.163 / 0.051 | 3.8% / 79.9% |
| 65,536 | 0.199 / 0.085 | 0.0% / 1.9% | 0.200 / 0.101 | 7.2% / 21.4% |
The isolated fold is steady, within 0–5.7% across passes. The difference is
noise-limited, at 1.6–80%, being a small number taken between two larger
ones. So #2105 is judged by the isolated fold rows, not by the difference.
5.14 Power rows against dB rows (2026-10-10) — the #2094 baseline¶
The method is 5.12's. Each power row (mode=power, the default since #1968)
is timed in its dB row's group, in an order rotated every round. This is the
U4-for-power entry moved here from #1968.
| nfft | hop | dB row, ns/sample | dB spread | power row, ns/sample | power spread | dB / power | power, MSa/s |
|---|---|---|---|---|---|---|---|
| 256 | 64 | 27.204 / 26.233 | 4.4% / 3.5% | 6.584 / 5.491 | 2.4% / 7.3% | 4.13 / 4.83 | 152 / 182 |
| 1,024 | 256 | 27.770 / 26.510 | 3.4% / 2.8% | 7.531 / 6.044 | 1.0% / 0.9% | 3.68 / 4.39 | 133 / 165 |
| 65,536 | 16,384 | 28.198 / 26.605 | 4.2% / 2.6% | 9.357 / 7.696 | 5.3% / 1.0% | 3.01 / 3.48 | 107 / 130 |
A power row is three to five times cheaper than a dB row: 107–152 MSa/s on one core portable, 130–182 native, against 35–38 for dB. The gap is 5.12's dB conversion, done once per bin per row at a quarter hop. The dB rows spread 2.6–4.4% across passes, more than the power rows at 1024, so a before/after on a dB row wants its spread beside it. A display that reads power rows, or converts only the bins it draws, keeps that difference. #2117 narrows it from the dB side.
This run's merged set (benchmarks/published/v0.66.0-b2094/) stays on the
measuring machine; its doppler_meta is b2094/meta.json. It is a
characterization, not a release's published numbers.
5.15 Row latency against hop, and U1 at nfft/2 (2026-10-16) — #2062¶
Why this run. Entry 5.6 measured U1 at hop nfft/4 and nfft, and
entry 5.7 measured U2 at hop 256 only. The other rows were held out of the
bench on purpose: it stopped at 29 rows, because jm_bench.h kept at most
32 and dropped the rest without a word (just-makeit#2188). 5.6 and 5.7 lost
no row; they had none to lose. With jm 0.106.0's header vendored (#2357),
the Spectrogram bench records all 53 of its rows, and this run measures the
ones 5.6 and 5.7 could not.
Method: 5.6's procedure, on different code. One run of
make bench-interleaved VERSION=0.73.0-2062 K=5 at e13a27fc1, a commit
on main, on cachyos-x8664-ai465, from 07:28 to 09:35 UTC:
- The same: the compiler, the two builds' flags, the governor, boost,
platform profile and pinning (cpus 0–3 and 10–13) are 5.6's, as the
run's merged
doppler_metarecords them (b2062/meta.json). - The machine: the Linux kernel is 7.2.10-1-cachyos; 5.6 ran on 7.2.9-2.
- The code under test is 308 commits on from 5.6's c1ae84190. That includes the Spectrogram holding PSD's frame kernel instead of a PSD (#1894), which entry 5.10 measured as moving no row by more than its own spread.
- The bench changed too. Three of the one-push dB rows (
hopnfft/4atnfft256, 1024 and 65,536) are now timed beside a power twin (5.14), where 5.6 timed each beside its direct row alone. The re-vendoredjm_bench.hwrites a unit for each row, but at this commitdp_bench_recorddid not pass the row's own unit on yet (#2314), so every row recordscall. The cells do not read it: they arestats.min / iterations, per sample, as in 5.6. - No other job was started on the machine. Load was not logged.
So this run is not 5.6's numbers re-measured, and a cross-run comparison reads as such. Of the 29 rows the two runs share, every portable row is 1.8–3.8% faster here, and every native row is within −0.4% and +1.1%. That one build moved and the other barely did points at the code or the build rather than the machine, but nothing here separates them. The tables below compare rows only within this run.
The ten per-pass snapshots, filtered to the spectrogram:: rows, are in
docs/design/spectrogram-measurements/b2062/. Every table below is the
output of docs/design/spectrogram-measurements/b2062.py on them, and its
--check is in make docs-invariants. A cell is computed as in 5.6. The
decision rules are 5.6's, set before its run and not revisited here:
- U1 closes if push/direct is at most 1.05 at every shape, and is falsified if the ratio scatters more than 5% across passes.
- U2's model gets no refit and must hold to 10%.
U1 at the midpoint hop. The same push and direct pair as 5.6, at
hop = nfft/2:
| nfft | hop | push, ns/sample | direct, ns/sample | push − direct | push/direct | worst pass | ratio spread |
|---|---|---|---|---|---|---|---|
| 256 | 128 | 13.236 / 13.119 | 13.148 / 13.034 | 0.096 / 0.089 | 1.007 / 1.007 | 1.010 / 1.010 | 0.5% / 0.5% |
| 1,024 | 512 | 13.563 / 13.334 | 13.428 / 13.222 | 0.130 / 0.117 | 1.010 / 1.009 | 1.012 / 1.010 | 0.4% / 0.6% |
| 4,096 | 2,048 | 13.684 / 13.393 | 13.549 / 13.295 | 0.128 / 0.103 | 1.009 / 1.008 | 1.013 / 1.010 | 0.5% / 0.4% |
| 16,384 | 8,192 | 13.794 / 13.432 | 13.621 / 13.256 | 0.191 / 0.176 | 1.014 / 1.013 | 1.014 / 1.016 | 0.2% / 0.5% |
| 65,536 | 32,768 | 14.356 / 13.823 | 14.252 / 13.756 | 0.104 / 0.144 | 1.007 / 1.011 | 1.011 / 1.012 | 0.6% / 1.5% |
The answer to U1 at nfft/2: closed, as at nfft/4 and nfft. The
worst single pass anywhere is 1.016 (native, 16,384/8,192), so the 1.05 rule
holds at every shape in every pass, and the ratio scatters at most 1.5%. The
copy costs 0.09 to 0.19 ns per sample, inside 5.6's range. The median ratio,
1.007 to 1.014, falls between 5.6's two hops: a half hop computes two rows
per nfft samples, where a quarter hop computes four and a full hop one,
and each sample is copied once either way.
U2 against hop. 5.7's model at nfft 1024, at three hops. c_s is the
one-push row's per-sample cost; c_p is the chunk-1 row's per-sample cost
minus c_s; a chunk of C samples should cost c_s + c_p / C per sample:
| hop | c_s, ns/sample | spread | chunk 1, ns/sample | chunk 1 spread | c_p, ns per push |
|---|---|---|---|---|---|
| 64 | 106.937 / 105.021 | 1.9% / 3.7% | 111.520 / 109.599 | 1.8% / 3.5% | 4.583 / 4.578 |
| 256 | 26.922 / 26.464 | 1.9% / 3.5% | 31.630 / 31.058 | 2.3% / 3.1% | 4.709 / 4.594 |
| 1,024 | 6.903 / 6.812 | 2.0% / 3.6% | 11.461 / 11.386 | 6.6% / 1.9% | 4.558 / 4.573 |
| hop | chunk | measured, ns/sample | predicted c_s + c_p/C |
error |
|---|---|---|---|---|
| 64 | 256 | 106.767 / 104.863 | 106.955 / 105.039 | -0.2% / -0.2% |
| 64 | 1,024 | 106.667 / 104.772 | 106.941 / 105.025 | -0.3% / -0.2% |
| 64 | 16,384 | 106.612 / 104.756 | 106.937 / 105.021 | -0.3% / -0.3% |
| 256 | 256 | 26.948 / 26.485 | 26.940 / 26.482 | +0.0% / +0.0% |
| 256 | 1,024 | 26.923 / 26.447 | 26.926 / 26.469 | -0.0% / -0.1% |
| 256 | 16,384 | 26.922 / 26.448 | 26.922 / 26.465 | +0.0% / -0.1% |
| 1,024 | 256 | 6.919 / 6.832 | 6.921 / 6.830 | -0.0% / +0.0% |
| 1,024 | 1,024 | 6.905 / 6.831 | 6.908 / 6.817 | -0.0% / +0.2% |
| 1,024 | 16,384 | 6.904 / 6.827 | 6.903 / 6.812 | +0.0% / +0.2% |
The answer to U2 against hop: the model holds to 0.3% at every hop, with
no refit.
- c_p does not depend on
hop. Its medians are 4.56 to 4.71 ns per push athop64, 256 and 1024, in both builds, and the per-pass values overlap at every hop: the push's own overhead, paid once per call whatever the row rate. It rests on the chunk-1 row alone, whose spread is up to 6.6% (portable,hop1024: one pass slow). - c_s scales with the rows per sample. It falls about fourfold per
fourfold hop: 3.97 and 3.90 portable, 3.97 and 3.88 native. c_s times
hopfits a fixed cost per row plus a small one per sample: 6.83 µs per row and 0.23 ns per sample portable, 6.71 µs and 0.26 ns native. The per-sample term is why the step is a little under four. - So the chunk-1 penalty, c_p / c_s, grows with
hop. It is about 4% athop64, 17% at 256 (5.7's figure) and 66–67% at 1024. - As in 5.7, the 0.3% tests only c_s. From 256 samples on,
c_p / Cis at most 0.018 ns, 0.26% of c_s even athop1024. Read pass by pass, a chunked row is within 0.7% of its one-block row at every hop and chunk measured, in both directions, so the residue below that is not resolved. - c_p repeats 5.7's; c_s does not, quite. At
hop256, c_p is 4.709 / 4.594 against 5.7's 4.640 / 4.607. c_s is 26.922 / 26.464 against 27.787 / 26.391: the portable build's 3.1% is the run-to-run shift above, not a change to the model.
What this does not cover.
- Chunks between 1 and 256 are unmeasured. That is where
c_p / Cis visible. By the model, a chunk of 16 athop1024 would cost about 4% more per sample than one block, but no row measures it. - U2 is still at
nfft1024 only. - U3 and U4 were not re-measured.
- The run is one machine and one compiler, like every entry in this record.
This run's merged set (benchmarks/published/v0.73.0-2062/) was removed from
the measuring machine after its doppler_meta was saved as
b2062/meta.json. It is a characterization, not a release's published
numbers.
A correction to 5.9 and 5.14. Both say their run's merged set stays on
the measuring machine. On 2026-10-16 neither v0.66.0-a4/ nor
v0.66.0-b2094/ is on cachyos-x8664-ai465: listing
~/doppler-bench/benchmarks/published/ there shows neither. The machine's
other clone, ~/doppler, was removed that day at the owner's request, and
nothing kept from it holds them. 5.12's doppler_meta survives as
b2094/meta.json. 5.6's was never committed, so its machine claims rest on
the entry's own text. _record.py now refuses a run with no meta.json,
waived only for 5.6's.