perf: a measurement pane is priced by its window, and its fold is inline

A pane is recomputed over the whole visible window on a cadence, and at a 15 s window
(11.8 M samples, 184 k level-0 level buckets, `--buffer-mb 4000` so the stride stays 1)
that is priced per *measured channel* and per *distinct share*. Measured with the new
`[levels-probe]` sweep, per recompute: one window with one level 6.58 ms, four shares
10.28 ms, sixteen shares 24.55 ms, and four measurements one share each over four
channels **35.69 ms** -- against a tick mean of 1.34 ms with an empty pane and 4.58 ms
with that one. That is the reported "very slow with multiple measurements": the pass
runs inside `tick()`, so it is a *frame* cost, and not an ingest one -- `gaps` and
`packets` stay clean even though each `MeasureWindowReader` holds the model mutex for
its whole life, because a socket buffer absorbs a few tens of milliseconds of producer.

The largest single term inside a pass was a call. `measure_accumulate_bucket()` folds
one bucket into one level, and it is the innermost loop of a walk whose iteration count
is the window's buckets *times* the pane's levels; it lived out of line in
`measure.cpp`, so every one of those iterations was a cross-translation-unit call.
Moving it, and the crossing state it updates, into `measure.hpp` as `inline` takes
sixteen shares on one channel from 24.55 to **17.25 ms** a recompute -- with every value
in the dump's `measurements:` unchanged. Two levers remain and both are measured: the
fetch's three out-of-line pyramid accessors plus a 48-byte copy per bucket (3.3 ms of an
11.8 M-sample window's 11.6 ms pass) needs a combined accessor upstream, and `raw` is
the *signal's* share (4.6 % on the mock's noisy channel against 0.1 % on its sine),
because a level inside a bucket's own `[lo, hi]` has to be walked sample by sample.

Bisected with the pane populated (the earlier bisect had it empty, which cannot see
this): the parent of the recent range and HEAD read **8.29** and **7.92 ms** a
recompute for the same four-type pane at the same window, and neither of the two
commits between them touches `ui_measure.cpp` or `measure.cpp` at all. So the cost is
the scale and not a regression, and the fix makes a pass cheaper rather than removing
one -- the plot's own per-frame cost was checked the same way (four commits, equal
within 3 %, and unchanged by the window, the ring size and the visible sample count).

`UDPSCOPE_PROBE_LEVELS_MS` with `UDPSCOPE_PROBE_LEVELS` and
`UDPSCOPE_PROBE_LEVELS_CHANNELS` is the sweep; `docs/claude/invariants-measurements.md`
records the curve and the inline rule, `verification.md` and `probe-readings.md` the
probe and what its numbers must read, and the README the pane's cost at this scale.
This commit is contained in:
Martino Ferrari committed 2026-10-16 08:00:54 +02:00
1 parent 0a209becb1
commit fc4700d1c9
9 files changed
+242 -61

No files matched your search

