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:
1 parent
714d042809
commit
a92a6b5bf0
20 files changed
+1945
-243
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user