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
@@ -95,8 +95,23 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||||
placed, which is the only way a stale result or a mark that should not be drawn shows up as a
|
||||
number. It must never print `marks=0` while measurements with `show_on_plot` are on and the
|
||||
data is there -- that was the rulers blinking off at a mode change.
|
||||
`measure cost: N recomputes, X ms total, Y ms each` follows it: **read it against a run with
|
||||
`UDPSCOPE_PROBE_MEASURE_MS` unset**, because a frame mean cannot attribute a cost.
|
||||
`measure cost: N recomputes, X ms total, Y ms each, data D ms (P%), covered=C buckets=B
|
||||
raw=R (F%), N ns/covered sample` follows it: **read it against a run with
|
||||
`UDPSCOPE_PROBE_MEASURE_MS` unset**, because a frame mean cannot attribute a cost. The last
|
||||
three fields are the assertion, and they are a *ratio*, not a time: `covered` is the samples
|
||||
the window spans, `buckets` the LOD buckets the two walks answered from, and `raw` the samples
|
||||
they still had to read. **`raw` must stay at a fraction of a percent of `covered`** on a clean
|
||||
waveform over a window of millions -- 0.1% is what the pyramid-backed walks give (see the
|
||||
measurement-cost invariant below). A `raw` near `covered` means the walks went back to reading
|
||||
the window, which at 1 MS/s is 19.7 ms per recompute and a third of the run's frames. It is
|
||||
*legitimately* near `covered` for a window shorter than a bucket (64 samples) or a signal noisy
|
||||
enough to straddle a level everywhere, so read it on the fast mock
|
||||
(`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`), where a window is ~2 M samples.
|
||||
Note the **final** `measurements:` line and `[measure-trace]`'s last lines may read `--`:
|
||||
`UDPSCOPE_EXIT_AFTER_MS` arms the trigger before it dumps, so the dump can land in `Waiting`
|
||||
with nothing bound and the axis at its default. The race-free reading is an earlier
|
||||
`[measure-trace]` line, which carries its own `view=`; those lines are also where the
|
||||
level-mark fallback is visible (an invalid pass must still print its `level:` marks).
|
||||
|
||||
- `[pan-probe] asked=[..] was=[..]`, `[pan-probe] after=[..] held=D asked=[..]`, then
|
||||
`[pan-probe] zoom asked=[..] after=[..] span=S window=W limited=L`
|
||||
@@ -120,6 +135,29 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||||
that is the state the old `max(retained, window_s_)` bound leaked in, and it showed up here as
|
||||
`span=2.502550 window=2.000000 limited=0` against `span=2.000000` after the fix.
|
||||
|
||||
- `[zoom-probe] <sweep>/x<step> axis=[..] span=.. n=N idx=[a..b) spp=.. level=L pts=P kept=K
|
||||
back=.. ink=[..] x_at_first=.. gap_px=.. end_px=.. rect=[..]` (stderr,
|
||||
`UDPSCOPE_PROBE_EVENTZOOM_MS`) — arms the trigger in **single** mode, polls for a **held
|
||||
event** (a latch happens when the stream says so; a sweep on a timer has nothing to zoom
|
||||
into), then zooms in eight doublings
|
||||
twice: `ges/` writes the row's own axis the way a gesture does, `win/` drives the window
|
||||
control (`apply_time_window`, which unpins and re-arms the live follow before the pin is
|
||||
restated). Each step's reading is taken in the *tick*, after that frame's pin and clamp — a
|
||||
`g_timeout` would report the ask. It recomputes what `Renderer::draw_traces` is handed, through
|
||||
the same calls: `neighbours()` for `idx`, the 64-spp gate for `level`, then `gather()`.
|
||||
**`gap_px` and `end_px` must stay within a pixel or two at every step** — they are how much of
|
||||
the left and right margin no geometry reaches, and a `gap_px` that *doubles* per step is the
|
||||
"zooming into a held event clips it more and more from the left" defect (374 px of a 716 px
|
||||
axis by `ges/x128`), whose cause is a constant offset in data seconds (see the `neighbours()`
|
||||
invariant below). `kept`/`back` are the renderer's own increasing-suffix filter counted rather
|
||||
than applied: `kept` far below `pts` is a staircase, and a non-zero `back` is a non-monotonic
|
||||
gather. **Run it with `--clock-jitter`** (`tools/mock_udpstreamer.py --rate 1300000 --batch
|
||||
2000 --clock-jitter 0.4`): the ideal mock's stamps sit on a perfect grid, which is exactly
|
||||
what hides this class of bug, and three runs against it reported `gap_px=0.0` throughout while
|
||||
the defect was live. The jitter varies each *packet's* spacing rather than offsetting its
|
||||
start, because the stream's clock has to stay strictly increasing. The `win/` sweep bottoms
|
||||
out at `span=0.05` (`RowSet::set_time_window()`'s floor) and walks to the event's post side,
|
||||
since `set_time_window()` frames the newest span before the pin clamps it — both are expected.
|
||||
- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are
|
||||
measured on a still picture. It now also asserts what a pause *is*
|
||||
(`[pause-probe] <action> paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..]` on
|
||||
@@ -275,17 +313,21 @@ Two lines of the self-check output are assertions in disguise:
|
||||
(a parked cursor reports a real number, so a read-back would adopt it as a gesture — the
|
||||
bug that made `T0` read 0). **`lines`** must be 0 for `vertical`, one per row for
|
||||
`horizontal`/`both` (and the time row exists only in the modes that show the vertical
|
||||
pair — `T=[]` in `horizontal` is the assertion that it does not), and one per bound trace
|
||||
for `mix`; in `mix` the values must be the
|
||||
pair — `T=[]` in `horizontal` is the assertion that it does not), and one per **bound**
|
||||
trace for `mix` — a slot with a name *and* a channel the model built, never a name alone
|
||||
(see the bound-slot invariant below; `lines=6 marks=3` over three real traces is that
|
||||
defect). Each line reports as `<name>=<A> <B> <dY>`, so the names are the reading: a row
|
||||
claiming a signal that is not on a plot is the phantom. In `mix` the values must be the
|
||||
traces' own (`--` when a marker is past the newest sample, `partial` when only one
|
||||
marker is over samples). After the move, `t0` must have changed and every row's marker
|
||||
must have followed it (the report's `t0` is the shared value). **`marks`** is the `mix`
|
||||
mode's crossing annotations: **0 in every other mode, and one per bound trace for each
|
||||
marker that is over samples in `mix`** (so a move that brings the second marker into the
|
||||
data doubles it). The `first mark at x=.. y=..` line that follows must agree with the
|
||||
pane's *first* value for the same trace (the pane's lines are `chip Y1 Y2 dY`, with
|
||||
the values formatted as measurements -- two decimals, an SI prefix, the channel's unit --
|
||||
so the comparison is against the first number): both are read through the same
|
||||
pane's *first* value for the same trace (the pane's lines are `chip name Y1 Y2 dY` --
|
||||
the chip carries the signal's name, since a chip alone leaves the user to match a colour
|
||||
against the plot by eye -- with the values formatted as measurements, two decimals, an SI
|
||||
prefix, the channel's unit, so the comparison is against the first number): both are read through the same
|
||||
`Plot::crosshair_value()`, and that line is the check that they stay the same number.
|
||||
A **ruler** drag is the other direction and the other hazard: the pane's `A`/`B` cells
|
||||
for a ruler row must change when the ruler moves (`after ruler drag` in the probe walks
|
||||
@@ -309,11 +351,18 @@ Two lines of the self-check output are assertions in disguise:
|
||||
its unit, so `frequency` must read `Hz` (not the signal's unit), `period`/`pulse duration`
|
||||
seconds, and the rest the channel's unit. `marks_shown` counts two per shown measurement
|
||||
(a min/max showing both its marks counts two of its own). `measures window:` is the
|
||||
timeliness assertion: **`drift` must stay at (or near) zero** -- the window the numbers
|
||||
were computed over against the one on screen -- and every *time* mark must lie inside it
|
||||
(`marks_in_window=K/K`). A measurement is recomputed when the window moves (2 % of its
|
||||
span) or the held/live state changes, never on a wall-clock timer: a timer is what let a
|
||||
marker sit outside the viewport and a dragged one survive a frame. **`marks_selectable` must be 0**: the
|
||||
timeliness assertion -- the window the numbers were computed over against the one on screen.
|
||||
**The reading that must hold is `marks_in_window=K/K`**: every *time* mark inside the window
|
||||
on screen. `drift` itself is a **single sample of a sawtooth** and is not an assertion on its
|
||||
own: a live view scrolls between recomputes, so it rises to one `kMeasureRefreshUs` of scroll
|
||||
and drops to zero at each pass. On a 2 s window at the 100 ms cadence the top of that sawtooth
|
||||
is `drift=5%`, which is what a dump landing just before a scheduled pass reads -- a `0.0000%`
|
||||
is a dump that landed just after one, not a better state. Read it against the recompute count:
|
||||
*more* recomputes with a *higher* drift sample is the sawtooth, while a drift that exceeds one
|
||||
refresh interval of scroll, or marks outside the window, is the defect. A measurement is
|
||||
recomputed when the window moves (2 % of its span) or the held/live state changes, never on a
|
||||
wall-clock timer: a timer is what let a marker sit outside the viewport and a dragged one
|
||||
survive a frame. **`marks_selectable` must be 0**: the
|
||||
measurement marks are created unselectable (`Plot::set_cursor_selectable()`, which the
|
||||
library gained for this), read back out of the plot here -- re-placing a marker every frame
|
||||
does **not** stop a drag, because the controller holds it for the gesture's life and the
|
||||
@@ -387,6 +436,15 @@ Two lines of the self-check output are assertions in disguise:
|
||||
and an expanding one is measured into the title's centre box, so it stretched from the
|
||||
label to the title (516 px at a 1270 px bar before the fix, 121 px after). A `W` that
|
||||
grows with the window width is that bug back.
|
||||
- `toolbar icons: trigger=NAME(ok) pause=... cursors=... measures=... math=... sidebar=...
|
||||
menu=...` (in the exit dump, beside `panel controls:`) — the icon name every toolbar button
|
||||
carries, with `gtk_icon_theme_has_icon()`'s verdict. **Every entry must read `(ok)`.** Four
|
||||
of the names are this app's own (`udpscope-trigger-symbolic` and friends, a `GResource`
|
||||
compiled into the binary — see the toolbar-icon invariant below), and a `(MISSING)` on one of
|
||||
those means the resource path was never registered. That failure is otherwise *silent*: GTK
|
||||
substitutes a missing-image placeholder, the button still works, and every other line of the
|
||||
dump still passes. Changing an icon name is a one-line change in `build_toolbar()` and this
|
||||
is the only check that it names something that exists.
|
||||
- **The session layout round trip** (`~/.config/udpscope/<host>_<port>.toml`) — run once
|
||||
with a drop and a window change, then again with no interaction: the second run's dump
|
||||
must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the
|
||||
@@ -433,6 +491,48 @@ Two lines of the self-check output are assertions in disguise:
|
||||
lines and the disarm probe's `axis=`/`snap=` need a fast source to say anything —
|
||||
`tools/mock_udpstreamer.py --rate 1300000 --batch 20000` — since at the default 10 kS/s a
|
||||
whole window fits in the raw path and the publish is too short to lose a race.
|
||||
- `trace ink: N paints checked, C collapsed, S staircase, H short, worst cover P%` plus a
|
||||
`[ink] paint N <flags> mode=.. axis=[..] n=.. idx=[..) spp=.. level=.. pts=.. kept=..
|
||||
ink=[..] cover=..% x0=.. x_at=[..] bounds=[..] rel=[..] dx=.. kept_fixed_x0=.. drops=[..]
|
||||
back=..` line per anomalous frame (stderr) — **`collapsed` and `staircase` must both be 0**.
|
||||
This is the only check that can see a frame whose *waveform* vanished: `staged_segments`
|
||||
counts geometry including the grid and the instruments, and `lod_traces` counts a trace whose
|
||||
points are then collapsed, so both read healthy through the flicker. The probe runs on every
|
||||
painted frame that drew a trace and recomputes what the renderer did, through the same calls
|
||||
— `neighbours()` for the index window, the 64-spp gate for the level, `gather()`, then the
|
||||
increasing-suffix filter — and reports where the ink actually landed against the axis.
|
||||
`collapsed` is fewer than two distinct positions left (nothing to draw at all), `staircase`
|
||||
is the filter dropping over three quarters of the points, `short` is ink reaching under a
|
||||
quarter of the axis (reported only once the data spans the window, since a filling ring is
|
||||
*meant* to leave its left side empty). `kept_fixed_x0` is the same filter with `x0()` hoisted
|
||||
out of the loop: it exceeding `kept` means `x0()` moved *during* the walk. **`back` ≈ one ring
|
||||
span is the signature** of the torn `x0()` read (see the cached-first-stamp invariant); so is
|
||||
an `ink=` range disjoint from `axis=`, or an `idx=` covering the whole ring with `cover` far
|
||||
over 100%. It is a race, so it needs load and a reason for long frames —
|
||||
`tools/mock_udpstreamer.py --rate 1300000 --batch 2000 --clock-jitter 0.4` with the
|
||||
measurements *and* cursors panes up:
|
||||
`UDPSCOPE_ENABLE_ALL_MS=1000 UDPSCOPE_PROBE_MEASURES_MS=2000 UDPSCOPE_PROBE_CURSORS_MS=3500
|
||||
UDPSCOPE_EXIT_AFTER_MS=35000`, which reported three staircase frames in each of three runs
|
||||
while the defect was live and none after. `frame path:`'s own numbers move with it (`segments`
|
||||
max 156078 → 19681), since a refused `neighbours()` gathers the whole ring.
|
||||
- `traceless paints: N (M after the first bind, last at paint P)` and `worst side/pane min
|
||||
width: W px`, plus `[gen] frame F generation A -> B (bound=.. empty=..)` and `[width-probe]
|
||||
...` lines (stderr) — three cheap per-frame counters that came out of the flicker hunt and
|
||||
each guard a documented invariant from the side the existing lines cannot see. **`M` must be
|
||||
only the frames in a state that draws nothing**: a traceless paint is legitimate in
|
||||
`DisplayMode::Waiting`, which is why the line carries `mode=` — `UDPSCOPE_EXIT_AFTER_MS` arms
|
||||
the trigger before it dumps, so a tail of them at the end of a run is expected. `[gen]` names
|
||||
each buffer rebuild as it happens with the bind state around it: a rebuild drops every ring's
|
||||
history, so the frames after one legitimately draw an empty plot, and `blank view frames`
|
||||
cannot see that (it asks whether the ring holds samples the view has not got, and a rebuilt
|
||||
ring holds none either) — so an unexplained `[gen]` burst is a structural change firing when
|
||||
nothing structural happened. `worst side/pane min width` is the "layout must not depend on
|
||||
content" invariant read from the **demand** side: a `GtkLabel`'s minimum width is its text and
|
||||
a minimum wider than the screen is honoured by growing the toplevel, which hands the plot an
|
||||
allocation wider than the window; `axes.w` sees that a frame later and cannot name the cause,
|
||||
so the panes the tick rewrites are measured directly. The `[width-probe]` lines are the
|
||||
per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget
|
||||
tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read.
|
||||
|
||||
The probes are the regression net — when changing UI or plot-host behaviour, extend
|
||||
the probe/dump in `src/ui.cpp` rather than adding a separate harness.
|
||||
@@ -553,6 +653,24 @@ far the largest file.
|
||||
while every other row showed them. The same goes for the vertical pair's `x`: it is one
|
||||
stream time for the figure, so it lives on the `RowSet` too, and `Row` keeps only what is
|
||||
genuinely per row (the rulers' values, the traces, the colours).
|
||||
- **A trace is a slot with a name *and* a channel; a name alone is not one.** `Row::channel[s]`
|
||||
is re-resolved by `RowSet::bind()` every frame (slots are identified by name, see above), and
|
||||
`-1` means the model built no channel for that name. Some names can *never* bind: the stream's
|
||||
**time signal** is skipped by `build_channels_locked()` however often it is enabled, so a slot
|
||||
holding it has a name for ever and data never. The plot showed nothing for it, which is why it
|
||||
went unnoticed, but the cursors pane's `mix` mode drew one line per *named* slot and so gave
|
||||
each phantom a row that could only read `--` — "the pane shows a lot of empty signals that do
|
||||
not exist", six lines over three real traces. Two halves to the rule. (1) Anything that counts
|
||||
traces counts `channel[s] >= 0`, not `!signal_name[s].empty()` — and a per-frame bind means the
|
||||
cursors pane cannot key its rebuild on `generation()` alone, hence `App::cursors_pane_bound`, a
|
||||
bit per row/slot (`kMaxRows * kSlotsPerRow` is 32, so one `std::uint32_t`): a trace that
|
||||
appears without a structural change still gets its line. (2) `apply_layout()` refuses a name
|
||||
that cannot produce a channel (`ScopeModel::is_time_signal()`), because such a name was
|
||||
**sticky**: `collect_layout()` writes `signal_name` back verbatim, so one that got into a
|
||||
session file was re-saved every session for ever. The refusal also heals an existing file on
|
||||
the next save. How the names first got in is unexplained — the drop path already refuses a
|
||||
signal with no channel — most likely an older build or a CONFIG whose time signal was not yet
|
||||
classified as one; the fix is the stickiness and the counting, not a guess at the origin.
|
||||
- **A row's contents are state of the `RowSet`, not of the figure.** `insert_row()` and
|
||||
`remove_row()` shift that state with `carry_row()` and let `rebuild_all()` re-deal the
|
||||
figure handles, so inserting above row 1 moves what row 1 plotted down to row 2 rather
|
||||
@@ -628,6 +746,20 @@ far the largest file.
|
||||
computes `got` from the same call the renderer makes, so it cannot catch a renderer that went
|
||||
back to the grid — what it still reports is the drift that made the walk wrong (`dx_ratio`).
|
||||
|
||||
- **`neighbours()` is a binary search over the real stamps in *every* series here, the
|
||||
snapshot's included.** `EventSeries` answered from the `(x - x0()) / dx()` grid — the walk the
|
||||
invariant above condemns: `dx()` is the *mean* spacing of the snapshot's stamps and the stamps
|
||||
are an arrival clock, so grid and clock drift apart by whatever that clock wandered over the
|
||||
capture. The renderer picks its visible index window from this call, so the drift arrived as a
|
||||
**constant offset in seconds** between the axis and the gathered geometry — and a constant
|
||||
number of seconds is a *doubling share of the screen* on every zoom step: 8 ms over a 2 s
|
||||
capture is invisible, and seven halvings later it is 374 px of blank left margin on a 716 px
|
||||
axis, with the ink pushed off the right-hand edge by the same amount. That is the "zooming
|
||||
into a held event clips it more and more from the left while the crosshair still reads the
|
||||
right values" report; the crosshair was right because it reads `x_at()`, a real stamp.
|
||||
`ChannelBuffer` always searched; the two now agree. **A perfectly uniform source cannot show
|
||||
this at all** — three probe runs against the ideal mock were clean — so the guard needs
|
||||
`--clock-jitter` (see `UDPSCOPE_PROBE_EVENTZOOM_MS` above).
|
||||
- **The `Series` grid contract does not hold for `ChannelBuffer`.** x is a streamed
|
||||
timestamp, so `dx()`/`x0()` report a nominal grid (LOD level, coarse snap) while
|
||||
`gather()`, `x_at()`, `neighbours()` and `x_bounds()` use the real time ring.
|
||||
@@ -668,6 +800,34 @@ far the largest file.
|
||||
every live view. The library's `StreamSeries` is immune for a reason worth copying:
|
||||
its x is `x0 + i*dx`, a function of the absolute index, so an overwritten slot costs a
|
||||
*y* value, never the geometry. Clamping buys the same bargain here.
|
||||
- **The window's *first* stamp is cached by `acquire()` and never read at paint time.** The
|
||||
clamp above makes an unreadable slot *survivable*; it does not make reading it *safe*, and
|
||||
`absolute(0)` is not an ordinary slot — `acquire()` pins the window to `values_->base()`, so
|
||||
sample 0 is the ring's oldest, the very slot the producer is about to overwrite. `time_of()`
|
||||
checks the bounds and then copies, and when the producer laps the slot in between the stamp it
|
||||
returns is **one whole ring-span too new**. Everything else on a `ChannelBuffer` is read at
|
||||
paint time with no lock, and `x0()` is the origin the renderer reconstructs every trace point
|
||||
from (`gather[i] + series.x0()`), read both inside its increasing-suffix filter and again at
|
||||
`stage_xy()` — so one torn read of that one slot produced three pictures, all of them "the
|
||||
waveform is gone while the grid and the axes stay": `neighbours()` tests against `x_at(0)` and
|
||||
refused the whole visible range (the renderer then gathered the *entire* ring at a coarse level
|
||||
and drew it off the axis — `idx=[0..8388608) level=7 cover=320%`); `x0()` changing *during* the
|
||||
filter loop dropped three quarters of the points; and `stage_xy()` shifted the finished polyline
|
||||
a ring-span off its axis. That is the "with measurements and cursors active the plot flickers as
|
||||
if there were no data, at no constant frequency" defect. It is **live only** (a snapshot is
|
||||
immutable, so `x0()` is constant for `Held`/`Paused`), it is a race against packet arrival so it
|
||||
is irregular, and the measurements pane makes it both likelier and larger: the pane holds
|
||||
`mutex()` for each `MeasureWindowReader`'s life, so the released producer bursts several packets
|
||||
at once. `acquire()` runs on the consumer under `ScopeModel::mutex()` — the same lock the
|
||||
producer's pushes hold — and the window is pinned on the line above, so that is the one point in
|
||||
the object's life where the slot is safe to read; `window_x0_` is also the *right* answer rather
|
||||
than merely a stable one (it is the stamp sample `window_base` actually carries, where a
|
||||
paint-time clamp answered with whatever slot happened to be oldest *now*). `x_at(0)` is served
|
||||
from it too, for the same reason, and `invalidate()` clears it with the window. The guard is
|
||||
`trace ink:` / `[ink]` (see Verification). The same torn read still exists for a small `i > 0`
|
||||
— the oldest frame or so of samples — but there its observable is a *prefix plateau*, which is
|
||||
exactly what the renderer's increasing-suffix filter was written to absorb, so it is a different
|
||||
symptom and deliberately not fixed here.
|
||||
- **`clamp_to_data()` caps the span at the *requested window*, never at the retained
|
||||
span.** It runs every frame and cannot distinguish "the user zoomed out past the data"
|
||||
from "the ring has only just started filling", so `window_s_` — what the window control
|
||||
@@ -764,7 +924,18 @@ far the largest file.
|
||||
so no rebuild can invalidate a live callback handle -- the same shape the measurements dialog
|
||||
uses. `UDPSCOPE_PROBE_MATH_MS` defines two graphs and opens the editor (a construction crash test
|
||||
as much as a state check), and the dump prints each derived channel's extremes beside the
|
||||
source's. Four rules here were wrong first and are worth keeping:
|
||||
source's. Read it on the fast mock, where the two assertions that matter are **`gaps=0`** (the
|
||||
math is on the producer thread, so what it can break is packet capture, never the frame rate)
|
||||
and each `math '<name>': ... N samples vs M time slots` being **equal**, with `math x2` at
|
||||
`y=[-2, 2]`, `math lp` at `y=[-0.998, 0.998]` and `math3` at `y=[-2, 2]`. The `0 visible
|
||||
(order breaks 0, first at -1), y=[0, 0]` tail of that line is **not** a failure: it is
|
||||
`visible_samples()` reading row 0's axis after the window was destroyed, and
|
||||
`UDPSCOPE_EXIT_AFTER_MS` arms the trigger first, so the dump can land in `Waiting`. The live
|
||||
`ch N '<name>' ... y=[..]` lines are the race-free reading of the same thing.
|
||||
`math cost: S slots, N ns/slot (T timed), X ms total est` is the per-slot cost; see the
|
||||
math-cost invariant below for why it is sampled, why it is an upper bound, and why a figure
|
||||
anywhere in 78--101 ns/slot is one reading of the same thing. Four rules here were wrong first
|
||||
and are worth keeping:
|
||||
* **A `Source` may only name an *acquired* channel** -- `ScopeModel::channels()`, which is what the
|
||||
dialog's dropdown lists, **and both places that create a node must take one from there**.
|
||||
`signals().front()` is the stream's *timestamp* signal: it is first in every CONFIG, it is not
|
||||
@@ -931,6 +1102,67 @@ far the largest file.
|
||||
compares with a relative tolerance, because clamping to exactly `window_s_` can leave the
|
||||
group's span an ULP above it and a clamp that fires every frame is a re-frame loop, not a
|
||||
clamp.
|
||||
|
||||
- **A measurement must not read its window raw.** Both passes go through
|
||||
`plot::data::MinMaxPyramid`, which the producer already maintains at amortised O(1) -- so the
|
||||
pane inherits the summary of the new samples rather than computing one, which is what the
|
||||
"evaluate only on new samples" ask comes to here. Throttling the cadence (above) bounded *how
|
||||
often*; this bounds *how much*, and it is the larger factor: 19.7 ms per recompute to 1.2--1.6
|
||||
ms, reading **0.1 %** of a 2 M-sample window. Pass one (`measure_scan_buckets()`) adds whole
|
||||
level-0 buckets -- 64 samples each, carrying min, max an **exact** sum and a finite count -- and
|
||||
locates each extreme to a *bucket*; one raw `measure_scan()` of that bucket alone then pins it
|
||||
to a sample, two buckets at most whatever the window's size. Pass two
|
||||
(`measure_accumulate_bucket()`) folds a bucket whole only when it lies **strictly** on one side
|
||||
of its level (`k.lo > level` or `k.hi < level`): then every sample 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, so this is exact and not an approximation. It refuses a
|
||||
bucket that straddles, and a bucket holding a non-finite sample, because the raw walk takes the
|
||||
first *finite* sample of the new side and an aggregate cannot say which that is; the caller
|
||||
then opens that bucket. Four things this rests on, each of which breaks it silently:
|
||||
* **Exactness is the point, so nothing may stride or decimate.** `peak to peak` must still read
|
||||
exactly `2` on a unit sine and `top` exactly `0.636620` (2/pi). A bucket's sum is a sum.
|
||||
* **The bucket index space must be the window's.** `ChannelBuffer::acquire()` calls
|
||||
`pyramid_.drop_before(values_->base())`, so a channel's pyramid is in the value ring's
|
||||
absolute index space -- the same space `live_window_locked()` answers in, since a channel's
|
||||
stride is always 1. The snapshot's pyramid is built from sample 0 and never dropped from, so
|
||||
its space is the snapshot's own. `MeasureWindowReader::origin_` is what turns one into a
|
||||
window offset, and a mismatch there measures the wrong samples rather than failing.
|
||||
* **The raw path stays, and stays exact**, for the window's partial edge buckets, for the
|
||||
samples produced since this frame's `acquire_all()` (one frame of production, so bounded), and
|
||||
for the straddling buckets. A window shorter than a bucket degrades to O(window) and must
|
||||
still be right.
|
||||
* **Both passes walk the same fetched bucket list** (`App::measure_bucket_scratch`, reused
|
||||
across recomputes -- a 2 M-sample window is 32 k buckets). Fetching twice is a second walk of
|
||||
the pyramid for nothing.
|
||||
`measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
|
||||
anything.
|
||||
|
||||
- **The math channels' cost is the *producer's*, and the instrument must not be part of it.**
|
||||
`push_math_sample()` runs once per stored slot -- up to the wire rate -- so what a regression
|
||||
here looks like is `gaps`, never a frame time. Three things were taken off that path and must
|
||||
not come back: a `std::vector` *local* (a heap allocation and free per slot; it is the member
|
||||
`math_values_`, `assign()`d), a `Source` node's channel resolved **by name** per sample (a
|
||||
linear scan with a `std::string` compare per candidate, per node, per slot, behind a
|
||||
type-erased call -- `math_prepare()` resolves it once into `MathNodeRt::src`, and every rebuild
|
||||
re-prepares), and a **`fetch_add` on the cost counters**. That last one was 20-25 % of the
|
||||
measured figure: a `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 producer-written
|
||||
only, so each is a relaxed `store()` of a value already in hand (`math_slot_count_` and
|
||||
friends). Two things about the figure itself:
|
||||
* **It is sampled, one slot in `kMathCostSample`, 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 **is wrong and was tried**: 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 a 64-slot run crosses one ~3 % of the time, and a ~1.5 ms gap spread over 64
|
||||
slots reported **799 ns/slot against the true ~80**. One slot is always inside one packet.
|
||||
* **78--101 ns/slot is one reading.** Four identical-config runs spread that far, so the per-slot
|
||||
path is at the noise floor of this harness and a micro-optimisation cannot be shown to work
|
||||
here. Converting `MathRuntime` from eight parallel `std::vector<double>` to one array of
|
||||
`MathNodeRt` was measured and bought **nothing** -- at six nodes the whole runtime is
|
||||
L1-resident either way. It was kept for the shape, not for a number; do not expect a cache
|
||||
argument to pay at this size.
|
||||
|
||||
- **The status line's per-second fields are measured once a second, not per frame.**
|
||||
`App::stored_rate`/`declared_rate` and the label's tooltip are refreshed on the same
|
||||
tick as the channel dump; the status line only formats what is already there. The
|
||||
@@ -980,4 +1212,26 @@ far the largest file.
|
||||
runs after the window is destroyed and asking GTK about a widget then is four criticals, which is
|
||||
how the first version of that line earned them. What stays in the menu is what acts on the *data*
|
||||
(Fit, Export) and the two editors that are not panes (Layout, Signal list). The Arm/Disarm button in the trigger panel still has text.
|
||||
- **Four of the toolbar icons are this app's own, and they are a `GResource` compiled into the
|
||||
binary.** No icon theme has a glyph for an oscilloscope trigger, a pair of cursors or a
|
||||
measurement ruler, and the stock names those buttons used to carry said something else
|
||||
entirely: a *record dot* for the trigger and three different **magnifiers** for cursors,
|
||||
measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,math}-symbolic.svg`
|
||||
are bundled by `src/icons/udpscope.gresource.xml` (CMake runs `glib-compile-resources`) and
|
||||
reached through the `gtk_icon_theme_add_resource_path()` at the top of `build_toolbar()`.
|
||||
A resource rather than files beside the binary because udpscope is run straight out of the
|
||||
build tree and has no install step. Five authoring rules, each of which fails quietly:
|
||||
* **GTK's symbolic recolour only reaches `fill` on `path`, `rect` and `circle`.** It wraps the
|
||||
SVG in a stylesheet for those selectors, so a `stroke`, a `line` or a `polyline` keeps the
|
||||
colour written in the file and stays dark on a dark theme. Everything is a filled `path`.
|
||||
* **Whole-pixel coordinates**, because these are drawn at 16 px far more often than not: the
|
||||
first draft placed features on half-pixels and the whole icon was a grey smear at 1x.
|
||||
* `fill-rule="evenodd"` is how the ruler's body is a hole *and* its ticks still fill -- a tick
|
||||
inside the hole is three crossings deep, which is odd. Nonzero would fill the hole back in.
|
||||
* Overlapping subpaths under the default nonzero rule must be **wound the same way** or they
|
||||
cancel instead of uniting. All of them run clockwise.
|
||||
* `--` is illegal inside an XML comment and librsvg refuses the whole file for it.
|
||||
`gtk_icon_theme_has_icon()` is checked for every toolbar button and reported by the dump (see
|
||||
`toolbar icons:` under Verification): a resource path that was never added does not raise an
|
||||
error, it draws `image-missing`.
|
||||
- Every source file carries an SPDX MIT header; match it in new files.
|
||||
Reference in new issue
Block a user