docs: the sample budget in samples per signal, the memory limit and the planner probe lines

Converts every --buffer-mb / buffer_mb mention to --buffer-samples / buffer_samples,
records the unit change as a deviation (README, SPECS, CLAUDE.md), documents the
buffer: and planner: dump lines and the new Preferences row, and updates the help
pages and the help-shot script.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-11-03 02:49:21 +01:00
1 parent 034c82f7d7
commit 1627abcd4a
19 files changed
+166 -93

No files matched your search

+46 -18
View File
@@ -75,7 +75,7 @@ Two lines of the self-check output are assertions in disguise:
a live axis narrower than the requested window is the same symptom from the other
cause (see the `clamp_to_data()` invariant in
[invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)). Both need a *saturated* ring to
show up, so run them under `--buffer-mb 1`, which wraps in a few seconds.
show up, so run them under `--buffer-samples 65536`, which wraps in a few seconds.
- `trigger: edge at X ... want [-a..b] got [-c..d] n=N` (stderr) — `got` must equal
`want` and `N` must be exactly the window times the stream's real rate, plus one
(2 s at 10 kS/s is `n=20001`, not 19999). `got` shorter than `want` on the *left*
@@ -206,10 +206,10 @@ Two lines of the self-check output are assertions in disguise:
decimation is for, and a `buffered` short of the window is the old "the control is capped
to what the ring holds" behaviour coming back. `decim` must be a power of two,
`stored × decim` must equal the wire rate, and `aa` must be exactly `stored / 2`. Run it
under `--buffer-mb 1`, which saturates in a few seconds and makes windows past ~6 s
decimate; at 200 MB nothing inside `--max-window` needs to.
under `--buffer-samples 65536`, which saturates in a few seconds and makes windows past ~6 s
decimate; at the default 10M nothing inside `--max-window` needs to.
- **The anti-alias check** is the same probe run against a waveform above the stored
Nyquist: `--buffer-mb 1 --freq 2000` with the probe ending on a 60 s window gives
Nyquist: `--buffer-samples 65536 --freq 2000` with the probe ending on a 60 s window gives
`decim=16`, a 625 S/s stored rate and a 312.5 Hz corner, and the channel dump's
`ch 0 'sine' ... y=[-0.059, 0.079]` is the assertion — a boxcar of 16 at 10 kS/s passes
2 kHz at about 6%. **A `y=[-1, 1]` there means the filter is gone** and the 2 kHz sine has
@@ -763,7 +763,7 @@ Two lines of the self-check output are assertions in disguise:
it exists to answer: freezing a whole `BufferStore` copies nothing and inherits the pyramid a
`ChannelBuffer` already maintains incrementally, so both `worst` figures would go to **0** — at
the price of a second budget's worth of memory (README records that one live ring is why
`--buffer-mb 200` really is 200 MB) and a second per-sample write on the producer. What the line
the planned bytes are the whole memory cost) and a second per-sample write on the producer. What the line
says about that trade is that the stall is real but **costs no packets**: an A/B of the same run
with and without a pause read `gaps=0` both ways, the paused one with an 86 ms publish, so a
second ring would buy a smoother frame at a latch rather than recovered data. (An earlier run
@@ -776,7 +776,7 @@ Two lines of the self-check output are assertions in disguise:
for the window**, one fetch and one scan per *measured channel*; **`L` is its price for the
pane's shares**, one accumulator per distinct one, walked over the window's buckets; and the
window's samples are the multiplier on both, so a sweep at one scale says nothing about
another. Measured at the user's scale (15 s window, `--buffer-mb 4000` -> `stride=1`, 11.8 M
another. Measured at the user's scale (15 s window, `--buffer-samples 100M` -> `stride=1`, 11.8 M
samples in the window = 184 k buckets, four signals, mock at ~0.5 MS/s per signal, llvmpipe),
with `UDPSCOPE_PROBE_LEVELS_CHANNELS` set:
* `50`, 1 channel -> `1 window, 1 level`, `measure cost: 226 recomputes, 6.58 ms each`, `raw=0.1%`,
@@ -1407,7 +1407,7 @@ popover row reads `failed: <strerror>` — one stderr line, no retry storm again
[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25
[configs] before=default active=default
[configs] save-as=1 names=probe,default active=probe
[configs] round trip read=1 comment='written by the configs probe' buffer_mb=8 relative=0 capture=0 plots=1 signals=4 measures=0 math=0 noise=-1 hf=-1
[configs] round trip read=1 comment='written by the configs probe' buffer_samples=10000000 relative=0 capture=0 plots=1 signals=4 measures=0 math=0 noise=-1 hf=-1
[configs] after forget names=default
```
`noise=` and `hf=` are the first measurement's band and corner as they came back from the file. The probe
@@ -1422,7 +1422,7 @@ sets them to **7.5 and 250** before saving, so a key that never reached the file
| `[defaults] trigger:` | `0.001 0.002 0 0.001 0 50 0.01 0 0 1` | the same, per trigger field — the ten spins in tree order: width, width2, level2, timeout, level, pre-trigger, hold-off, noise band, HF reject, persistence; a level of `-1e12` is a number nobody chose, and the trailing `1` is the persistence depth, whose default *is* the feature off |
| `[configs] names` | gains `probe`, and loses it after `forget` | a save that writes somewhere the listing does not read, or a delete that leaves the file |
| `[configs] active` | `probe` after the save-as | the `active` file not written, so the next start opens the wrong configuration |
| `[configs] round trip` | `read=1`, the note verbatim, `buffer_mb` non-zero | the note or the startup settings not persisted — a configuration that carries a layout but not the buffer size it was tuned with |
| `[configs] round trip` | `read=1`, the note verbatim, `buffer_samples` non-zero | the note or the startup settings not persisted — a configuration that carries a layout but not the buffer size it was tuned with |
## `[rowmenu]`
@@ -1524,8 +1524,8 @@ actually use.
```
Printed by `apply_stored_settings()` when a source is named on the command line: the stored
configuration's startup settings, filled in where the command line was silent. A `buffer=` here
that disagrees with `--buffer-mb` is a flag being overruled by the file; a *missing* line, with a
configuration's startup settings, filled in where the command line was silent. A `samples=` here
that disagrees with `--buffer-samples` is a flag being overruled by the file; a *missing* line, with a
configuration that carries settings, is the defect this exists for — a saved host opened with
`--server` ran on the defaults, so an edit to its buffer or clock never took effect.
@@ -2170,7 +2170,7 @@ of `S` signals shows `floor((8 - S) / S)` ages of each, so the default four-sign
**`kept`/`bytes`/`dropped` are the *retention*, and they bound what those slots can draw.** `kept=3` of the
3 asked at `bytes=20998152` (21 MB: three acquisitions of a 0.5 s window at 500 kS/s over four channels,
~7 MB each) against `budget=209715200`, the ring's own `--buffer-mb`. `drawn=` is how many layer slots are
~7 MB each) against `budget=` the planned ring bytes (`persist_budget_bytes()`, `Partition::bytes()`). `drawn=` is how many layer slots are
actually *bound* to a retained acquisition, and it is a different number from `placed=` exactly when the
budget bit — which is the run to take next, on the fast mock with a long window:
@@ -2182,7 +2182,7 @@ budget bit — which is the run to take next, on the fast mock with a long windo
```
`UDPSCOPE_PROBE_PERSIST_DEPTH=8 UDPSCOPE_PROBE_PERSIST_WINDOW=2` against the mock at 1.3 MS/s: one retained
acquisition is 72.8 MB, so the 200 MB budget keeps two and drops two — `kept` below the depth, `dropped`
acquisition is 72.8 MB, so the budget (the planned rings, ~201 MB for four channels at the default 10M) keeps two and drops two — `kept` below the depth, `dropped`
non-zero, and **`drawn=4` against `placed=28`**: the layer slots exist and the oldest seven ages per row draw
nothing at all. That is the memory bound as a reading, and `settled:` is the wait saying so (the retention
can never reach the depth, so the probe reports what stopped growing instead of waiting out its cap — its
@@ -2374,7 +2374,7 @@ refusal it may still hold the previous text.
## `[enable-all-probe]` and switching a signal off
```
[enable-all-probe] 4 channel(s) enabled and assigned to plots, notice='connecting to 127.0.0.1:21023 -- planning the 200 MB buffer'
[enable-all-probe] 4 channel(s) enabled and assigned to plots, notice='connecting to 127.0.0.1:21023 -- planning the buffer: 8.39 M samples per signal'
[enable-all-probe] switched 'sine' off: traces_left=0 (want 0) notice=''sine' is off: 1 trace left the plots'
```
@@ -2383,7 +2383,7 @@ earliest thing that reads the status bar. It is set as the window is presented -
CONFIG can take the model's mutex, so before anything can block -- and it names the host and the
buffer being planned, because that is the window's only answer to "why is nothing happening yet".
An empty `notice` here is the window that used to come up silent while a large buffer was allocated:
at `--buffer-mb 4000` that is the dialog disappearing and then over a second of a frozen, blank
at `--buffer-samples 100M` that is the dialog disappearing and then over a second of a frozen, blank
window. (The buffer is allocated on the producer thread under `ScopeModel::mutex()`, which the frame
tick also takes, so the freeze is real and cannot be papered over in the UI -- see the
`[udpscope] buffer allocated:` line below.)
@@ -2452,6 +2452,34 @@ rate whenever data arrives at all:
Neither can be read off the engine's own fields, which is why the store's pair is here.
## `[udpscope] buffer:` and `[udpscope] planner:`
```
[udpscope] buffer: requested=10000000 used=8388608 bytes=167772160 limit=8589934592 limited=0
[udpscope] planner: unclipped got=67108864 pow2=1 limited=0 | clipped(1GiB) got=33554432 bytes=671088640 fits=1 pow2=1 limited=1 | clipped_rel got=33554432 bytes=939524096 fits=1 | too_small_throws=1
```
Printed by the exit dump (`src/ui.cpp`), next to the per-channel `produced` lines. The first is the live
plan: `requested` is `Config::buffer_samples`, `used` is `Partition::time_capacity` (the request rounded
down to a power of two, then halved under the memory limit), `bytes` is `Partition::bytes()` (the rings'
real size -- 8 388 608 ticks x (8 + 3 x 4) = 167 772 160 for the mock's three channels) and `limit` is
`memory_limit_bytes()`. **`used` must be a power of two no larger than `requested`'s floor, `bytes` must be
<= `limit`, and `limited=1` only when `used` is below that floor.** A run with `--buffer-samples 100M
--memory-limit-mb 256` reads `used=8388608 limited=1` and prints the `buffer limited to ...` stderr line;
`used=0 bytes=0` means no plan was made yet (no channel enabled -- add `UDPSCOPE_ENABLE_ALL_MS`).
The second line is not about the run: it feeds `plan_partition()` three channels and a 100 M request on
fixed inputs, so a change to the rounding, the halving or the byte formula reads here even when the run's
own plan is not clipped. `unclipped` (16 GiB limit) must give the pow2 floor of 100 M, **67108864**, with
`pow2=1 limited=0`. `clipped(1GiB)` must give a smaller power of two whose `bytes` are <= 1 GiB
(**33554432**, 671 088 640 bytes: 67 108 864 would need 1 342 177 280), `fits=1 pow2=1 limited=1`.
`clipped_rel` is the same in relative time, where the clock ring is doubled, so a tick costs 8 more bytes:
the same 33 554 432 ticks but 939 524 096 bytes, still `fits=1`. `too_small_throws=1` is a 1000-byte limit
refused (it cannot hold 128 ticks). A `fits=0` is the halving loop stopping early; `limited=0` on the clipped
case is the clip not being reported to the UI; `too_small_throws=0` is a planner that allocates past the limit.
The Preferences *Memory limit* row is not probed: change it by hand and read the new `buffer allocated:` line
and the status popover.
## `[udpscope] buffer allocated:`
```
@@ -2460,7 +2488,7 @@ Neither can be read off the engine's own fields, which is why the store's pair i
Not a probe — it is printed by `ScopeModel::rebuild_locked()` in every build, because this is the one
operation in the application whose duration a user can see with no instrument: the store's rings are
`std::vector<std::atomic<T>>` and so **value-initialised**, which touches the whole `--buffer-mb`
`std::vector<std::atomic<T>>` and so **value-initialised**, which touches the whole planned ring storage
(roughly a second per GB, mostly first-touch page faults), and it runs on the producer thread holding
`ScopeModel::mutex()` — which the frame tick also takes. So the line's `ms` is a frozen window, not
bookkeeping.
@@ -2469,7 +2497,7 @@ bookkeeping.
rebuild whose plan describes the storage already allocated clears the store instead
(`Partition::same_storage_as()` → `BufferStore::reset()`) and prints nothing, so a startup or a mode
change that reads more than one or two of these is a re-plan that should have been coalesced. Startup
asked for **fifteen**; it now asks for two or three. A run at `--buffer-mb 4000` measured those
asked for **fifteen**; it now asks for two or three. A run at `--buffer-samples 100M` measured those
coalesced allocations at 1148 ms each and a max frame tick of 1186 ms — the same number, which is the
evidence that the freeze *is* this call and not something downstream of it.
@@ -2495,8 +2523,8 @@ while the edit runs) and never falls to 0, which is what a rebuild would do. Eve
[mathwrap-probe] after edit: produced=72400 slots=72400 (want equal) y=[-0.5000,0.5000] (want +-0.5, was +-2.0000)
```
`UDPSCOPE_PROBE_MATHWRAP_MS`, and it must be run with a **small `--buffer-mb`** and given time to
saturate it: `--buffer-mb 1` against `tools/mock_udpstreamer.py --rate 20000 --batch 200`, with
`UDPSCOPE_PROBE_MATHWRAP_MS`, and it must be run with a **small `--buffer-samples`** and given time to
saturate it: `--buffer-samples 65536` against `tools/mock_udpstreamer.py --rate 20000 --batch 200`, with
`UDPSCOPE_PROBE_MATHWRAP_MS=1500 UDPSCOPE_EXIT_AFTER_MS=25000`, reads the lines above. It installs one
`Gain` graph of 2 over the mock's unit sine, polls (100 ms) until the ring has **wrapped**, and only then
edits the gain to 0.5 — the same channel list with one node changed, which is the in-place recompute and