+41
View File
@@ -118,6 +118,47 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
the pyramid for nothing.
`measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
anything.
- **A pass costs the window's buckets times the pane's *measured channels* and its *distinct
levels*, and the window's own size multiplies all of it.** The cadence above bounds *how
often*; this is *how much*, and it is the term a big time scale buys. Measured on the mock at
the user's scale -- a **15 s** window, `--buffer-mb 4000` (so `time_stride` stays 1 and the
window is 11.8 M samples, 184 k level-0 buckets), four signals, one row -- with
`[levels-probe]` (see [verification.md](verification.md)), `measure cost:` reading
`recomputes`/`each`/`covered`/`buckets`/`raw`:
| pane | `each` | `raw` | tick mean |
|---|---|---|---|
| empty | -- | -- | 1.34 ms |
| 1 measurement, 1 level | 6.58 ms | 0.1 % | 1.99 ms |
| 4 measurements, 4 shares on **one** channel | 10.28 ms | 0.4 % | 2.38 ms |
| 16 measurements, 16 shares on one channel | 24.55 ms | 1.2 % | 3.86 ms |
| 4 measurements, 1 share each on **four** channels | 35.69 ms | 2.6 % | 4.58 ms |
So a pane is priced per measured **channel** (one `MeasureWindowReader`, one fetch, one scan)
and per **distinct share** (one accumulator), and the window's samples are the multiplier on
both: the same pane over a 2 M-sample window is the 1.2--1.6 ms a recompute this pane's
budget was fixed at, and a machine's ten noisy channels at a 15 s window is **tens of ms a
pass**, several times a second. That lands on the **tick** -- `update_measures()` runs inside
it -- so `frame cost:`'s tick mean is where a pane's cost shows (1.34 -> 4.58 ms above), and
it is *not* an ingest problem: `gaps=`/`packets=` stay exactly clean even though each
`MeasureWindowReader` holds `model->mutex()` for its whole life, because a socket buffer
absorbs a few tens of ms of producer. Read the tick mean and `each` before blaming the
producer.
Inside a pass the terms are, instrumented per window at that scale: the **fetch** 3.3 ms
(three out-of-line `MinMaxPyramid` accessors and a 48-byte `MeasureBucket` per bucket),
`measure_scan_buckets()` 0.8 ms, and **pass two** 7.5 ms -- dominated by the per-(bucket,
level) fold. That fold is why `measure_accumulate_bucket()` is `inline` in `measure.hpp`: it
is the innermost loop of a walk whose iteration count is the window's buckets *times* the
pane's levels, and as an out-of-line call in its own translation unit it cost **30 % of the
pass** (sixteen shares on one channel: `24.55 -> 17.25 ms` a recompute). A caller adding a
per-bucket type keeps it in the header, and a caller adding a bulk one may not let it grow a
call per bucket either.
The third term is `raw`, and it is the **signal's**: a level that falls inside a bucket's
`[lo, hi]` has to be walked sample by sample (the fold cannot split a bucket), so a signal
whose local range covers the level everywhere -- white noise, a noisy machine channel --
degrades the pass to O(window) *per level*: measured `raw=4.6 %` on the mock's `noise` channel
against `0.1 %` on its `sine` at the same window. It is the field to read before blaming the
pane for a slow run, and no cheaper walk fixes it.
- **`Top`/`Bottom`/`Amplitude` read a *flat* level, and the density that finds it is the buckets',
not the samples'.** A band-limited edge overshoots and rings -- the mock's `square` at 30
harmonics reaches ±1.179 where its plateau is ±1 -- so a measurement that reads the extremes, or
+27
View File
@@ -714,6 +714,33 @@ Two lines of the self-check output are assertions in disguise:
reported `gaps=73` with a pause against 0 without, which is what made the stall worth measuring;
it has not reproduced and did not scale with the publish length — 79 gaps with the pyramid build
removed against 73 with it — so it was incidental load on one run. Do not quote it as a cost.)
- `[levels-probe] N measurement(s) over K channel(s), shares='...' -> W window(s), L level
walk(s)` — then the exit dump's `measure cost:` and `frame cost:` beside it. This is how a
pane's cost is read, and the three fields are the three factors: **`W` is the pane's price
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
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%`,
`tick 1.99 ms` against an empty pane's **1.34**;
* `5,10,15,20`, 1 channel -> `4 levels`, **10.28 ms** each, `raw=0.4%`, `tick 2.38 ms`;
* the sixteen shares `5..80`, 1 channel -> `16 levels`, **24.55 ms** each, `raw=1.2%`, `tick 3.86 ms`;
* `5,10,15,20` over **4 channels** -> 4 windows and 4 levels, **35.69 ms** each, `raw=2.6%`,
`tick 4.58 ms`, and still `gaps=0 packets=30580` — the pane is a *tick* cost and not an
ingest one, which is the thing to say before blaming the producer.
`measure cost:`'s `each` is the pass and `covered`/`recomputes` is the mean window it ran
over (11.8 M samples here), so `each` is only comparable between runs at the same window and
the same pane; `raw` is the *signal's* share (the mock's `noise` channel reads `4.6%` against
its `sine`'s `0.1%` at the same window, because a level inside a bucket's own range has to be
walked sample by sample). A pass that reads **tens of ms** on a 15 s window with a handful of
measurements is the curve in
[invariants-measurements.md](invariants-measurements.md) and not a defect, and the same pane
at a 2 s window reads the 1.2--1.6 ms the pane's budget was fixed at. The guard for the walk
itself is the exit dump's `frame cost:`: sixteen shares on one channel read `24.55 ms` a pass
and `3.86 ms` of tick mean before `measure_accumulate_bucket()` was made `inline`, and
**17.25 ms** a pass after it, with every value in `measurements:` unchanged.
- `trace ink: N paints checked, C collapsed, S staircase, H short, worst cover P%, worst
drawn-vs-quoted D px over R raw paints` plus a
`[ink] paint N <flags> mode=.. axis=[..] n=.. idx=[..) spp=.. level=.. pts=.. kept=..
+16
View File
@@ -76,6 +76,22 @@ precedent, not an exception.
with, so a synchronous sequence would read every position one action stale. It clicks by
emitting `::clicked`, never `gtk_widget_activate()`, which holds a `GtkButton` for its
250 ms `ACTIVATE_TIMEOUT` and then emits into a button the rebuild has already destroyed.
- `[levels-probe] N measurement(s) over K channel(s), shares='...' -> W window(s), L level
walk(s)` (stderr, `UDPSCOPE_PROBE_LEVELS_MS`) — **prices a measurement pane**, which is what a
report of "it is slow with many measurements" has to become before it is a number. It adds one
`above` per share in `UDPSCOPE_PROBE_LEVELS` (comma separated, default `50`), spread in turn
over the first `UDPSCOPE_PROBE_LEVELS_CHANNELS` channels (default 1), and reports the two
things a count does not name: how many **windows** (one `MeasureWindowReader`, one fetch, one
scan per measured channel) and how many **level walks** (one accumulator per *distinct* share)
the pass then had to take. `50,50,50,50` on one channel is four pane rows and **one** walk;
`5,10,15,20` is four rows and **four**; the same four shares over four channels is four
windows *and* four walks. Read the `measure cost:` line it leaves in the exit dump and the
sweep in [invariants-measurements.md](invariants-measurements.md) — and read it against a run
with `UDPSCOPE_PROBE_LEVELS_MS` unset, because the pane's cost is a *difference* between two
tick means. **Run it at the scale being asked about**: the cost is the window's, so a 2 s
window prices a 15 s one at nothing (four measurements over four channels read `35.69 ms` a
recompute and a 4.58 ms tick mean at a 15 s window against `6.58 ms`/1.34 ms for one window at
the same window).
- `[measure-probe] N measurements on 'X'` (stderr, `UDPSCOPE_PROBE_MEASURE_MS`) — adds one
measurement of each statistical type (average, top, peak to peak, amplitude) on the first
*channel* and forces a recompute. It must name a **channel**, not `signals().front()`: the