fixed plot flicker, measurement cost and toolbar icons

The flicker ("no data" at no constant frequency, worse with the measurements
and cursors panes up) was a torn read of the window's first stamp: acquire()
pins the window to the ring's oldest slot, and reading it at paint time races
the producer lapping it, returning a stamp one ring span too new. x0() is the
origin every trace point is reconstructed from, so one torn read produced a
refused neighbours(), a filter that dropped three quarters of the points, and
a polyline shifted off its axis. It is cached by acquire() under the lock now.

EventSeries::neighbours() answered from the (x - x0)/dx grid, whose drift from
the real arrival stamps arrives as a constant offset in seconds -- a doubling
share of the screen per zoom step, which is the "zooming into a held event
clips it more from the left" report. Both series binary-search the stamps now.

The measurements pane went through the LOD pyramid rather than reading its
window raw (19.7 ms per recompute to 1.2 ms, 0.1% of a 2 M-sample window) and
recomputes on a cadence; the math engine lost a per-slot heap allocation, a
per-sample source lookup by name and a fetch_add that cost more than the
arithmetic it counted.

The cursors pane's mix mode drew a line per *named* slot, so a slot holding a
signal that can never produce a channel -- the stream's timestamp -- got a row
that could only read "--", and such names were re-saved every session. It
counts bound traces now and apply_layout() refuses them.

Four toolbar icons are drawn for this app and compiled in as a GResource: the
stock ones were a record dot for the trigger and three magnifiers for cursors,
measurements and math.

New probes and dump lines for all of it (trace ink, measure cost, math cost,
event zoom, toolbar icons); README and CLAUDE.md record what they cure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-03 23:36:07 +02:00
1 parent 714d042809
commit a92a6b5bf0
20 files changed
+1945 -243

No files matched your search

