The Payload as a Data Source — the measurement record¶
The dated record behind the design page, under the same section numbers. The design page states what is; this page records what was measured to get there, in order.
4. The rules the shape leaves open¶
4.0 The chunker, prototyped before any C (2026-09-30)¶
A throwaway Python chunker (about 180 lines, kept in a scratch directory and not committed, per the lifecycle) implemented §F.5's four rules over a finite source and a non-blocking file descriptor. It asserted seven cases:
| case | result |
|---|---|
96 bits, LEN 32 |
3 data frames, bits identical |
90 bits, LEN 32, no fill |
refused: "26 bits into a 32-bit frame; 6 bits short" |
90 bits, LEN 32, fill 01 |
3 frames; the last carries 6 fill bits, 010101 |
| 0 bits | 0 frames (which led to §4.3: refuse it) |
a pipe of 24 bits, LEN 10 |
data, data, then the end padded with 6 bits; octets straddle frames and the bits come back in order (§4.2) |
| a paced pipe that pauses for 2.4 frame periods | data, idle, idle, data (§4.1) |
| a paced pipe that underruns, no fill | refused, but only at the underrun (which led to §4.4) |
What it changed:
- §4.1: the prototype held a partial residue through the idle frames and gave it to the next data frame. Writing that down made it a rule, rather than an accident of the loop.
- §4.4: the refusal for a stream without a fill arrived mid-run in the prototype, after frames had been emitted. That is why the design requires the fill up front.
- §4.5: the paced case's output depends on scheduling. The prototype had to sleep to make it happen, so the C tests must inject the underrun instead.
6. Unknowns — the two throughput ones, measured (2026-10-02)¶
Headline: assembly does not need to run ahead of the pacer. A CADU takes 20–25 µs to assemble, about 11 % of the 190–204 µs that wfmgen spends on the whole frame at one sample per bit. The paced stdin has no frame-period threshold of its own. A pipe read costs about 1 µs, so what limits a paced run is the per-sample work, and a producer that paces itself to the frame rate has to stay up to one 4096-sample block ahead. The third unknown, when the SigMF metadata is written, was not measured.
Machine: AMD Ryzen AI 7 445 w/ Radeon 840M (12 logical CPUs), under
WSL2 (kernel 6.18.35.2-microsoft-standard-WSL2), gcc 15.2.0. The
build is make build (Release), and compile_commands.json gives
-O3 -DNDEBUG -march=x86-64-v2 -ffast-math -fno-finite-math-only.
Base commit 1c918681.
6.1 The cost of assembling every frame¶
The rows are in native/benchmarks/bench_frame_core.c. frame is a jm
component, so make bench runs them. A round is 32 frames, there are
200 rounds, and each row reports the minimum. The payload is
LEN = 1784 bits, which is a CADU's 223 octets, and it goes into two
frames:
- plain:
asm | data:1784 | crc16, 1832 bits; - cadu:
asm | data:1784 | rs_paritywith RS(255,223) at I=1, the 10.4.1 randomiser, and conv r1/2 over the whole frame, 4144 bits. This is the guide'scadu.jsonwith its payload madedata:1784.
Five runs (four of the binary run directly, one through make bench).
Each cell is the range of the per-run minimums:
| row | what one frame is | µs/frame | frames/s |
|---|---|---|---|
data pn:0 |
dp_wfm_data_frame, an endless pn:0:15 stream |
2.40 – 2.76 | 362 k – 417 k |
data pipe |
the same from a pipe already holding it: poll(0) + read |
0.91 – 1.09 | 914 k – 1.1 M |
assemble plain |
dp_wfm_frame_assemble_data, plain |
1.87 – 2.28 | 438 k – 534 k |
assemble cadu |
the same, cadu: RS + randomise + conv | 19.4 – 24.2 | 41 k – 51 k |
pipe+cadu |
pull from the pipe, then assemble the cadu | 20.1 – 25.0 | 40 k – 50 k |
That is the frame path alone. The whole wfmgen pipeline takes a CADU from a pipe that is never empty, then pulls, assembles, modulates and writes cf32 to a file. Five runs of 20000 frames, unpaced, give:
cat in.bin | wfmgen --type bits --modulation bpsk --frame cadu-data.json \
--data-from-file - --fill 0 --sps 1 -o out.cf32
| µs/frame | frames/s | samples/s |
|---|---|---|
| 189.7 – 204.2 | 4 897 – 5 271 | 20.3 – 21.8 MSa/s |
So the frame accounts for 20–25 µs of the ~197 µs, and the remaining ~42 ns per sample is the synth and the writer. At more samples per bit the frame's share only gets smaller.
6.2 The shortest frame period a paced stdin keeps up with¶
Derivation. The paced loop is drain_to_writer in wfmgen.c. It
computes a block of BLK = 4096 samples, writes it, and sleeps until
that block is due (dp_sample_clock_pace). A frame is pulled when the
synth reaches the frame's first bit. So every frame that starts in a
block is pulled at the moment the block is computed, which is up to
4096 samples before the frame is due. Each pull is one poll(0) and
one read, about 1 µs (§6.1). That leaves two conditions, and the frame
period appears in only one of them:
- Throughput:
c_frame + N × c_sample ≤ T_frame. Herec_frameis the pull plus the assembly, 20–25 µs for a CADU, andc_sampleis about 42 ns. A CADU at one sample per bit therefore needs a frame period of at least about 200 µs, which is about 21 MSa/s. Of that, assembly is about 11 %. - Lead: a frame's data must be in the pipe when its block is
computed, up to
4096 / fsseconds early. A producer that is ahead of the run, ascatalways is, never trips this. A producer that paces itself to the frame rate gets spurious idle frames until it is one block ahead, and this holds at any frame period. It limits itself: each idle frame moves the data one frame later, so the lead grows by one frame period until it covers the block.
Throughput, empirically. The same CADU ran under --realtime for
3 s at each --fs, from a pipe that is never empty. A run that keeps up
takes 3.00 s of wall time:
--fs |
frame period | wall (s) | idle frames |
|---|---|---|---|
| 1 MHz | 4144 µs | 3.01 | 0 |
| 10 MHz | 414 µs | 3.01 – 3.07 | 0 |
| 20 MHz | 207 µs | 3.01 – 3.03 | 0 |
| 22 MHz | 188 µs | 3.09 | 0 |
| 25 MHz | 166 µs | 3.18 – 3.57 | 0 |
The run keeps up to 20 MHz (a 207 µs CADU) and falls behind from 22 MHz,
as condition 1 predicts. The rows combine runs on the base-commit build
with runs on the probe build described below. With a pipe that is never
empty, the two builds give the same result. Every rate, 1 MHz included,
reports clock underruns (--realtime without --realtime-resync counts
every block until the run has caught up). The same runs reading a
regular file instead of the pipe report as many: 24–59 at 1 MHz,
worst 37–78 ms. So the underruns come from the paced loop on this WSL2
box and not from the pipe pull. Their cause is not diagnosed here.
Lead, empirically. This needed a fix that is not on main.
wfmgen --realtime never sends an idle frame today, because the pacing
is set after the composer has already built its synths
(#1782). On main,
a 0.5 s pause in stdin gives 0 idle frames and
75 underrun(s) — worst 518.967 ms behind real time. With the setter
also applied to the live synths (a three-line scratch probe, not
committed), the same pause gives 176 idle frames (0.535 s / 3.04 ms) and
no underrun. The rows below come from the probe build. A Python producer
writes LEAD chunks at once, then one chunk per frame period on an
absolute schedule. The frame is
--sync 0x1ACFFC1D --crc crc16 --data-len 256 --fill 0 --sps 1, so
N = 304 samples. The idle frames are counted from the capture: an idle
payload is all fill, so all of its samples have one sign. Only idle
frames after the first data frame are counted.
--fs |
frame period | block | spurious idle frames by producer lead (frames) |
|---|---|---|---|
| 100 kHz | 3.04 ms | 40.96 ms | 0: 3, 7, 24 · 4: 7 · 8: 0 · 16: 0, 0 · 32: 0, 0 |
| 1 MHz | 304 µs | 4.10 ms | 0: 29 · 8: 12 · 16: 7 · 32: 49 · 64: 0 |
At 100 kHz, a lead of 8 frames (24 ms) or more gave none. With no lead, the idle frames arrive in the first block, and each one adds to the lead. One no-lead run also had a burst of 17 at frame 131, which was a producer stall. At 1 MHz the idle frames come in bursts at random positions, and those are stalls of 1–15 ms in the Python producer on WSL2, which outlast a smaller lead. So the frame period sets nothing here. What a flowing input needs is to stay ahead of the run by one block plus its own jitter.
6.3 The consequence §6 asks for¶
- Assembly does not run ahead of the pacer. At one sample per bit it is about 11 % of a CADU's cost, the per-sample path is the bound, and the 4096-sample block already separates a frame's compute from its deadline. Moving assembly to another thread would raise the ceiling by at most that 11 %. No issue is filed for it.
- The lead belongs in the guide (§7 step 9): a producer that paces
itself to the frame rate under
--realtimemust stay up to one 4096-sample block ahead, or it gets idle frames at the start. - Filed: #1782,
--realtimenever sends idle frames. §4.5's behaviour is not reached from wfmgen until it is fixed.