+174 -4
View File
@@ -148,6 +148,70 @@ the window its measurement was computed over. Re-placing alone was not enough: a
for the gesture's life, so the marker followed the pointer *between* frames and the readout
looked draggable.
### What a measurement costs
A measurement is a statistic of the **visible window**, and at a megasample a second a window
is millions of samples — so the obvious implementation is two O(window) walks per measurement
per recompute, and that is what it was. With four measurements over a 2.2 M-sample window it
measured **19.7 ms per recompute** and a 1.8 ms frame tick mean: a third of the frames lost, on
a pane that is only showing four numbers.
It is now **1.2–1.6 ms**, and it reads **0.1 % of the window**. The samples are not what it
walks: `plot::data::MinMaxPyramid` already holds the window as 64-sample buckets, each carrying
its own min, max, exact sum and finite count, and the producer folds every sample into it as it
arrives at amortised O(1). That pyramid *is* "evaluate only on the new samples" — it is
maintained by the ingest, so the pane inherits a summary it never paid to build.
Both walks go through it:
- **Pass one** (the extremes, the mean) adds whole buckets: 1/64 of the reads, and *exact*,
because a bucket's sum is a sum and not an estimate. It locates each extreme to a **bucket**,
and one raw scan of that bucket alone then pins it to a sample — two buckets at most, whatever
the window's size. The two stamps the scan needed are read at the end rather than at every
chunk that moved an extreme, since a stamp is a ring read and an extreme moves many times.
- **Pass two** (the threshold types: `top`, `bottom`, `pulse duration`, `period`, `frequency`)
folds a bucket whole whenever it lies *strictly* on one side of its level — then every sample
in it decides the same side, none is *at* the level, and an entering edge is exactly the
bucket's first sample, which is where the raw walk would have found it. Only the buckets that
**straddle** a level are opened, which on a clean waveform is one per crossing.
The raw path is still there and still exact, for the window's partial edge buckets, for the
samples produced since this frame's `acquire_all()` (bounded by one frame of production), and
for those straddling buckets. Nothing is decimated or strided: `peak to peak` still reads
exactly `2` on a unit sine and `top` exactly `0.636620` — 2/π, the half-wave mean, which is the
check that a threshold type is not merely plausible. A window shorter than a bucket, or a signal
noisy enough to straddle everywhere, degrades back to the O(window) cost and nothing else
changes. The dump's `measure cost:` line reports `covered`, `buckets` and `raw` for exactly
this: `raw` near `covered` means the pyramid bought nothing.
### What the math channels cost
The derived channels run on the **producer** thread, once per stored slot, so they never appear
in a frame time at all — what they can do instead is drop packets. Three things were removed
from that per-slot path: a heap allocation and free (the slot's value scratch is a member now,
`assign()`d), a per-sample type-erased lookup of each `Source` node's channel *by name* (a
linear scan with a `std::string` compare per candidate, per node, per sample — resolved once in
`math_prepare()` into `MathNodeRt::src` instead), and a locked read-modify-write that belonged
to the cost counter itself. That last one is worth naming: one `lock xadd` costs about what a
two-node graph's whole arithmetic does, so counting the slots cost more than the work being
counted. The counters are written by the producer and only by the producer, so each is a relaxed
*store* of a value already in hand.
The figure is around 80–100 ns per slot for three two-node graphs at ~1.1 MS/s — roughly a tenth
of one core, with `gaps=0`. It is sampled one slot in a thousand, and it is an **upper bound**: a
slot's work is the same order as one `clock_gettime`, so the measured slot carries a clock read.
Timing a *run* of consecutive slots to amortise that away was tried and is wrong — the producer
gets its slots a packet at a time, so a run long enough to be worth timing straddles a packet
boundary now and then and charges the measured slots the wait for the next packet. At 2000
samples a packet that reported 799 ns/slot against the true ~80.
Below that the per-slot path is at the noise floor of what can be measured here: four
identical-config runs spread over 78–101 ns/slot, which is wider than any remaining
micro-optimisation. Converting `MathRuntime` from eight parallel `std::vector<double>` to one
array of per-node structs was measured and bought **nothing** — at six nodes the whole runtime is
L1-resident either way. It was kept because a node's state belonging on one cache line is the
right shape for a graph that grows, not because it showed up.
## The session layout file
What you arranged is remembered per data source, in
@@ -362,6 +426,29 @@ arrangement as `panel controls: cursors=1 measurements=1 math=1 in the toolbar,
cursors pane`, computed on the first frame — a widget's parent is the only thing that says it, and
the dump runs after the window is gone.
**Four of those icons are drawn for this app**, because no icon theme has a glyph for what they
name, and the stock ones they used to carry said something else entirely: the trigger was a
**record dot**, and cursors, measurements and math were three different **magnifiers**. In their
place: a rising edge crossing a dashed trigger level; a pair of flat-capped markers on dashed
shafts; a ruler with alternating long and short ticks; and a sigma. They live in
`src/icons/scalable/actions/` and are compiled into the binary as a `GResource` rather than
installed beside it — udpscope is run straight out of the build tree and has no install step, so
a search path would have to be found at runtime, and the failure mode of not finding it is a
silent placeholder. Hence the dump's `toolbar icons:` line, which names every button's icon and
whether the theme resolved it.
Drawing them was mostly learning what a 16 px symbolic icon will not forgive. Everything sits on
**whole-pixel** boundaries: the first draft placed features on half-pixels and the whole set was a
grey smear at 1x, legible only when magnified. Every shape is a filled `path` — GTK's symbolic
recolour reaches `fill` on `path`, `rect` and `circle` and nothing else, so a stroked line keeps
the file's own colour and stays dark on a dark theme. The ruler's body is a hole punched with
`fill-rule="evenodd"`, which also lets its ticks fill (three crossings deep is odd); the sigma's
four subpaths are wound the same direction instead, so the default nonzero rule unions them rather
than cancelling the overlaps. Two readings had to be designed away: the trigger's level at a
2-on/2-off dash rhythm read as scattered dots, fixed at 2-on/1-off with one wide gap where the
edge rises through, and the cursors first read as **audio faders** — a downward triangular cap
then read as a funnel, and what works is a flat cap over a *dashed* shaft.
**The session is saved with the layout**, per data source, in
`~/.config/udpscope/<host>_<port>.toml`: the display window, the rows and which signal each slot
holds, the per-signal calibration, **which signals were enabled**, **the math channels with their
@@ -450,6 +537,22 @@ event pin already worked this way, and the pause agrees with it. `[pause-probe]`
beside `frozen_x=`, and while paused the axis must lie *inside* `frozen_x` — equal at the pause, a
subset after a zoom-in.
**Zooming into a held event clipped it further and further from the left**, and the cause was the
one the library's own margin fix is about, in the half of the code the fix could not reach.
`EventSeries::neighbours()` — the call the renderer maps the visible range onto sample indices with
— answered from the nominal `(x - x0()) / dx()` grid, while `ChannelBuffer::neighbours()` next to
it binary-searches the real time ring. `dx()` on a snapshot is the *mean* spacing of its stamps and
the stamps are an arrival clock, so the grid and the clock drift apart by however much the clock
wandered over the capture; the renderer was handed an index window offset from the axis by that
drift. Because the offset is a constant number of *seconds*, its share of the screen doubles with
every zoom step: a 2 s capture off by 8 ms is invisible, and seven halvings later it is 374 px of
blank on a 716 px axis, with the trace's ink pushed off the right-hand edge by the same amount.
The crosshair reads `x_at()` — a real stamp — so it went on reporting the right value over the
blank part, which is exactly how the report described it. `neighbours()` is a binary search over
`stamps` now, like every other series here. A perfectly uniform source hides the defect
completely, so the mock can jitter its clock (`--clock-jitter`, applied to each packet's own
spacing so the stream stays monotonic) and `[zoom-probe]`'s `gap_px` is the guard.
**A pinned window is not a navigation surface.** In trigger mode the x window is the event —
`[-pre, +post]` while a capture fills, the snapshot's own span once it is held — and a gesture
must not move it. Nothing was pulling a drag back: `clamp_to_data()` is the live-view half of
@@ -535,6 +638,53 @@ costs a y value and never the geometry. The self-check prints `dx_ratio` and the
renderer's index window beside the binary-searched one, so both are checked rather than
assumed.
**The clamp made that slot survivable; it did not make reading it safe**, and the difference is
the "with the measurements and cursors panes up the plot flickers as if there were no data, at no
constant frequency" report. Sample 0 of the window is not an ordinary slot: `acquire()` pins the
window to the value ring's `base()`, so it is the ring's *oldest* — the one the producer is about
to overwrite. `time_of()` checks the bounds and then copies, and everything on a `ChannelBuffer`
except `acquire()` is read at **paint time with no lock**, so when the producer lapped that slot
in between, the stamp came back one whole ring span too new. From there it is `x0()`, which is the
origin the renderer reconstructs *every* trace point from (`gather[i] + series.x0()`), read both
inside its increasing-suffix filter and again when it stages the geometry — so one torn read of
one slot produced three different pictures, all of them "the waveform is gone, the grid and the
axes are still there":
* `neighbours()` tests the asked-for x against `x_at(0)`, so a bad first stamp made it refuse the
whole visible range. The renderer then gathered the **entire ring** at a coarse level and drew it
off the axis — the probe caught `idx=[0..8388608) level=7 cover=320%`.
* `x0()` changing *during* the filter loop made it drop three quarters of the points (`kept=750`
against `kept_fixed_x0=1865` on the same frame).
* Staging shifted the finished polyline a ring span sideways: `ink=[-6.04..0.38]` against
`axis=[4.80..6.80]`.
The stamp is now read **once, by `acquire()`** — which runs on the consumer under
`ScopeModel::mutex()`, the same lock the producer's pushes hold, on the line after the window is
pinned, which is the one point in the object's life where that slot cannot move — and cached as
`window_x0_`; `x0()` and `x_at(0)` are served from it. It is also the *correct* answer rather than
merely a stable one: it is the stamp sample `window_base` actually carries, where the paint-time
clamp answered with whatever slot happened to be oldest at the moment of asking. The defect was
live-only (a snapshot is immutable, so its `x0()` never moves, which is why a held event never
flickered), and the measurements pane made it both likelier and larger because the pane holds
`mutex()` for each window reader's lifetime and the released producer then bursts through several
packets at once — hence the report's pairing with the panes, which turned out to be a symptom of
frame length rather than of the panes themselves. The user's own first guess, that it might be an
anti-aliasing artefact of a thin line, is ruled out by the line shader: a degenerate segment still
gets quad area on purpose, and `half_w = width/2 + aa` with the fragment ramp keeps a 1 px line
above about half coverage at the worst alignment. Anti-aliasing can dim a trace; it cannot erase
one.
Neither `staged_segments` nor `lod_traces` can see this — the first counts the grid and the
instruments too, the second counts a trace whose points are collapsed a moment later — so the
guard is a new per-paint instrument. `check_frame_ink()` recomputes what the renderer was handed,
through the same calls, and reports where the ink landed against the axis: the dump's
`trace ink: N paints checked, C collapsed, S staircase, H short` must read `0` for the first two,
and each anomalous frame prints an `[ink]` line whose `back` ≈ one ring span is this defect's
signature. The same torn read still exists for a small `i > 0` — the oldest frame or so of samples
— but there the observable is a prefix plateau of equal stamps, which is exactly what the
renderer's increasing-suffix filter exists to absorb, so it is left alone rather than fixed
speculatively.
**A held event carries its own LOD pyramid**, and the lack of one is what "the plot gets
laggy with large data" was. The renderer picks a level per trace per frame with
`MinMaxPyramid::level_for()`, which descends to level 0 — *raw samples* — on a pyramid
@@ -823,7 +973,7 @@ chooses what the instruments are:
| `vertical` | two x markers, one pair for the whole figure | `T0`, `T1`, `dT` |
| `horizontal` | two y rulers on **each** row | `Y1`, `Y2`, `dY` per row |
| `both` | both of the above | the times, then a line per row |
| `mix` | the x pair, plus where each marker **crosses each trace** | the times, then `Y1`, `Y2`, `dY` per plotted signal |
| `mix` | the x pair, plus where each marker **crosses each trace** | the times, then `Y1`, `Y2`, `dY` per **bound** trace |
The vertical pair is one stream time shared by every row, so the markers line up down
the column and dragging one on any row moves them all — the app reads the position back
@@ -854,9 +1004,29 @@ instruments can claim (a marker lands on a sample, and the sample is an `f32`),
trailing zero is dropped, so `1.50 V` reads `1.5 V`. A row's rulers use its signals' unit
when they agree on one and print bare digits when they do not: a ruler is on the row's y
axis, and two units on one axis make "the" unit a guess. A trace's line carries the
signal's **colour chip** instead of its name — the chip identifies it exactly as the signal
panel does — and the values are bare: `● 616.49 mV -24.57 mV -641.06 mV`, because the
pane is a readout beside a plot and `Y1:` labels there are noise.
signal's **colour chip and its name** — `● sine 616.49 mV -24.57 mV -641.06 mV` — and the
values are bare, because the pane is a readout beside a plot and `Y1:` labels there are noise.
The chip began as the *whole* identifier, on the argument that it names a trace exactly as the
signal panel does; it does not, once there are several traces, because matching a colour
against the plot by eye is a guess, and a line with nothing but a chip is also unreadable in a
bug report. The name is appended to the chip's markup (escaped — a signal name is the stream's
text, not ours) and the cell is ellipsized with the full name on a tooltip, so a long name
cannot widen the pane.
**`mix` draws a line per *bound* trace, not per named slot.** A slot holds a signal **name**
and the channel behind it is re-resolved every frame, so a name with no channel is not a
trace — and some names can never get one: the stream's time signal is never acquired, so a
slot holding it has a name for ever and data never. The plot drew nothing for such a slot,
which is how it stayed invisible, but the pane counted names and gave each one a row that
could only read `--`: a user with three traces saw six lines, three of them blank. They were
also **sticky**, because a session file is written from the slot names verbatim, so one that
got in was saved again every session. Both halves are fixed — the pane follows the bind
(which is also why its rebuild watches a bit per slot rather than only the model's
generation: a trace can appear with no structural change behind it), and loading a layout
now refuses a name that cannot produce a channel, which heals an existing file on its next
save. Where those names originally came from is unexplained: dropping a signal that has no
channel is already refused, so the likely source is an older build or a datasource whose
time signal was not yet recognised as one.
`mix` also *draws* the crossings: a mark on each trace where each marker meets it, in the
marker's own colour, placed with the same call the pane reads (so the mark and the number