From a92a6b5bf0b140f33f38e902ebd94b456f59ada2 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sat, 3 Oct 2026 23:36:07 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 280 +++++- CMakeLists.txt | 22 + README.md | 178 +++- deps/plotter | 2 +- src/channel.cpp | 15 +- src/channel.hpp | 13 + src/event_series.hpp | 25 +- .../actions/udpscope-cursors-symbolic.svg | 19 + .../actions/udpscope-math-symbolic.svg | 13 + .../actions/udpscope-measure-symbolic.svg | 16 + .../actions/udpscope-trigger-symbolic.svg | 16 + src/icons/udpscope.gresource.xml | 17 + src/math_channel.cpp | 58 +- src/math_channel.hpp | 49 +- src/measure.cpp | 307 ++++-- src/measure.hpp | 126 ++- src/model.cpp | 67 +- src/model.hpp | 33 + src/ui.cpp | 914 +++++++++++++++++- tools/mock_udpstreamer.py | 18 +- 20 files changed, 1945 insertions(+), 243 deletions(-) create mode 100644 src/icons/scalable/actions/udpscope-cursors-symbolic.svg create mode 100644 src/icons/scalable/actions/udpscope-math-symbolic.svg create mode 100644 src/icons/scalable/actions/udpscope-measure-symbolic.svg create mode 100644 src/icons/scalable/actions/udpscope-trigger-symbolic.svg create mode 100644 src/icons/udpscope.gresource.xml diff --git a/CLAUDE.md b/CLAUDE.md index a931d95..fc90e9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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] /x 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] 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 `= `, 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/_.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 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 '': ... 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 '' ... 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` 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. diff --git a/CMakeLists.txt b/CMakeLists.txt index d9b0220..0eb13f9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -30,8 +30,30 @@ if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") target_compile_options(udpsclient PRIVATE -Wall -Wextra) endif() +# --- icons: four symbolic SVGs compiled into the binary ----------------------- +# There is no stock icon for an oscilloscope trigger, a pair of cursors or a measurement +# ruler, so this app draws its own. They go in a GResource rather than beside the binary +# because udpscope is run straight out of the build tree and has no install step: a +# search path would have to be located at runtime, and the failure mode of not locating +# it is a silent missing-image placeholder. +find_program(GLIB_COMPILE_RESOURCES NAMES glib-compile-resources REQUIRED) +set(UDPSCOPE_ICON_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/icons) +file(GLOB UDPSCOPE_ICON_FILES CONFIGURE_DEPENDS + ${UDPSCOPE_ICON_DIR}/scalable/actions/*.svg) +add_custom_command( + OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c + COMMAND ${GLIB_COMPILE_RESOURCES} + --target=${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c + --sourcedir=${UDPSCOPE_ICON_DIR} + --generate-source + ${UDPSCOPE_ICON_DIR}/udpscope.gresource.xml + DEPENDS ${UDPSCOPE_ICON_DIR}/udpscope.gresource.xml ${UDPSCOPE_ICON_FILES} + COMMENT "Compiling udpscope icon resources" + VERBATIM) + # --- the application ---------------------------------------------------------- add_executable(udpscope + ${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c src/main.cpp src/config.cpp src/channel.cpp diff --git a/README.md b/README.md index 02741d4..c8f0748 100644 --- a/README.md +++ b/README.md @@ -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` 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/_.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 diff --git a/deps/plotter b/deps/plotter index faef19b..0ee6e10 160000 --- a/deps/plotter +++ b/deps/plotter @@ -1 +1 @@ -Subproject commit faef19ba60df5aa44be7cf52bd39deb90e7ecf00 +Subproject commit 0ee6e10c81b7c98b6518ce545907078d1f8f8802 diff --git a/src/channel.cpp b/src/channel.cpp index d27612e..864e997 100644 --- a/src/channel.cpp +++ b/src/channel.cpp @@ -56,10 +56,15 @@ void ChannelBuffer::acquire() noexcept { // The pyramid's window follows the ring's: sample index `base` is the oldest // one still retained. drop_before() is monotonic and cheap. pyramid_.drop_before(values_->base()); + // And the window's first stamp with it, here and nowhere else (see the member's note): the + // window is pinned by the line above, so its origin is a fixed sample, and this is the one + // place in the object's life where that sample's slot is safe to read. + if (pyramid_.size() > 0) window_x0_ = time_of(absolute(0)); } void ChannelBuffer::invalidate() noexcept { published_ = 0; + window_x0_ = 0.0f; pyramid_.clear(); } @@ -116,11 +121,16 @@ f64 ChannelBuffer::dx() const { f64 ChannelBuffer::x0() const { if (pyramid_.size() == 0) return epoch_; - return epoch_ + f64(time_of(absolute(0))); + return epoch_ + f64(window_x0_); } f64 ChannelBuffer::x_at(u64 i) const { if (i >= pyramid_.size()) return x0(); + // Sample 0 is served from the cache, never from the ring: it is the slot the producer is + // overwriting, and `neighbours()` tests the asked-for x against this value, so a read one + // ring-span too new made it refuse the whole visible range -- which left the renderer + // gathering the entire ring at a coarse level and drawing it off the axis. + if (i == 0) return x0(); return epoch_ + f64(time_of(absolute(i))); } @@ -161,8 +171,7 @@ u64 ChannelBuffer::snap_index(f64 x) const { plot::Range ChannelBuffer::x_bounds() const { const u64 n = pyramid_.size(); if (n == 0) return plot::Range{epoch_, epoch_}; - return plot::Range{epoch_ + f64(time_of(absolute(0))), - epoch_ + f64(time_of(absolute(n - 1)))}; + return plot::Range{x0(), epoch_ + f64(time_of(absolute(n - 1)))}; } plot::Range ChannelBuffer::y_bounds() const { diff --git a/src/channel.hpp b/src/channel.hpp index 2ec071d..6318937 100644 --- a/src/channel.hpp +++ b/src/channel.hpp @@ -102,6 +102,19 @@ class ChannelBuffer final : public plot::data::Series { f64 epoch_ = 0.0; f64 dt_ = 1.0; u64 published_ = 0; + /// The stamp of the window's **first** sample, read once by `acquire()` -- which runs on the + /// consumer under `ScopeModel::mutex()`, the same lock the producer's pushes hold, so the + /// slot cannot be overwritten under the read. Everything else on this object is read at + /// *paint* time with no lock, and this one slot is the ring's oldest: the one the producer is + /// about to overwrite. Reading it there returned a stamp a whole ring-span too new whenever + /// the producer lapped it between the bounds check and the copy -- and `x0()` is the origin + /// the renderer reconstructs every trace point from (`gather[i] + series.x0()`), so one such + /// read moved the entire polyline a ring-span off its axis (an invisible trace) or, caught + /// inside the renderer's increasing-suffix filter, made it drop most of the points. That is + /// the "live plot flickers as if there were no data" defect. The cached value is also the + /// *right* answer rather than merely a stable one: it is the stamp sample `window_base` + /// actually carries, where the clamp answered with whatever slot happened to be oldest now. + f32 window_x0_ = 0.0f; plot::data::MinMaxPyramid pyramid_; }; diff --git a/src/event_series.hpp b/src/event_series.hpp index 92a632d..049d8a9 100644 --- a/src/event_series.hpp +++ b/src/event_series.hpp @@ -180,15 +180,28 @@ class EventSeries final : public plot::data::Series { if (last * span < e) emit_raw(last * span, e); } + /// Bracketing samples by **binary search over the stamps**, which are monotonic -- never + /// through the `(x - x0()) / dx()` grid, for the same reason `ChannelBuffer` does not: `dx()` + /// is the window's *mean* spacing and the stamps are an arrival clock, so the grid and the + /// real stamps drift apart by however much the clock wandered. The renderer picks the visible + /// index window from this call, so that drift came out as a **constant offset in data + /// seconds** between the axis and the gathered geometry: a blank left margin whose *pixel* + /// width doubles with every zoom step (374 px of a 716 px axis after seven), with the trace's + /// ink pushed off the right-hand edge by the same amount and the crosshair -- which reads + /// `x_at()`, the real stamp -- still correct everywhere. That is the "zooming into a held + /// event clips it more and more from the left" report. A perfectly uniform source hides it + /// completely, which is why the mock can jitter its clock (`--clock-jitter`). bool neighbours(f64 x, u64& i0, u64& i1) const override { const u64 n = size(); if (n < 2) return false; - const f64 step = dx(); - if (!(step > 0.0)) return false; - f64 idx = (x - x0()) / step; - if (idx < 0.0) idx = 0.0; - if (idx > static_cast(n - 1)) idx = static_cast(n - 1); - i0 = static_cast(idx); + const auto& s = snap_->stamps; + const auto begin = s.begin(); + const auto end = begin + static_cast(n); + // The first stamp *greater* than x, so x itself brackets as [that - 1, that]: a pointer + // exactly on a sample must not put the sample behind it. + const auto it = std::upper_bound(begin, end, static_cast(x)); + const u64 above = static_cast(it - begin); + i0 = above > 0 ? above - 1 : 0; i1 = i0 + 1 < n ? i0 + 1 : i0; return true; } diff --git a/src/icons/scalable/actions/udpscope-cursors-symbolic.svg b/src/icons/scalable/actions/udpscope-cursors-symbolic.svg new file mode 100644 index 0000000..d73c67b --- /dev/null +++ b/src/icons/scalable/actions/udpscope-cursors-symbolic.svg @@ -0,0 +1,19 @@ + + + + + + + diff --git a/src/icons/scalable/actions/udpscope-math-symbolic.svg b/src/icons/scalable/actions/udpscope-math-symbolic.svg new file mode 100644 index 0000000..877f7c0 --- /dev/null +++ b/src/icons/scalable/actions/udpscope-math-symbolic.svg @@ -0,0 +1,13 @@ + + + + + + + diff --git a/src/icons/scalable/actions/udpscope-measure-symbolic.svg b/src/icons/scalable/actions/udpscope-measure-symbolic.svg new file mode 100644 index 0000000..1dee0af --- /dev/null +++ b/src/icons/scalable/actions/udpscope-measure-symbolic.svg @@ -0,0 +1,16 @@ + + + + + + + diff --git a/src/icons/scalable/actions/udpscope-trigger-symbolic.svg b/src/icons/scalable/actions/udpscope-trigger-symbolic.svg new file mode 100644 index 0000000..8de4ebe --- /dev/null +++ b/src/icons/scalable/actions/udpscope-trigger-symbolic.svg @@ -0,0 +1,16 @@ + + + + + + + diff --git a/src/icons/udpscope.gresource.xml b/src/icons/udpscope.gresource.xml new file mode 100644 index 0000000..455e1d1 --- /dev/null +++ b/src/icons/udpscope.gresource.xml @@ -0,0 +1,17 @@ + + + + + + + scalable/actions/udpscope-trigger-symbolic.svg + scalable/actions/udpscope-cursors-symbolic.svg + scalable/actions/udpscope-measure-symbolic.svg + scalable/actions/udpscope-math-symbolic.svg + + diff --git a/src/math_channel.cpp b/src/math_channel.cpp index 11e3c13..e633c5f 100644 --- a/src/math_channel.cpp +++ b/src/math_channel.cpp @@ -5,11 +5,15 @@ #include #include +#include namespace udpscope { namespace { constexpr double kPi = 3.14159265358979323846; +/// A value the step can return without a library call: `std::nan("")` parses its argument, and this +/// runs once per stored slot per node. +constexpr double kNaN = std::numeric_limits::quiet_NaN(); /// A biquad's coefficients for one node, from the RBJ audio-EQ cookbook. `q` is 1/sqrt(2) /// (Butterworth) for the plain low/high pass, and the node's own for the band pass -- which is @@ -119,44 +123,54 @@ bool math_op_uses_filter(MathOp op) { } bool math_op_uses_clamp(MathOp op) { return op == MathOp::Clamp; } -void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt) { +void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt, + const std::function& index_of) { const std::size_t n = channel.nodes.size(); - rt.value.assign(n, 0.0); - rt.b0.assign(n, 1.0); - rt.b1.assign(n, 0.0); - rt.b2.assign(n, 0.0); - rt.a1.assign(n, 0.0); - rt.a2.assign(n, 0.0); // A rate change *keeps* the filter state: the state words are in the signal's units, not in // time, so a decimation change moves the cutoffs (which is why the coefficients are rebuilt) // without making the trace jump. A node that is new gets zeros, which is a filter starting from // rest -- the only honest starting point there is. - rt.z1.resize(n, 0.0); - rt.z2.resize(n, 0.0); + const std::vector was = std::move(rt.node); + rt.node.assign(n, MathNodeRt{}); + for (std::size_t i = 0; i < n && i < was.size(); ++i) { + rt.node[i].z1 = was[i].z1; + rt.node[i].z2 = was[i].z2; + } rt.dt = dt; rt.ready = true; for (std::size_t i = 0; i < n; ++i) { const MathNode& node = channel.nodes[i]; + MathNodeRt& out = rt.node[i]; + if (node.op == MathOp::Source) { + out.src = index_of ? index_of(node.source) : -1; + continue; + } if (!math_op_uses_filter(node.op)) continue; - biquad(node.op, node.fc, node.q, dt, rt.b0[i], rt.b1[i], rt.b2[i], rt.a1[i], rt.a2[i]); + biquad(node.op, node.fc, node.q, dt, out.b0, out.b1, out.b2, out.a1, out.a2); } } -double math_step(const MathChannel& channel, MathRuntime& rt, - const std::function& source) { +double math_step(const MathChannel& channel, MathRuntime& rt, const float* values, + std::size_t count) { const std::size_t n = channel.nodes.size(); - if (n == 0 || rt.value.size() != n) return std::nan(""); + if (n == 0 || rt.node.size() != n) return kNaN; + MathNodeRt* rts = rt.node.data(); const auto at = [&](int index) -> double { - if (index < 0 || static_cast(index) >= n) return std::nan(""); - return rt.value[static_cast(index)]; + if (index < 0 || static_cast(index) >= n) return kNaN; + return rts[static_cast(index)].value; }; for (std::size_t i = 0; i < n; ++i) { const MathNode& node = channel.nodes[i]; + MathNodeRt& self = rts[i]; double x = 0.0; switch (node.op) { - case MathOp::Source: - x = source(node.source); + case MathOp::Source: { + const int c = self.src; + x = c >= 0 && static_cast(c) < count + ? static_cast(values[static_cast(c)]) + : kNaN; break; + } case MathOp::Gain: x = at(node.in_a) * node.gain + node.offset; break; @@ -174,8 +188,8 @@ double math_step(const MathChannel& channel, MathRuntime& rt, case MathOp::LowPass: case MathOp::HighPass: case MathOp::BandPass: - x = biquad_step(at(node.in_a), rt.b0[i], rt.b1[i], rt.b2[i], rt.a1[i], rt.a2[i], rt.z1[i], - rt.z2[i]); + x = biquad_step(at(node.in_a), self.b0, self.b1, self.b2, self.a1, self.a2, self.z1, + self.z2); break; case MathOp::Sum: x = at(node.in_a) + at(node.in_b); @@ -194,11 +208,11 @@ double math_step(const MathChannel& channel, MathRuntime& rt, break; } } - rt.value[i] = x; + self.value = x; } const int out = channel.output; - if (out < 0 || static_cast(out) >= n) return std::nan(""); - return rt.value[static_cast(out)]; + if (out < 0 || static_cast(out) >= n) return kNaN; + return rts[static_cast(out)].value; } bool math_channel_valid(const MathChannel& channel, std::string* why) { diff --git a/src/math_channel.hpp b/src/math_channel.hpp index f5d2e0d..d898d16 100644 --- a/src/math_channel.hpp +++ b/src/math_channel.hpp @@ -68,25 +68,50 @@ struct MathChannel { /// The per-channel runtime: the filter memory and the per-node scratch. Kept apart from the /// `MathChannel` because it is *state* (one sample of history per biquad) while the channel is /// *configuration* -- so the pane can edit one while the producer streams through the other. +/// One node's runtime: its value this sample, its biquad coefficients and state, and its resolved +/// source. **One struct per node, not one vector per field**: the step touches all of them for the +/// node it is on and none of them for any other, so a node's state belongs on one cache line. +/// Eight parallel `std::vector` put a filter node's eight words on eight separate lines, +/// which at the stored rate is eight misses per node per sample. +struct MathNodeRt { + double value = 0.0; ///< this node's output for the sample being evaluated + /// Biquad coefficients (transposed direct form II) and the two state words. + double b0 = 1.0; + double b1 = 0.0; + double b2 = 0.0; + double a1 = 0.0; + double a2 = 0.0; + double z1 = 0.0; + double z2 = 0.0; + /// The channel index a `Source` node reads, -1 when its name names nothing acquired (and for + /// every other op). Resolved **once**, by `math_prepare()`, rather than per sample: the step runs + /// at the stored rate -- a million times a second on a fast stream -- and reading a source by + /// name there was a linear scan of the channel list with a `std::string` compare per candidate, + /// per source node, per sample, behind a type-erased call. Names cannot change without a + /// rebuild, and every rebuild re-prepares. + int src = -1; +}; + struct MathRuntime { - std::vector value; ///< one per node, this sample. - /// Biquad coefficients per node (transposed direct form II), and its two state words. - std::vector b0, b1, b2, a1, a2, z1, z2; + std::vector node; /// The rate the coefficients were built for; a change rebuilds them. double dt = 0.0; bool ready = false; }; -/// Fills `rt`'s size and the biquad coefficients for `dt` seconds per sample. Call it whenever the -/// graph or the stored rate changes -- cheap, and it is what keeps the filters' cutoffs honest -/// across a decimation change, where `dt` is not what it was when the user typed a frequency. -void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt); +/// Fills `rt`'s size, the biquad coefficients for `dt` seconds per sample, and the resolved source +/// indices -- `index_of(name)` is the channel a `Source` node names, or -1. Call it whenever the +/// graph, the channel list or the stored rate changes -- cheap, and it is what keeps the filters' +/// cutoffs honest across a decimation change, where `dt` is not what it was when the user typed a +/// frequency. +void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt, + const std::function& index_of); -/// One sample through the graph. `source(name)` returns that channel's current value, and NAN when -/// it is not acquired -- which propagates to the output, so a math channel whose source is disabled -/// reads as "no data" rather than as whatever the last good sample was. -double math_step(const MathChannel& channel, MathRuntime& rt, - const std::function& source); +/// One sample through the graph. `values[c]` is channel `c`'s value for this slot; a `Source` node +/// whose channel is not among them reads NAN -- which propagates to the output, so a math channel +/// whose source is disabled reads as "no data" rather than as whatever the last good sample was. +double math_step(const MathChannel& channel, MathRuntime& rt, const float* values, + std::size_t count); /// Whether the graph is complete enough to produce a value: an output node that exists, and every /// input it needs connected to a node that exists (with `Source` nodes naming something). diff --git a/src/measure.cpp b/src/measure.cpp index ee78119..47598f6 100644 --- a/src/measure.cpp +++ b/src/measure.cpp @@ -8,30 +8,185 @@ namespace udpscope { -MeasureStats measure_stats(const std::vector& time, const std::vector& value) { - MeasureStats s; - const std::size_t n = std::min(time.size(), value.size()); - for (std::size_t i = 0; i < n; ++i) { - const double v = value[i]; +namespace { + +/// One crossing *into* side `e` at time `t`, which is also what closes the interval the walk was +/// in. Shared by the raw walk and the bucket walk so the two cannot disagree about what an edge +/// records -- an interval's width is the distance from the edge that entered it to the edge that +/// left it, which is the pairing the pulse duration averages. +void enter_side(MeasureLevel& acc, std::size_t e, double t) { + const std::size_t other = 1 - e; + if (acc.enters[e] == 0) acc.first_enter[e] = t; + acc.last_enter[e] = t; + ++acc.enters[e]; + if (acc.open[other]) { + acc.width_sum[other] += t - acc.open_at[other]; + ++acc.widths[other]; + acc.open[other] = false; + } + acc.open_at[e] = t; + acc.open[e] = true; +} + +} // namespace + +void measure_scan(const MeasureChunk& chunk, MeasureStats& s) { + // The chunk's own extremes first, in a loop that touches nothing but the values: *where* an + // extreme is costs an index, and the caller turns the two that survive into stamps once at the + // end. The sum is accumulated per chunk and folded in once, which is also kinder numerically + // than one running total over a few million terms. + double sum = 0.0; + std::size_t count = 0; + double lo = 0.0; + double hi = 0.0; + std::size_t at_lo = 0; + std::size_t at_hi = 0; + bool any = false; + for (std::size_t i = 0; i < chunk.count; ++i) { + const double v = static_cast(chunk.value[i]); if (!std::isfinite(v)) continue; - if (!s.valid) { - s.lo = s.hi = v; - s.at_lo = s.at_hi = time[i]; - s.valid = true; + if (!any) { + lo = hi = v; + at_lo = at_hi = i; + any = true; } else { - if (v < s.lo) { - s.lo = v; - s.at_lo = time[i]; + if (v < lo) { + lo = v; + at_lo = i; } - if (v > s.hi) { - s.hi = v; - s.at_hi = time[i]; + if (v > hi) { + hi = v; + at_hi = i; } } - s.sum += v; - ++s.count; + sum += v; + ++count; + } + if (!any) return; + s.sum += sum; + s.count += count; + if (!s.valid) { + s.lo = lo; + s.hi = hi; + s.lo_i = chunk.offset + at_lo; + s.hi_i = chunk.offset + at_hi; + s.lo_search = 0; + s.hi_search = 0; + s.valid = true; + return; + } + // Strictly better only: the *first* sample to reach an extreme is the one a reader's eye lands + // on, so a later chunk that merely ties it does not move the mark. + if (lo < s.lo) { + s.lo = lo; + s.lo_i = chunk.offset + at_lo; + s.lo_search = 0; + } + if (hi > s.hi) { + s.hi = hi; + s.hi_i = chunk.offset + at_hi; + s.hi_search = 0; + } +} + +void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, MeasureStats& s) { + double sum = 0.0; + std::size_t finite = 0; + for (std::size_t b = 0; b < count; ++b) { + const MeasureBucket& k = buckets[b]; + // A bucket of nothing (every sample non-finite) is stored as an empty aggregate, and the raw + // walk skips exactly those samples -- so it contributes nothing here either. + if (k.count == 0) continue; + sum += k.sum; + finite += k.count; + if (!s.valid) { + s.lo = k.lo; + s.hi = k.hi; + s.lo_i = s.hi_i = k.offset; + s.lo_search = s.hi_search = k.span; + s.valid = true; + continue; + } + if (k.lo < s.lo) { + s.lo = k.lo; + s.lo_i = k.offset; + s.lo_search = k.span; + } + if (k.hi > s.hi) { + s.hi = k.hi; + s.hi_i = k.offset; + s.hi_search = k.span; + } + } + s.sum += sum; + s.count += finite; +} + +void measure_accumulate(const MeasureChunk& chunk, + const std::function& time_of, MeasureLevel* levels, + std::size_t level_count) { + for (std::size_t k = 0; k < level_count; ++k) { + MeasureLevel& acc = levels[k]; + for (std::size_t i = 0; i < chunk.count; ++i) { + const double v = static_cast(chunk.value[i]); + if (!std::isfinite(v)) continue; + const int s = v > acc.level ? 1 : 0; + acc.sum[static_cast(s)] += v; + ++acc.count[static_cast(s)]; + // A sample *at* the level is on neither side, so it decides no crossing and the walk keeps + // the side it last decided. That is what stops a signal which merely touches the level from + // counting every sample as an edge. + if (v == acc.level) continue; + if (acc.have_side && s != (acc.side ? 1 : 0)) { + enter_side(acc, static_cast(s), time_of(chunk.offset + i)); + } + acc.side = s != 0; + acc.have_side = true; + } + } +} + +bool measure_accumulate_bucket(const MeasureBucket& k, + const std::function& time_of, + MeasureLevel& acc) { + if (k.count == 0) return true; + // A non-finite sample in the bucket would make the edge's position a guess: the raw walk takes + // the first *finite* sample of the new side, and an aggregate cannot say which that is. + if (k.count != k.span) return false; + // Strictly on one side, so every sample decides the same side and none is *at* the level -- + // which is what makes this exact rather than an approximation of the walk. + const bool above = k.lo > acc.level; + const bool below = k.hi < acc.level; + if (!above && !below) return false; + const std::size_t e = above ? 1u : 0u; + acc.sum[e] += k.sum; + acc.count[e] += k.count; + if (acc.have_side && e != (acc.side ? 1u : 0u)) { + // The edge is the bucket's *first* sample: everything before it was on the other side and + // everything in the bucket is on this one, so that is where the raw walk would have found it. + enter_side(acc, e, time_of(k.offset)); + } + acc.side = e != 0; + acc.have_side = true; + return true; +} + +bool measure_level_pct(MeasureType type, const MeasureOptions& options, double& pct) { + switch (type) { + case MeasureType::Top: + case MeasureType::Bottom: + pct = std::clamp(options.threshold_pct, 0.0, 100.0) / 100.0; + return true; + case MeasureType::PulseDuration: + case MeasureType::Period: + case MeasureType::Frequency: + // The *mid* level, for all three. No option here on purpose: a period is the spacing of + // one crossing per cycle, and a threshold near either extreme counts noise as crossings. + pct = 0.5; + return true; + default: + return false; } - return s; } @@ -77,27 +232,11 @@ bool measure_uses_side(MeasureType type) { return type == MeasureType::PulseDura bool measure_can_show_on_plot(MeasureType) { return true; } -MeasureResult compute_measure(const std::vector& time, const std::vector& value, - MeasureType type, const MeasureOptions& options, - const MeasureStats* stats) { +MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level, MeasureType type, + const MeasureOptions& options) { MeasureResult out; - const std::size_t n = std::min(time.size(), value.size()); - if (n == 0) return out; - - // A threshold as a share of the window's own range, which is what makes one percentage - // travel across signals: a threshold in volts would have to be typed for each. The bounds - // come from the caller when it has them (several measurements on one signal), because the - // scan is O(visible samples) and this runs per frame while a live window scrolls. - const MeasureStats b = stats != nullptr ? *stats : measure_stats(time, value); if (!b.valid) return out; - // A threshold as a share of the window's own range. A flat window has no range at all, so - // the level is the signal's one value -- every sample is on neither side of it (nothing is - // strictly above or below), and the crossing types then find no edge, which is the honest - // answer for a signal that never moves. - const double pct = std::clamp(options.threshold_pct, 0.0, 100.0) / 100.0; - const double level = b.lo + pct * (b.hi - b.lo); - switch (type) { case MeasureType::Average: { if (b.count == 0) return out; @@ -133,17 +272,13 @@ MeasureResult compute_measure(const std::vector& time, const std::vector } case MeasureType::Top: case MeasureType::Bottom: { - const bool want_high = type == MeasureType::Top; - double sum = 0.0; - std::size_t used = 0; - for (std::size_t i = 0; i < n; ++i) { - if (!std::isfinite(value[i])) continue; - if ((value[i] > level) != want_high) continue; - sum += value[i]; - ++used; - } - if (used == 0) return out; - out.primary = sum / static_cast(used); + // The mean of one side of the level, straight out of the second pass' running sums. A flat + // window has no range at all, so every sample lands on the "below" side -- which is why + // `top` on a signal that never moves reads `--` rather than that one value. + if (level == nullptr) return out; + const std::size_t s = type == MeasureType::Top ? 1 : 0; + if (level->count[s] == 0) return out; + out.primary = level->sum[s] / static_cast(level->count[s]); out.marks.push_back({true, out.primary}); out.valid = true; return out; @@ -151,72 +286,34 @@ MeasureResult compute_measure(const std::vector& time, const std::vector case MeasureType::PulseDuration: case MeasureType::Period: case MeasureType::Frequency: { - // No options here on purpose: a period is the spacing of *rising* crossings of the - // *mid* level -- one edge per cycle -- and a threshold or a side to choose from would - // only let the user make the count wrong (a threshold near either extreme counts noise - // as crossings; counting both edges halves the period). - const double period_level = b.lo + 0.5 * (b.hi - b.lo); - // Every strict side change is an edge. "Strict" is what keeps a signal that merely - // touches the level from counting each sample as a crossing: a sample equal to the - // level is on neither side, so the walk holds the last decided side until one is. - struct Edge { - double t; - bool rising; - }; - std::vector edges; - bool have_side = false; - bool side = false; - for (std::size_t i = 0; i < n; ++i) { - const double v = value[i]; - if (!std::isfinite(v) || v == period_level) continue; - const bool cur = v > period_level; - if (have_side && cur != side) edges.push_back({time[i], cur}); - side = cur; - have_side = true; - } - if (edges.size() < 2) return out; - // The pulse is the `above` side: its widths run from the edge that enters it to the - // edge that leaves it, and a period is the spacing of the edges that enter it -- one + if (level == nullptr) return out; + // The pulse is one side of the mid level: its widths run from the edge that enters it to + // the edge that leaves it, and a period is the spacing of the edges that *enter* it -- one // per cycle, whatever else the waveform does in between. - std::vector starts; - std::vector widths; - for (std::size_t i = 0; i + 1 < edges.size(); ++i) { - if (edges[i].rising != options.above) continue; - starts.push_back(edges[i].t); - if (edges[i + 1].rising == options.above) continue; - widths.push_back(edges[i + 1].t - edges[i].t); - } + const std::size_t s = options.above ? 1 : 0; + const double enter_span = level->last_enter[s] - level->first_enter[s]; + const double enter_period = level->enters[s] >= 2 + ? enter_span / static_cast(level->enters[s] - 1) + : 0.0; if (type == MeasureType::PulseDuration) { - if (widths.empty()) return out; - double sum = 0.0; - for (const double w : widths) sum += w; - out.primary = sum / static_cast(widths.size()); + if (level->widths[s] == 0) return out; + out.primary = level->width_sum[s] / static_cast(level->widths[s]); // Duty as width/period, not as pulse-time over the window: a window that starts or // ends mid-pulse holds partial cycles, and counting only the complete ones against // the whole span would report a duty that depends on where the window happened to - // begin. The period is the spacing of the pulse's own enter-edges. - double period = 0.0; - if (starts.size() >= 2) { - period = (starts.back() - starts.front()) / static_cast(starts.size() - 1); - } - // The duty needs a period, which needs two pulses; a window holding one reports the - // width alone rather than a duty computed against a span that is not a cycle. - out.secondary = period > 0.0 ? out.primary / period : 0.0; - out.has_secondary = period > 0.0; - if (!starts.empty()) { - out.marks.push_back({false, starts.front()}); - out.marks.push_back({false, starts.front() + out.primary}); - } + // begin. The period is the spacing of the pulse's own enter-edges, and it needs two of + // them -- a window holding one pulse reports the width alone. + out.secondary = enter_period > 0.0 ? out.primary / enter_period : 0.0; + out.has_secondary = enter_period > 0.0; + out.marks.push_back({false, level->first_enter[s]}); + out.marks.push_back({false, level->first_enter[s] + out.primary}); out.valid = true; return out; } - if (starts.size() < 2) return out; - const double span = starts.back() - starts.front(); - const double period = span / static_cast(starts.size() - 1); - if (!(period > 0.0)) return out; - out.primary = type == MeasureType::Period ? period : 1.0 / period; - out.marks.push_back({false, starts.front()}); - out.marks.push_back({false, starts.front() + period}); + if (!(enter_period > 0.0)) return out; + out.primary = type == MeasureType::Period ? enter_period : 1.0 / enter_period; + out.marks.push_back({false, level->first_enter[s]}); + out.marks.push_back({false, level->first_enter[s] + enter_period}); out.valid = true; return out; } diff --git a/src/measure.hpp b/src/measure.hpp index fd5ba7c..d7579d4 100644 --- a/src/measure.hpp +++ b/src/measure.hpp @@ -2,6 +2,8 @@ // SPDX-FileCopyrightText: 2026 Martino Ferrari #pragma once +#include +#include #include #include @@ -82,25 +84,123 @@ struct MeasureStats { /// derived from them, so the window is read once for every type that is not an edge count. double sum = 0.0; std::size_t count = 0; - /// The time of the *first* sample to reach each extreme -- "min" is a level and a place, and - /// finding the place is why this is not just the two numbers. + /// The *window index* of the first sample to reach each extreme -- "min" is a level and a + /// place, and finding the place is why this is not just the two numbers. An index rather than + /// a stamp because a stamp costs a ring read and the walk moves an extreme many times, while + /// the caller needs the time exactly twice. + std::size_t lo_i = 0; + std::size_t hi_i = 0; + /// How many samples from `lo_i`/`hi_i` still have to be searched to turn it into a *sample*, + /// or 0 when it already is one. A bucket pass knows only which bucket holds the extreme, so + /// the caller finishes with one raw `measure_scan()` over that bucket alone -- two buckets at + /// most, whatever the window's size. + std::size_t lo_search = 0; + std::size_t hi_search = 0; + /// The time of each extreme. Filled by the caller from `lo_i`/`hi_i` once the walk is done; + /// `compute_measure()` reads these, so a caller that skips the resolve reports t = 0. double at_lo = 0.0; double at_hi = 0.0; bool valid = false; }; -/// One pass over a window: both extremes with their first positions, and the sum and count. -MeasureStats measure_stats(const std::vector& time, const std::vector& value); - -/// The engine: the visible samples of one signal, in stream seconds and in the signal's own -/// units. Both arrays are the same length and in time order; the function reads nothing else, -/// which is what makes it testable and what keeps the answer independent of the plot. +/// One chunk of a window's values, in time order, exactly as the rings hold them. /// -/// `stats` is `measure_stats(time, value)` when the caller already has it -- several measurements -/// on one signal share one scan -- and null to compute it here. It must describe these arrays. -MeasureResult compute_measure(const std::vector& time, const std::vector& value, - MeasureType type, const MeasureOptions& options, - const MeasureStats* stats = nullptr); +/// The engine is *fed* the window this way rather than handed a materialized copy. A visible +/// window is millions of samples and the rings hold `f32`, so widening the whole thing into two +/// `std::vector` cost a 16-byte-per-sample write and then a read of it for every pass -- +/// 60 % of the pane's time before a single statistic had been computed, and 12 ns a sample over a +/// 1.7 M-sample window. Only the *values* are streamed: a stamp is needed at the extremes and at +/// the level crossings, which are a handful in a window of millions, so it is fetched by index. +struct MeasureChunk { + const float* value = nullptr; + std::size_t count = 0; + /// The window offset of `value[0]`, so `time_of()` below can be asked for one absolute sample. + std::size_t offset = 0; +}; + +/// One whole LOD bucket of a window, exactly as `plot::data::MinMaxPyramid` already holds it. +/// +/// The pyramid *is* "evaluate only on the new samples": the producer folds every sample into it +/// as it arrives, at amortised O(1), so a bucket is 64 samples the measurement walks never have +/// to read. A 1.7 M-sample window is 26 k buckets, and only the handful that *straddle* a level +/// are opened. +struct MeasureBucket { + double lo = 0.0; + double hi = 0.0; + double sum = 0.0; + std::size_t count = 0; ///< finite samples folded in; 0 = a bucket with nothing usable + std::size_t offset = 0; ///< window offset of the bucket's first sample + std::size_t span = 0; ///< samples the bucket covers, finite or not +}; + +/// Pass one, chunk by chunk: both extremes with the *index* of the first sample to reach each, +/// and the sum and count of the finite samples. Feed the chunks in window order into a zeroed +/// `stats` -- the first sample to reach an extreme is the one the mark names, so a later chunk +/// that merely ties it must not move it. +void measure_scan(const MeasureChunk& chunk, MeasureStats& stats); + +/// Pass one from whole buckets: the same figures, exactly, for 1/64 of the reads. The extreme's +/// position comes out as a *bucket* (`lo_search`/`hi_search` non-zero), which one raw +/// `measure_scan()` of that bucket then pins. Interleave with `measure_scan()` in window order. +void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, MeasureStats& stats); + +/// Everything the threshold-relative types need from a second walk, at one level: the sum and +/// count of the samples on either side of it, and its crossings reduced to running figures -- the +/// first and last *enter* edge of each side, how many there were, and the summed width of the +/// intervals that closed. All of it is O(1) state, so `Top`, `Bottom`, `PulseDuration`, `Period` +/// and `Frequency` are all answered from one pass: adding a *type* must not add a pass, and nor +/// must adding a second measurement of the same type at the same level. +struct MeasureLevel { + double level = 0.0; + /// Index 0 is the side below the level (a sample exactly *at* it counts here, which is what + /// `bottom` has always reported), index 1 the side above it. + double sum[2] = {0.0, 0.0}; + std::size_t count[2] = {0, 0}; + double first_enter[2] = {0.0, 0.0}; + double last_enter[2] = {0.0, 0.0}; + std::size_t enters[2] = {0, 0}; + double width_sum[2] = {0.0, 0.0}; + std::size_t widths[2] = {0, 0}; + /// Walk state carried between chunks: the side the signal was last *decided* to be on (a sample + /// equal to the level decides nothing, so the walk holds the previous side), and the interval + /// per side that has been entered but not yet left. + bool have_side = false; + bool side = false; + double open_at[2] = {0.0, 0.0}; + bool open[2] = {false, false}; +}; + +/// Pass two: one chunk into every level at once. The usual case is a single level -- every +/// crossing type reads the mid level and `Top`/`Bottom` default to it too -- so a pane full of +/// measurements on one signal costs two walks, not two per measurement. +void measure_accumulate(const MeasureChunk& chunk, + const std::function& time_of, MeasureLevel* levels, + std::size_t level_count); + +/// Pass two from one whole bucket, into one level. True when the bucket answered it: a bucket +/// every one of whose samples is on the same side of the level holds no crossing, so its sums +/// fold in whole and the walk's side is decided by the bucket rather than by 64 samples. An edge +/// *into* such a bucket lands on its first sample, exactly, which is why that is the stamp taken. +/// +/// False means the bucket straddles the level -- or holds a non-finite sample, which would make +/// the edge's position a guess -- and the caller must feed its raw samples to +/// `measure_accumulate()` for this level instead. That is the only place a measurement still +/// reads raw samples in bulk, and on a clean waveform it is a bucket per crossing. +bool measure_accumulate_bucket(const MeasureBucket& bucket, + const std::function& time_of, + MeasureLevel& level); + +/// The level a type reads, as a share of the window's own min..max, or false when it reads none +/// (the pure statistics, answered from `MeasureStats` alone). `Top`/`Bottom` read the +/// measurement's own threshold; the crossing types read the mid level, deliberately -- see +/// `measure_uses_threshold()`. +bool measure_level_pct(MeasureType type, const MeasureOptions& options, double& pct); + +/// The engine: one measurement's answer, O(1), from the two passes' results. `level` is the +/// accumulator for `measure_level_pct(type, options)` and may be null for the types that read +/// none. The function reads nothing else, which is what keeps the answer independent of the plot. +MeasureResult compute_measure(const MeasureStats& stats, const MeasureLevel* level, + MeasureType type, const MeasureOptions& options); /// A measurement type's name, as the pane's row and the dialog's dropdown spell it. const char* measure_type_name(MeasureType type); diff --git a/src/model.cpp b/src/model.cpp index 582c136..3904da0 100644 --- a/src/model.cpp +++ b/src/model.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -22,6 +23,23 @@ u64 pow2_floor(u64 v) { return p; } +std::uint64_t now_ns() { + return static_cast(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count()); +} + +/// One stored slot in this many is timed: see `ScopeModel::push_math_sample()`. +/// +/// A *single* slot, deliberately, even though one slot's work is the same order as one +/// `clock_gettime` -- so the figure carries a clock read's own cost and is an upper bound. 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 a 64-slot run crosses one about 3% of the time, and a ~1.5 ms gap spread over 64 slots +/// reported 799 ns/slot against the true 78. One slot is always inside one packet. +constexpr std::uint64_t kMathCostSample = 1024; + } // namespace u64 Partition::total_samples() const noexcept { @@ -221,29 +239,52 @@ void ScopeModel::prepare_math_locked() { // starting point for the new one. Both are reset here, together, so the two cannot drift apart. math_rt_.assign(math_.size(), MathRuntime{}); const double dt = part_.slot_dt(); - for (std::size_t i = 0; i < math_.size(); ++i) math_prepare(math_[i], math_rt_[i], dt); + // A `Source` node's channel index, resolved here so the per-sample step never looks a name up. + const auto index_of = [this](const std::string& name) -> int { + for (std::size_t c = 0; c < channels_.size(); ++c) { + if (channels_[c].name == name) return static_cast(c); + } + return -1; + }; + for (std::size_t i = 0; i < math_.size(); ++i) { + math_prepare(math_[i], math_rt_[i], dt, index_of); + } } void ScopeModel::push_math_sample(const std::vector& sample) { + // The common case is no derived channels at all, and this runs once per *stored slot* -- so the + // early-out is what keeps a stream with no math from paying for the feature. + if (math_.empty()) return; + // The cost is *sampled*: a run of `kMathCostRun` consecutive slots every `kMathCostPeriod`. This + // runs up to the wire rate, where a slot's own work is tens of nanoseconds -- so timing every + // slot is itself able to drop packets, and timing one slot at a time charges it a whole clock + // read. A run amortises both: one pair of reads per 64 measured slots, one pair per 65536 slots + // overall, and the figure converges over the millions this counter sees. + const std::uint64_t slot = math_slot_count_++; + math_slots_.store(math_slot_count_, std::memory_order_relaxed); + const bool timed = slot % kMathCostSample == 0; + if (timed) math_cost_t0_ = now_ns(); // Values for this slot, extended as the derived ones are computed: a graph may name another math // channel, and nodes are evaluated in list order, so by the time one is asked for it is there. - std::vector values = sample; - const auto value_of = [&](const std::string& name) -> double { - for (std::size_t c = 0; c < channels_.size(); ++c) { - if (channels_[c].name != name) continue; - return c < values.size() ? static_cast(values[c]) : std::nan(""); - } - return std::nan(""); - }; + // `assign()` into a member reuses the capacity -- a local vector here was a heap allocation and + // free per stored slot, i.e. up to a million pairs a second on a fast stream. + math_values_.assign(sample.begin(), sample.end()); + if (math_values_.size() < channels_.size()) math_values_.resize(channels_.size(), 0.0f); for (std::size_t m = 0; m < math_.size(); ++m) { const int channel = m < math_channel_.size() ? math_channel_[m] : -1; if (channel < 0 || m >= math_rt_.size()) continue; - const double out = math_step(math_[m], math_rt_[m], value_of); + const double out = + math_step(math_[m], math_rt_[m], math_values_.data(), math_values_.size()); live().values(static_cast(channel)).push(static_cast(out)); - if (values.size() <= static_cast(channel)) { - values.resize(static_cast(channel) + 1, 0.0f); + if (static_cast(channel) < math_values_.size()) { + math_values_[static_cast(channel)] = static_cast(out); } - values[static_cast(channel)] = static_cast(out); + } + if (timed) { + math_cost_count_ += now_ns() - math_cost_t0_; + ++math_timed_count_; + math_cost_ns_.store(math_cost_count_, std::memory_order_relaxed); + math_timed_.store(math_timed_count_, std::memory_order_relaxed); } } diff --git a/src/model.hpp b/src/model.hpp index 172495c..67e67ae 100644 --- a/src/model.hpp +++ b/src/model.hpp @@ -204,6 +204,21 @@ class ScopeModel { /// the streamed channels. `sample[c]` is channel `c`'s value for this slot; the math channels are /// evaluated from those, in order, so a math channel may use another one. void push_math_sample(const std::vector& sample); + /// What the derived channels cost the *producer*: every stored slot that went through + /// `push_math_sample()`, and the nanoseconds taken by the `math_timed()` of them that were + /// measured. The producer owns the budget between two DATA packets, so this cost is invisible in + /// the frame mean and surfaces only as dropped packets -- which is why it is counted rather than + /// inferred. It is *sampled*, so that the instrumentation cannot itself cause what it is looking + /// for: read `math_cost_ns() / math_timed()` as the per-slot figure, scaled by `math_slots()`. + std::uint64_t math_slots() const noexcept { + return math_slots_.load(std::memory_order_relaxed); + } + std::uint64_t math_timed() const noexcept { + return math_timed_.load(std::memory_order_relaxed); + } + std::uint64_t math_cost_ns() const noexcept { + return math_cost_ns_.load(std::memory_order_relaxed); + } const std::vector& signals() const noexcept { return signals_; } const std::vector& channels() const noexcept { return channels_; } @@ -434,6 +449,24 @@ class ScopeModel { /// before the stream's own are re-appended -- a CONFIG must not leave a stale derived channel /// behind, and must not drop a live one either. std::vector math_signal_names_; + /// The producer's scratch for one slot's channel values, derived ones appended as they are + /// computed. A member rather than a local because `push_math_sample()` runs once per *stored + /// slot* -- up to the wire rate -- and a local `std::vector` is a heap allocation and free per + /// slot. `assign()` reuses the capacity, so after the first slot it is a memcpy. + std::vector math_values_; + /// The counters' own copies, written and read by the producer and nobody else, so each publish + /// below is a relaxed **store** of a value already in hand rather than a `fetch_add`. The + /// read-modify-write is a locked instruction and this is the per-slot path: one `lock xadd` costs + /// about what a two-node graph's whole arithmetic does, so counting the slots cost more than the + /// work it was counting. + std::uint64_t math_slot_count_ = 0; + std::uint64_t math_timed_count_ = 0; + std::uint64_t math_cost_count_ = 0; + /// When the slot being timed began. + std::uint64_t math_cost_t0_ = 0; + std::atomic math_slots_{0}; + std::atomic math_timed_{0}; + std::atomic math_cost_ns_{0}; std::vector enabled_; Partition part_; diff --git a/src/ui.cpp b/src/ui.cpp index 45baaa0..3058a81 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -137,6 +137,12 @@ struct App { /// Where the panel controls ended up, decided once at build time: the dump runs after the window /// is destroyed and cannot ask GTK anything about a widget. std::string dbg_panel_controls; + /// The icon each toolbar button carries, and whether the theme resolved it. Recorded at the + /// same moment and for the same reason, but it guards a different thing: four of the icons are + /// drawn for this app and shipped inside the binary, and a resource path that never got + /// registered is *silent* -- GTK substitutes a missing-image placeholder, the button still + /// works, and every other line of the dump still passes. + std::string dbg_toolbar_icons; /// The header bar, so the dump can report the order the controls are in: a header bar is a /// *titlebar*, not a child of the window, so walking down from the window does not reach it. GtkWidget* toolbar = nullptr; @@ -189,17 +195,24 @@ struct App { plot::Range measure_window{}; bool measure_window_valid = false; bool measure_was_held = false; - /// The visible samples, per channel, for the *pass*: several measurements on one signal - /// must not copy the ring several times. - /// The visible window of one channel, with the extremes several measurements on it share: - /// each of them needs `lo`/`hi` (a threshold is a share of the range, and two of the types - /// *are* the range), and the scan is over every visible sample. + /// What the two walks over one channel's visible window produced, shared by every measurement + /// on that signal: the window's statistics, and one accumulator per *distinct level* any of + /// them asked for. Several measurements on one signal must cost two walks, not two each -- and + /// the window itself is never stored, which is what the chunked walks bought (see + /// `MeasureChunk`). struct MeasureWindow { - std::vector time; - std::vector value; MeasureStats stats; + std::vector pct; ///< the level shares asked for, deduplicated + std::vector level; ///< one accumulator per entry of `pct` + bool ok = false; }; std::unordered_map measure_samples; + /// The window's LOD buckets, reused across recomputes: a 1.7 M-sample window is 26 k of them, + /// and both passes walk the same list. + std::vector measure_bucket_scratch; + /// The ink probe's gather buffer, reused for the same reason: it is refilled on every + /// painted frame and its size is O(axis pixels). + std::vector measure_ink_scratch; /// When the pane was last recomputed, for the refresh cadence above. gint64 measure_stamp_us = 0; bool measure_stamp_valid = false; @@ -208,6 +221,16 @@ struct App { /// frame mean, so it is measured where it happens and reported in the dump. gint64 measure_cost_us = 0; int measure_passes = 0; + /// How much of the window the pane actually *read*: the samples covered, against the LOD + /// buckets the two walks answered from and the samples they still had to open. That ratio is + /// the whole question for this pane -- a bucket is 64 samples not read, and the raw tally is + /// the window's partial edges plus the buckets that straddle a level, which on a clean + /// waveform is one per crossing. A `raw` near `covered` means the pyramid bought nothing + /// (a noisy signal, or a window shorter than a bucket) and the cost is back to O(window). + gint64 measure_fetch_us = 0; + std::uint64_t measure_covered = 0; + std::uint64_t measure_bucketed = 0; + std::uint64_t measure_raw = 0; /// The last pass' own duration, which the cadence adapts to. gint64 measure_last_pass_us = 0; /// The last *valid* result per measurement, which the marks fall back to: a level is a property @@ -231,6 +254,12 @@ struct App { int cursors_pane_mode = -1; int cursors_pane_rows = -1; unsigned cursors_pane_generation = ~0u; + /// Which slots were *bound* when the pane was last built, one bit per row/slot + /// (`kMaxRows * kSlotsPerRow` is 32). `mix` draws a line per bound trace, and a slot's + /// channel is re-resolved by `bind()` every frame -- so the pane has to follow the bind + /// and not just the model's generation, or a trace that appears without a structural + /// change gets no line until something else rebuilds the table. + std::uint32_t cursors_pane_bound = ~0u; /// The persisted-layout file this data source maps to (--server/--port), a /// debounced save handle, and whether the file has been applied once the @@ -341,6 +370,43 @@ struct App { std::uint64_t dbg_visible_max = 0; int dbg_raw_frames = 0; int dbg_lod_frames = 0; + /// Painted frames on which `draw_traces` drew **no trace at all**, and the last one's index. + /// This is the observable for "the plot flickers as if there were no data": `staged_segments` + /// cannot see it, because the grid and the instruments stage geometry of their own, so a frame + /// that drew every chrome line and not one sample still reports tens of thousands of segments. + /// A count that is only the frames before the first bind is startup; one that keeps growing + /// afterwards is the glitch. + int dbg_notrace_frames = 0; + int dbg_notrace_last = -1; + /// The widest *minimum* width the side column or either pane has ever demanded. The side + /// column is a fixed 320 px by design, so a figure far above that is a label whose text drove + /// its own minimum -- the "layout must not depend on content" rule broken, and the only known + /// mechanism by which the plot's allocation can exceed the window. + int dbg_min_w_worst = 0; + int dbg_min_w_reports = 0; + /// Buffer rebuilds the tick has seen. Each one drops the history behind every trace. + unsigned dbg_generation_prev = 0; + bool dbg_generation_seen = false; + int dbg_generation_changes = 0; + /// Set by the tick once a bind has put samples on a trace, so the counter above can tell the + /// frames before any data from the ones after. + bool dbg_ever_bound = false; + int dbg_notrace_after_bound = 0; + /// The frame's one `DisplayMode`, recorded by the tick so the paint probe can name the + /// state a frame was drawn in without taking a second read of it (the producer can latch + /// between any two, which is why the mode is decided once per frame). + DisplayMode dbg_display = DisplayMode::Live; + /// The **ink** account, recomputed per painted frame the renderer's own way (see + /// `check_frame_ink`). `staged_segments` and `lod_traces` cannot see a waveform that + /// vanished for a frame -- the grid and the instruments stage geometry of their own, and a + /// trace whose gathered points all collapse onto one x is still "drawn" -- so these count + /// the polyline the filter actually leaves and how much of the axis it spans. + int dbg_ink_checked = 0; + int dbg_ink_collapse = 0; ///< fewer than two distinct positions left: nothing to draw + int dbg_ink_staircase = 0; ///< the filter dropped over three quarters of the points + int dbg_ink_short = 0; ///< the ink reached under a quarter of the axis' width + double dbg_ink_worst_cover = 1.0; + int dbg_ink_reports = 0; /// Frames the badge spent in each `BadgePhase`. Both `lit` and `dim` have to be non-zero /// over a run that acquired anything -- that is what "it blinks" means, and a badge /// stuck lit looks identical to a working one in a screenshot. @@ -371,6 +437,11 @@ struct App { /// slips past the state it was meant to observe as soon as the frames are heavy -- which is /// exactly the load this probe runs under. int dbg_gesture_verdict = 0; + /// The event-zoom probe's remaining steps, counted down by the *tick* for the same reason + /// the gesture verdicts are: each step's reading has to be taken after the frame's pin and + /// clamp have run, or it reports the ask rather than the view. + int dbg_zoom_steps = 0; + int dbg_zoom_step_no = 0; int dbg_axes_w_change_frame = -1; int dbg_axes_w_change_side = -1; float dbg_scale_min = 1e9f; @@ -553,6 +624,13 @@ void apply_layout(App& app, const LayoutState& st) { if (name.empty()) continue; for (std::size_t si = 0; si < signals.size(); ++si) { if (signals[si].name != name) continue; + // A signal the model will never build a channel for must not take a slot. The + // stream's time signal is skipped by `build_channels_locked()` however often it is + // enabled, so such a slot holds a name for ever and data never -- and because + // `collect_layout()` writes the names straight back, one that got in stayed in and + // was re-saved every session. On the plot it drew nothing; in the cursors pane's + // `mix` mode it drew a line that could only read `--`. + if (ScopeModel::is_time_signal(signals, si)) break; // Enabling is what starts acquisition (a loaded plot nobody acquires would // come back blank), and it is what a drop does for the signal it places. app.rows->assign(i, slot, name, name, signal_color_index(signals, si)); @@ -850,6 +928,159 @@ void cancel_drag_on(App& app, int row) { } } +/// Every widget under `root` whose *minimum* width exceeds `threshold`, as a type name and a +/// depth. A minimum width is what GTK must honour, so the chain printed here is the chain that +/// forced the toplevel wider than the screen -- and the deepest line in it is the widget whose +/// own content did it, which no summary of the plot's allocation can name. +void report_wide_widgets(GtkWidget* root, int threshold, int depth) { + if (root == nullptr || !GTK_IS_WIDGET(root)) return; + int min = 0; + int nat = 0; + gtk_widget_measure(root, GTK_ORIENTATION_HORIZONTAL, -1, &min, &nat, nullptr, nullptr); + if (min <= threshold) return; + std::fprintf(stderr, "[width-probe] %*s%s min=%d nat=%d\n", depth * 2, "", + G_OBJECT_TYPE_NAME(root), min, nat); + for (GtkWidget* c = gtk_widget_get_first_child(root); c != nullptr; + c = gtk_widget_get_next_sibling(c)) { + report_wide_widgets(c, threshold, depth + 1); + } +} + +const char* display_mode_name(DisplayMode m) { + switch (m) { + case DisplayMode::Live: return "live"; + case DisplayMode::Capturing: return "capturing"; + case DisplayMode::Held: return "held"; + case DisplayMode::Waiting: return "waiting"; + case DisplayMode::Paused: return "paused"; + } + return "?"; +} + +/// What a painted frame's first bound trace actually had to *draw*, recomputed through the +/// same three calls `Renderer::draw_traces` makes: the index window from `neighbours()`, the +/// LOD level from the 64-samples-per-pixel gate, then `gather()`. It then **counts** the +/// renderer's increasing-suffix filter rather than applying it, and measures the kept +/// polyline's extent against the axis in pixels. +/// +/// This exists because every other counter here measures *geometry*, and the reported defect +/// is missing *ink*: `staged_segments` includes the grid and the instruments, so a frame that +/// drew all the chrome and not one sample still reports tens of thousands, and `lod_traces` +/// counts a trace whose gathered points the filter then collapses onto a single x as drawn. +/// Three distinguishable outcomes, each a different bug: `collapse` (under two distinct +/// positions left -- an invisible trace on a frame the renderer thinks it drew), `staircase` +/// (most points dropped, i.e. the stamps came back in runs of equal f32) and `short` (the ink +/// reaches a fraction of the axis -- geometry that is there but off to one side). +void check_frame_ink(App& app) { + if (app.rows == nullptr || app.rows->row_count() == 0) return; + const RowSet::Row& r = app.rows->row(0); + if (r.plot == nullptr) return; + const plot::data::Series* s = nullptr; + for (int i = 0; i < RowSet::kSlotsPerRow; ++i) { + const std::size_t si = static_cast(i); + if (r.channel[si] < 0) continue; + s = &r.slots[si]; + break; + } + if (s == nullptr || s->size() == 0) return; + const plot::Range axis = r.plot->x_range(r.axis_x); + const double axes_w = double(r.plot->layout().axes.w); + if (!(axes_w > 0.0) || !(axis.span() > 0.0)) return; + + const plot::u64 n = s->size(); + plot::u64 i0 = 0; + plot::u64 i1 = 0; + plot::u64 first = 0; + plot::u64 last = n; + if (s->neighbours(axis.min, i0, i1)) first = i0 > 0 ? i0 - 1 : 0; + if (s->neighbours(axis.max, i0, i1)) last = std::min(n, i1 + 1); + if (last <= first) return; + const double spp = double(last - first) / axes_w; + const plot::u32 level = spp >= 64.0 ? s->pyramid().level_for(n, spp) : 0u; + std::vector& xy = app.measure_ink_scratch; + xy.clear(); + s->gather(level, first, last, xy); + const std::size_t pts = xy.size() / 2; + + std::size_t kept = 0; + double lo = 0.0; + double hi = 0.0; + // Where the filter's drops fall, and how far back each one steps. The renderer reconstructs + // every point's absolute x as `gather[i] + series.x0()` **inside** this loop, so an `x0()` + // that is not constant for the loop's duration shifts the comparison under itself -- and on a + // ring-backed series `x0()` is `time_of(window_base)`, which *clamps to the oldest readable + // slot* and therefore advances with the producer. The two readings that separate that from a + // genuinely out-of-order gather are the drops' *positions* (spread through the points, or + // bunched at the front where the clamp lives) and `back` (one sample's spacing, or a packet's + // worth). So the loop is run twice: once the renderer's way, and once with `x0()` read a + // single time. + std::size_t drop_first = 0; + std::size_t drop_last = 0; + double worst_back = 0.0; + bool any_drop = false; + { + double next = std::numeric_limits::infinity(); + for (std::size_t k = pts; k > 0; --k) { + const double x = double(xy[2 * (k - 1)]) + s->x0(); + if (!(x < next)) { + worst_back = std::max(worst_back, x - next); + if (!any_drop) drop_last = k - 1; + any_drop = true; + drop_first = k - 1; + continue; + } + next = x; + if (kept == 0) hi = x; + lo = x; + ++kept; + } + } + std::size_t kept_fixed = 0; + { + const double base = s->x0(); // one read, held for the whole walk + double next = std::numeric_limits::infinity(); + for (std::size_t k = pts; k > 0; --k) { + const double x = double(xy[2 * (k - 1)]) + base; + if (!(x < next)) continue; + next = x; + ++kept_fixed; + } + } + ++app.dbg_ink_checked; + const double cover = kept >= 2 ? (hi - lo) / axis.span() : 0.0; + app.dbg_ink_worst_cover = std::min(app.dbg_ink_worst_cover, cover); + const bool collapse = kept < 2; + const bool staircase = pts >= 8 && kept * 4 < pts; + // A ring that has not yet reached back to the axis' left edge is *meant* to leave its left + // side empty (see the `clamp_to_data()` invariant), so a short ink there is the buffer + // filling and not a defect: it is only reported once the data spans the window. + const bool short_ink = !collapse && cover < 0.25 && s->x_bounds().min <= axis.min; + if (collapse) ++app.dbg_ink_collapse; + if (staircase) ++app.dbg_ink_staircase; + if (short_ink) ++app.dbg_ink_short; + if ((collapse || staircase || short_ink) && app.dbg_ink_reports < 40) { + ++app.dbg_ink_reports; + // The series' own answers beside the reconstruction, because the two disagreeing is itself + // a reading: `rel` is what `gather()` emitted (relative to the `x0()` *it* read) and `x0` + // is the `x0()` read here one call later, so a trace whose ink sits a whole ring-span off + // its axis separates into "the stamps moved under us" and "the window is the wrong one". + const plot::Range xb = s->x_bounds(); + std::fprintf(stderr, + "[ink] paint %d %s%s%s mode=%s axis=[%.6f..%.6f] n=%llu idx=[%llu..%llu) " + "spp=%.1f level=%u pts=%zu kept=%zu ink=[%.6f..%.6f] cover=%.1f%% " + "x0=%.6f x_at=[%.6f..%.6f] bounds=[%.6f..%.6f] rel=[%.6f..%.6f] dx=%.3g " + "kept_fixed_x0=%zu drops=[%zu..%zu] back=%.3g\n", + app.dbg_paint_n, collapse ? "collapse " : "", staircase ? "staircase " : "", + short_ink ? "short " : "", display_mode_name(app.dbg_display), axis.min, axis.max, + (unsigned long long)n, (unsigned long long)first, (unsigned long long)last, spp, + level, pts, kept, lo, hi, cover * 100.0, s->x0(), s->x_at(0), s->x_at(n - 1), + xb.min, xb.max, pts > 0 ? double(xy[0]) : 0.0, + pts > 0 ? double(xy[2 * (pts - 1)]) : 0.0, s->dx(), kept_fixed, drop_first, + drop_last, worst_back); + std::fflush(stderr); + } +} + /// Frame-cost probe, end of the plot's own draw. Connected *after* the adapter's /// `::render`, which is the whole reason `PlotArea::on_render` returns FALSE: the signal /// accumulates with `g_signal_accumulator_true_handled`, so a TRUE there would stop the @@ -874,6 +1105,31 @@ int on_render_probe(GtkGLArea*, GdkGLContext*, gpointer user) { app->dbg_seg_last = s.staged_segments; if (s.raw_traces > 0) ++app->dbg_raw_frames; if (s.lod_traces > 0) ++app->dbg_lod_frames; + if (s.raw_traces > 0 || s.lod_traces > 0) check_frame_ink(*app); + if (s.raw_traces == 0 && s.lod_traces == 0) { + ++app->dbg_notrace_frames; + app->dbg_notrace_last = app->dbg_paint_n; + if (app->dbg_ever_bound) { + ++app->dbg_notrace_after_bound; + // What the frame was asked to draw, beside what there was to draw it from: an axis + // that left the data and a trace that had nothing bound are the same picture and + // different bugs, so both are named here rather than inferred from the count. + if (app->dbg_notrace_after_bound <= 40) { + const RowSet::Row& r = app->rows->row(0); + const plot::Range ax = r.plot != nullptr ? r.plot->x_range(r.axis_x) : plot::Range{}; + std::fprintf(stderr, "[notrace] paint %d mode=%s axis=[%.6f..%.6f] segments=%u", + app->dbg_paint_n, display_mode_name(app->dbg_display), ax.min, ax.max, + s.staged_segments); + for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) { + if (r.channel[static_cast(sl)] < 0) continue; + const plot::Range b = r.slots[static_cast(sl)].x_bounds(); + std::fprintf(stderr, " slot%d n=%zu x=[%.6f..%.6f]", sl, + r.slots[static_cast(sl)].size(), b.min, b.max); + } + std::fprintf(stderr, "\n"); + } + } + } } } return FALSE; // same reason the adapter does: never stop the emission @@ -1732,6 +1988,124 @@ bool visible_samples(App& app, std::size_t channel, std::vector& time, return true; } +/// One channel's visible window, readable in chunks and never copied out whole -- what the +/// measurements walk instead of `visible_samples()`. +/// +/// The old path widened the window into two `std::vector` before anything was computed, +/// which at a 1.7 M-sample window was 27 MB written and then read again per pass: 60 % of the +/// pane's cost, 12 ns a sample, 20 ms a recompute. Here the *values* are handed out as `f32` +/// straight from the snapshot (a pointer, no copy) or in a cache-sized scratch from the ring, and +/// a **stamp** is fetched one at a time -- the walks need a time only at the window's extremes and +/// at the level crossings, which number in the handful. +/// +/// For a live window it holds `ScopeModel::mutex()` for its whole life, as the materialization did +/// but for a fraction of the time. Keep one of these alive no longer than the walks. +class MeasureWindowReader { + public: + MeasureWindowReader(App& app, std::size_t channel) { + const RowSet::Row& r0 = app.rows->row(0); + if (r0.plot == nullptr) return; + const plot::Range view = r0.plot->x_range(r0.axis_x); + if ((snap_ = app.model->display_snapshot()) != nullptr) { + if (channel >= snap_->values.size()) return; + const std::vector& st = snap_->stamps; + if (snap_->values[channel].size() != st.size()) return; + const auto lo = std::lower_bound(st.begin(), st.end(), static_cast(view.min)); + const auto hi = std::upper_bound(st.begin(), st.end(), static_cast(view.max)); + if (hi <= lo) return; + off_ = static_cast(lo - st.begin()); + count_ = static_cast(hi - lo); + snap_vals_ = &snap_->values[channel]; + // The snapshot's pyramid is built from sample 0 and nothing is ever dropped from it, so + // its absolute index space *is* the snapshot's. + if (channel < snap_->pyramids.size()) pyr_ = &snap_->pyramids[channel]; + origin_ = off_; + return; + } + lock_ = std::unique_lock(app.model->mutex()); + if (channel >= app.model->channel_count()) return; + epoch_ = app.model->display_epoch(); + u64 first = 0; + u64 last = 0; + app.model->live_window_locked(view.min - epoch_, view.max - epoch_, first, last); + if (last <= first) return; + store_ = &app.model->live(); + channel_ = channel; + first_ = first; + count_ = static_cast(last - first); + // The channel's own pyramid, in the value ring's absolute index space -- the same space + // `live_window_locked()` answers in, since a channel's stride is 1. It is current as of this + // frame's `acquire_all()`; the samples produced since are past `window_end()` and are read + // raw, which bounds that tail at one frame of production. + pyr_ = &store_->channel(channel).pyramid(); + origin_ = first; + } + + std::size_t size() const noexcept { return count_; } + + /// The whole level-0 LOD buckets lying inside this window, as absolute bucket indices + /// `[first, last)`. False when the window has none -- a window shorter than a bucket, or one + /// the pyramid does not reach. + bool bucket_range(u64& first, u64& last) const { + if (pyr_ == nullptr || count_ == 0) return false; + const u64 begin = std::max(origin_, pyr_->window_base()); + const u64 end = std::min(origin_ + count_, pyr_->window_end()); + if (end <= begin) return false; + const u64 stored = pyr_->level_base(0); + first = std::max(stored, (begin + plot::data::kBucket - 1) / plot::data::kBucket); + last = std::min(stored + pyr_->level_size(0), end / plot::data::kBucket); + return last > first; + } + + /// Window offset of absolute bucket `k`'s first sample. + std::size_t bucket_offset(u64 k) const noexcept { + return static_cast(k * plot::data::kBucket - origin_); + } + + MeasureBucket bucket(u64 k) const { + const plot::data::MinMax mm = pyr_->bucket(0, k); + MeasureBucket out; + out.lo = static_cast(mm.min); + out.hi = static_cast(mm.max); + out.sum = pyr_->bucket_sum(0, k); + out.count = static_cast(pyr_->bucket_count(0, k)); + out.offset = bucket_offset(k); + out.span = plot::data::kBucket; + return out; + } + + /// `n` values from window offset `at`. The pointer is valid until the next call. + const f32* values(std::size_t at, std::size_t n) { + if (snap_vals_ != nullptr) return snap_vals_->data() + off_ + at; + if (scratch_.size() < n) scratch_.resize(n); + store_->values(channel_).copy_from(first_ + at, n, scratch_.data()); + return scratch_.data(); + } + + /// Window sample `i`'s stream time. One sample at a time on purpose: see the class comment. + double time_of(std::size_t i) const { + if (snap_vals_ != nullptr) return static_cast(snap_->stamps[off_ + i]); + f32 stamp = 0.0f; + store_->time().copy_from(first_ + i, 1, &stamp); + return epoch_ + static_cast(stamp); + } + + private: + std::shared_ptr snap_; + const std::vector* snap_vals_ = nullptr; + std::size_t off_ = 0; + std::unique_lock lock_; + BufferStore* store_ = nullptr; + std::size_t channel_ = 0; + u64 first_ = 0; + std::size_t count_ = 0; + f64 epoch_ = 0.0; + const plot::data::MinMaxPyramid* pyr_ = nullptr; + /// Absolute sample index of window offset 0, so a bucket's absolute span becomes an offset. + u64 origin_ = 0; + std::vector scratch_; +}; + /// The channel a measurement's signal resolved to, or -1 while the stream does not carry it. int measure_channel(App& app, const std::string& signal) { std::lock_guard lock(app.model->mutex()); @@ -1890,10 +2264,18 @@ App::CursorLine cursors_line(App& app, const char* who, plot::Color chip, bool h GtkGrid* grid = GTK_GRID(app.cursors_body); const int at = static_cast(app.cursors_lines.size()) + 1; // row 0 is the header App::CursorLine line; - line.label = has_chip ? gtk_label_new("") : gtk_label_new(who); + line.label = gtk_label_new(who); gtk_widget_set_halign(line.label, GTK_ALIGN_START); gtk_label_set_ellipsize(GTK_LABEL(line.label), PANGO_ELLIPSIZE_END); - if (has_chip) gtk_label_set_markup(GTK_LABEL(line.label), chip_markup(chip).c_str()); + if (has_chip) { + // The chip **and** the name: a chip alone says a trace has a colour and leaves the user + // to match it against the plot, which with several traces is a guess. Escaped because + // this is markup and a signal name is the stream's text, not ours. + char* esc = g_markup_escape_text(who, -1); + gtk_label_set_markup(GTK_LABEL(line.label), (chip_markup(chip) + " " + esc).c_str()); + g_free(esc); + gtk_widget_set_tooltip_text(line.label, who); + } gtk_grid_attach(grid, line.label, 0, at, 1, 1); GtkWidget* cells[3] = {nullptr, nullptr, nullptr}; for (int c = 0; c < 3; ++c) { @@ -1960,6 +2342,23 @@ std::string slot_unit(App& app, int row, int slot) { return app.model->channels()[static_cast(ch)].unit; } +/// One bit per row/slot for the slots that are *bound* -- a name **and** a channel the +/// model actually built. A name alone is not a trace: the stream's time signal is never +/// acquired, so a slot naming it has a name for ever and data never, and `mix` would draw +/// it a line that can only read `--`. +std::uint32_t bound_slot_mask(App& app) { + std::uint32_t mask = 0; + for (int r = 0; r < app.rows->row_count(); ++r) { + const RowSet::Row& row = app.rows->row(r); + for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { + const std::size_t si = static_cast(s); + if (row.signal_name[si].empty() || row.channel[si] < 0) continue; + mask |= 1u << (r * RowSet::kSlotsPerRow + s); + } + } + return mask; +} + /// The pane's body follows what the mode reads and what is plotted: a line per row for /// the rulers, a line per bound trace for `mix`. Called when the mode changes, when the /// rows change and when the stream is re-described -- the texts are then rewritten every @@ -1977,6 +2376,7 @@ void rebuild_cursors_pane(App& app) { app.cursors_pane_mode = static_cast(app.rows->cursor_mode()); app.cursors_pane_rows = app.rows->row_count(); app.cursors_pane_generation = app.model->generation(); + app.cursors_pane_bound = bound_slot_mask(app); const RowSet::CursorMode mode = app.rows->cursor_mode(); const bool rulers = mode == RowSet::CursorMode::Horizontal || mode == RowSet::CursorMode::Both; @@ -2006,9 +2406,13 @@ void rebuild_cursors_pane(App& app) { const RowSet::Row& row = app.rows->row(r); for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { const std::size_t si = static_cast(s); - if (row.signal_name[si].empty()) continue; - app.cursors_lines.push_back( - cursors_line(app, "", RowSet::palette_color(row.color_index[si]), true)); + // A *bound* trace, not merely a named slot: a slot can name a signal the model + // will never build a channel for (the stream's time signal is never acquired), + // and a line for one of those is a row of `--` beside a colour chip -- which is + // what "the pane shows a lot of empty signals that do not exist" was. + if (row.signal_name[si].empty() || row.channel[si] < 0) continue; + app.cursors_lines.push_back(cursors_line( + app, row.name[si].c_str(), RowSet::palette_color(row.color_index[si]), true)); app.cursors_lines.back().row = r; app.cursors_lines.back().slot = s; } @@ -2022,7 +2426,8 @@ void update_cursors_pane(App& app) { if (app.cursors_body == nullptr || !app.rows->cursors_visible()) return; if (app.cursors_pane_mode != static_cast(app.rows->cursor_mode()) || app.cursors_pane_rows != app.rows->row_count() || - app.cursors_pane_generation != app.model->generation()) { + app.cursors_pane_generation != app.model->generation() || + app.cursors_pane_bound != bound_slot_mask(app)) { rebuild_cursors_pane(app); } const double t0 = app.rows->cursor_a_x(); @@ -2425,40 +2830,143 @@ void update_measures(App& app, bool force) { ++app.measure_passes; } } pass_timer{app, pass_start_us}; + + // Who needs what, before any data is touched: the channel each measurement resolved to, and + // *which* level accumulator answers it. Several measurements on one signal share both walks, and + // several sharing a threshold share one accumulator -- so a pane full of measurements on one + // trace costs the same two walks as a single one. The common case is exactly one level: every + // crossing type reads the mid level and `Top`/`Bottom` default to it. + std::vector chan(app.measures.size(), -1); + std::vector want_level(app.measures.size(), -1); + for (std::size_t i = 0; i < app.measures.size(); ++i) { + const Measure& m = app.measures[i]; + const int channel = measure_channel(app, m.signal); + chan[i] = channel; + if (channel < 0) continue; // unresolvable this pass: a rebuild has emptied the channel list + App::MeasureWindow& w = app.measure_samples[channel]; + double pct = 0.0; + if (!measure_level_pct(m.type, m.options, pct)) continue; + std::size_t k = 0; + while (k < w.pct.size() && w.pct[k] != pct) ++k; + if (k == w.pct.size()) w.pct.push_back(pct); + want_level[i] = static_cast(k); + } + + // The data work: two walks per channel, whatever the pane holds. The first fixes the window's + // extremes (and so every level, which is a share of them); the second is skipped entirely when + // no measurement on this channel reads a level. + // + // Both go through the **LOD pyramid** rather than over the samples. The producer already folds + // every sample into it as it arrives, which is what "evaluate only on the new samples" means + // here: a 64-sample bucket carries its own extremes and its exact sum, so pass one reads 1/64 + // of the window, and pass two opens only the buckets that *straddle* its level -- a handful per + // crossing. The raw path is still here, and still exact, for the window's partial edge buckets, + // for the samples produced since this frame's `acquire_all()`, and for those straddling + // buckets; 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. + constexpr std::size_t kChunk = 8192; + std::vector& buckets = app.measure_bucket_scratch; + for (auto& entry : app.measure_samples) { + App::MeasureWindow& w = entry.second; + const gint64 fetch_start_us = g_get_monotonic_time(); + MeasureWindowReader reader(app, static_cast(entry.first)); + const std::size_t n = reader.size(); + const std::function time_of = [&reader](std::size_t i) { + return reader.time_of(i); + }; + // The window's whole buckets, fetched once and walked by both passes. + u64 kb = 0; + u64 ke = 0; + buckets.clear(); + if (reader.bucket_range(kb, ke)) { + buckets.reserve(static_cast(ke - kb)); + for (u64 k = kb; k < ke; ++k) buckets.push_back(reader.bucket(k)); + } + const std::size_t bulk_from = buckets.empty() ? n : buckets.front().offset; + const std::size_t bulk_to = buckets.empty() ? n : buckets.back().offset + buckets.back().span; + std::size_t raw_read = 0; + // In window order, always: the first sample to reach an extreme is the one the mark names, so + // a later chunk that merely ties it must not move it. + const auto raw = [&](std::size_t from, std::size_t to, const auto& fn) { + for (std::size_t at = from; at < to; at += kChunk) { + const std::size_t take = std::min(kChunk, to - at); + fn(MeasureChunk{reader.values(at, take), take, at}); + raw_read += take; + } + }; + + const auto scan = [&](const MeasureChunk& c) { measure_scan(c, w.stats); }; + raw(0, bulk_from, scan); + if (!buckets.empty()) measure_scan_buckets(buckets.data(), buckets.size(), w.stats); + raw(bulk_to, n, scan); + // A bucket named the 64 samples that hold an extreme, not the sample. One scan of that bucket + // pins it -- two buckets at most, whatever the window's size. + if (w.stats.valid) { + const auto pin = [&](std::size_t& index, std::size_t& search, bool want_hi) { + if (search == 0) return; + MeasureStats one; + measure_scan(MeasureChunk{reader.values(index, search), search, index}, one); + if (one.valid) index = want_hi ? one.hi_i : one.lo_i; + raw_read += search; + search = 0; + }; + pin(w.stats.lo_i, w.stats.lo_search, false); + pin(w.stats.hi_i, w.stats.hi_search, true); + // The two stamps the whole scan needed, read now rather than at every chunk that moved an + // extreme: a stamp is a ring read, and an extreme moves many times over a long window. + w.stats.at_lo = reader.time_of(w.stats.lo_i); + w.stats.at_hi = reader.time_of(w.stats.hi_i); + } + + w.ok = w.stats.valid; + if (w.ok && !w.pct.empty()) { + w.level.assign(w.pct.size(), MeasureLevel{}); + for (std::size_t k = 0; k < w.pct.size(); ++k) { + w.level[k].level = w.stats.lo + w.pct[k] * (w.stats.hi - w.stats.lo); + } + const auto accumulate = [&](const MeasureChunk& c) { + measure_accumulate(c, time_of, w.level.data(), w.level.size()); + }; + raw(0, bulk_from, accumulate); + for (const MeasureBucket& b : buckets) { + const f32* values = nullptr; + for (MeasureLevel& level : w.level) { + if (measure_accumulate_bucket(b, time_of, level)) continue; + // Fetched once for however many levels this bucket straddles, and only then: the usual + // case is one level, and the usual bucket answers without being opened at all. + if (values == nullptr) { + values = reader.values(b.offset, b.span); + raw_read += b.span; + } + measure_accumulate(MeasureChunk{values, b.span, b.offset}, time_of, &level, 1); + } + } + raw(bulk_to, n, accumulate); + } + app.measure_fetch_us += g_get_monotonic_time() - fetch_start_us; + app.measure_covered += static_cast(n); + app.measure_bucketed += static_cast(buckets.size()); + app.measure_raw += raw_read; + } + for (std::size_t i = 0; i < app.measures.size(); ++i) { const Measure& m = app.measures[i]; GtkWidget* label = app.measure_labels[i]; - const int channel = measure_channel(app, m.signal); - if (channel < 0) { - // Its signal is momentarily unresolvable -- a rebuild renumbers the channels and empties - // the list -- so there is nothing to measure *this* pass. The marks still fall back (below); - // `continue`ing here was the other half of the rulers blinking off. - app.measure_text[i] = "--"; - if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--"); - continue; - } - // One *window* per channel per pass, shared by every measurement on that signal (the common - // case is several on one trace). It is held by reference from here on: this loop used to copy - // both vectors out of the cache for every measurement, which at four measurements over a - // 4.7M-sample window is 300 MB of memcpy per frame -- memory-bandwidth bound, and the tick's - // mean went from 0.4 ms to 13.7 ms with the pane holding them. - auto it = app.measure_samples.find(channel); - if (it == app.measure_samples.end()) { - App::MeasureWindow fresh; - if (visible_samples(app, static_cast(channel), fresh.time, fresh.value)) { - fresh.stats = measure_stats(fresh.time, fresh.value); - it = app.measure_samples.emplace(channel, std::move(fresh)).first; - } - } else if (it->second.time.empty()) { - it = app.measure_samples.end(); - } - if (it == app.measure_samples.end()) { + const auto it = chan[i] < 0 ? app.measure_samples.end() : app.measure_samples.find(chan[i]); + if (it == app.measure_samples.end() || !it->second.ok) { + // Its signal is momentarily unresolvable, or the window holds nothing -- so there is nothing + // to measure *this* pass. The marks still fall back (below); `continue`ing out of the marks + // was the other half of the rulers blinking off. app.measure_text[i] = "--"; if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--"); continue; } const App::MeasureWindow& w = it->second; - const MeasureResult r = compute_measure(w.time, w.value, m.type, m.options, &w.stats); + const MeasureLevel* level = + want_level[i] >= 0 && static_cast(want_level[i]) < w.level.size() + ? &w.level[static_cast(want_level[i])] + : nullptr; + const MeasureResult r = compute_measure(w.stats, level, m.type, m.options); app.measure_results[i] = r; if (r.valid) app.measure_last_valid[i] = r; if (label != nullptr) { @@ -3897,6 +4405,118 @@ void note_panel_controls(App& app) { }; if (app.toolbar != nullptr) walk(app.toolbar); app.dbg_panel_controls = std::string(text) + "; toolbar, left to right: " + order; + + // ... and the icon each of them carries, asked of the theme rather than of our own + // string. Four of these are drawn for this app and live in a GResource inside the + // binary, which the theme only looks in because `build_toolbar()` adds the resource + // path -- and a path that was never added fails *silently*: the button keeps working, + // the layout is unchanged, and GTK draws a missing-image placeholder no probe can see. + GtkIconTheme* theme = gtk_icon_theme_get_for_display(gdk_display_get_default()); + for (const auto& [widget, name] : named) { + const char* icon = nullptr; + if (GTK_IS_BUTTON(widget)) { + icon = gtk_button_get_icon_name(GTK_BUTTON(widget)); + } else if (GTK_IS_MENU_BUTTON(widget)) { + icon = gtk_menu_button_get_icon_name(GTK_MENU_BUTTON(widget)); + } + if (icon == nullptr) continue; + app.dbg_toolbar_icons += std::string(app.dbg_toolbar_icons.empty() ? "" : " ") + name + "=" + + icon + (gtk_icon_theme_has_icon(theme, icon) ? "(ok)" : "(MISSING)"); + } +} + +/// The event-zoom sweep's two halves: a gesture zoom, then the same walk through the window +/// control. The second is the tail of the countdown. +constexpr int kZoomGestureSteps = 8; +constexpr int kZoomWindowSteps = 8; + +/// Headless self-check: what `Renderer::draw_traces` would gather for row 0's first bound +/// trace at the axis the frame settled on, recomputed through the same calls it makes. +/// +/// The report this exists for is "zooming into a held event clips the trace further and +/// further from the left, while the crosshair still reads the right values there". The +/// crosshair reads `x_at()`, so a symptom that only the *ink* has localises to the index +/// window and the gather -- and a blank margin that grows as a fraction of the screen while +/// the view narrows is the signature of a roughly constant shift in *data* space. So the +/// line prints both sides of the comparison in data units and in pixels: +/// `axis=` is what the frame drew, `ink=` is the first and last gathered x reconstructed the +/// renderer's way (`x0() + gather`), and `gap_px=` is how many pixels of the left margin no +/// geometry reaches. `gap_px` must stay at a pixel or two at every zoom. +void report_event_zoom(App& a, const char* what) { + if (a.rows->row_count() == 0 || a.rows->row(0).plot == nullptr) return; + const RowSet::Row& r0 = a.rows->row(0); + const plot::data::Series* s = nullptr; + for (int i = 0; i < RowSet::kSlotsPerRow; ++i) { + const std::size_t si = static_cast(i); + if (r0.channel[si] < 0) continue; + s = &r0.slots[si]; + break; + } + const plot::Range axis = r0.plot->x_range(r0.axis_x); + const plot::Rect axes = r0.plot->layout().axes; + const double axes_w = double(axes.w); + if (s == nullptr || s->size() == 0 || !(axes_w > 0.0)) { + std::fprintf(stderr, "[zoom-probe] %-8s axis=[%.6f..%.6f] no trace\n", what, double(axis.min), + double(axis.max)); + std::fflush(stderr); + return; + } + const plot::u64 n = s->size(); + plot::u64 i0 = 0, i1 = 0; + plot::u64 first = 0; + plot::u64 last = n; + if (s->neighbours(axis.min, i0, i1)) first = i0 > 0 ? i0 - 1 : 0; + if (s->neighbours(axis.max, i0, i1)) last = std::min(n, i1 + 1); + if (last <= first) { + std::fprintf(stderr, "[zoom-probe] %-8s axis=[%.6f..%.6f] empty window [%llu..%llu)\n", what, + double(axis.min), double(axis.max), (unsigned long long)first, + (unsigned long long)last); + std::fflush(stderr); + return; + } + // `dense_threshold_spp` is the library's own default (64): above it the renderer takes a + // pyramid level, below it raw samples, and which side of the gate a step lands on is part + // of what this line reports. + const double spp = double(last - first) / axes_w; + const plot::u32 level = spp >= 64.0 ? s->pyramid().level_for(n, spp) : 0u; + std::vector xy; + s->gather(level, first, last, xy); + double ink_lo = 0.0, ink_hi = 0.0; + if (xy.size() >= 2) { + ink_lo = s->x0() + double(xy[0]); + ink_hi = s->x0() + double(xy[xy.size() - 2]); + } + // The renderer's own increasing-suffix filter, counted rather than applied: it drops every + // point that is not strictly older than the one kept after it, which for a *run* of equal x + // keeps the last of the run. The stamps here are f32, so once the sample spacing falls below + // the f32 step at the stream's magnitude the gathered x has such runs -- and `kept` is how + // many distinct positions the polyline is left with. A `kept` far below `pts` is a staircase, + // and a `kept` of a handful is a trace with nothing to draw. + std::size_t kept = 0; + double worst_back = 0.0; // the largest backwards step, in data units: non-monotonic gather + { + double next = std::numeric_limits::infinity(); + for (std::size_t k = xy.size() / 2; k > 0; --k) { + const double x = double(xy[2 * (k - 1)]) + s->x0(); + if (!(x < next)) { + worst_back = std::max(worst_back, x - next); + continue; + } + next = x; + ++kept; + } + } + const double span = axis.span() > 0.0 ? axis.span() : 1.0; + std::fprintf(stderr, + "[zoom-probe] %-8s axis=[%.6f..%.6f] span=%.6g n=%llu idx=[%llu..%llu) spp=%.1f " + "level=%u pts=%zu kept=%zu back=%.3g ink=[%.6f..%.6f] x_at_first=%.6f " + "gap_px=%.1f end_px=%.1f rect=[%g,%g,%gx%g]\n", + what, double(axis.min), double(axis.max), axis.span(), (unsigned long long)n, + (unsigned long long)first, (unsigned long long)last, spp, level, xy.size() / 2, kept, + worst_back, ink_lo, ink_hi, s->x_at(first), + (ink_lo - axis.min) / span * axes_w, (axis.max - ink_hi) / span * axes_w, + double(axes.x), double(axes.y), double(axes.w), double(axes.h)); + std::fflush(stderr); } // --- the frame tick --------------------------------------------------------- @@ -3977,6 +4597,7 @@ gboolean tick(gpointer user) { // close a capture between any two reads of the model, and the bind, the epoch, the pin // and the status line all have to describe the same picture. const DisplayMode display = model.display_mode(); + app->dbg_display = display; // the paint probe names the state rather than re-reading it const double cap_edge = model.capture_edge(); const bool new_capture = display == DisplayMode::Capturing && (!app->capturing || cap_edge != app->capture_edge); @@ -4092,6 +4713,40 @@ gboolean tick(gpointer user) { app->rows->clamp_to_data(); } + // The event-zoom probe's step, taken here because the pin and the clamp above are what + // decide the axis the renderer is about to read: a `g_timeout` would report the ask. + if (app->dbg_zoom_steps > 0 && app->rows->row_count() > 0 && + app->rows->row(0).plot != nullptr) { + char what[24]; + // Two sweeps, because there are two ways to zoom the time base and they take different + // paths: the second half drives the *window control* (`apply_time_window`, which unpins + // and re-arms the live follow before the pin is restated), the first half a gesture. + const bool via_window = app->dbg_zoom_steps <= kZoomWindowSteps; + std::snprintf(what, sizeof what, "%s/x%d", via_window ? "win" : "ges", + 1 << app->dbg_zoom_step_no); + report_event_zoom(*app, what); + --app->dbg_zoom_steps; + ++app->dbg_zoom_step_no; + const RowSet::Row& r0 = app->rows->row(0); + if (app->dbg_zoom_steps == kZoomWindowSteps) { + // Hand the window sweep a full event again, so it starts where the gesture sweep did. + app->dbg_zoom_step_no = 0; + double lo = 0.0, hi = 0.0; + if (app->rows->event_data_window(lo, hi)) apply_time_window(*app, hi - lo); + } else if (via_window) { + apply_time_window(*app, std::max(1.0e-4, app->rows->time_window() * 0.5)); + } else { + // Halve the window about its **centre**, so the left edge walks *into* the data. An + // anchor that keeps the left edge where it is keeps it on the series' own first sample, + // where no index window can be short and the margin cannot be empty -- which is how the + // first version of this probe reported `gap_px=0.0` at every zoom on a view that was + // visibly clipped. + const plot::Range now = r0.plot->x_range(r0.axis_x); + const double c = 0.5 * (now.min + now.max); + r0.plot->set_x_range(r0.axis_x, plot::Range{c - 0.25 * now.span(), c + 0.25 * now.span()}); + } + } + // Curator: the trigger's arm state can change under us (single mode disarms // itself), so the button and the status line read the published atomics. const bool trigger_on = model.trigger_enabled(); @@ -4218,6 +4873,22 @@ gboolean tick(gpointer user) { } } if (bound > 0 && empty == bound) ++app->dbg_blank_frames; + if (bound > 0 && empty < bound) app->dbg_ever_bound = true; + // A rebuild drops every ring's history, so the frames after one draw an empty plot until the + // producer has refilled the window -- which is what "the plot flickers as if there were no + // data" looks like. `dbg_blank_frames` cannot see it: it asks whether the ring holds samples + // the view has not got, and a rebuilt ring holds none either. The generation is the only + // thing that says a rebuild happened, so each change is named here with what asked for it. + const unsigned gen = model.generation(); + if (app->dbg_generation_seen && gen != app->dbg_generation_prev) { + ++app->dbg_generation_changes; + if (app->dbg_generation_changes <= 40) { + std::fprintf(stderr, "[gen] frame %d generation %u -> %u (bound=%d empty=%d)\n", + app->dbg_axes_frames, app->dbg_generation_prev, gen, bound, empty); + } + } + app->dbg_generation_prev = gen; + app->dbg_generation_seen = true; // And the renderer's blank: the rings hold samples, yet the frame staged nothing. if (bound > 0 && empty == 0 && app->dbg_seg_last == 0) ++app->dbg_render_blank_frames; } @@ -4238,6 +4909,36 @@ gboolean tick(gpointer user) { if (app->layout_editing) sync_layout_editor(*app); if (app->area_widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(app->area_widget)); + // What the side column *demands*, not what it got. A GTK box's minimum width is its + // children's, a `GtkLabel`'s minimum is its text, and a minimum larger than the screen is + // honoured by growing the toplevel -- which hands the plot an allocation wider than the window + // and pushes most of the trace off the right-hand edge. `axes.w` sees the consequence a frame + // later and cannot name the cause, so the demand is read here, from the panes whose contents + // the frame tick rewrites. + { + const auto min_w = [](GtkWidget* w) { + if (w == nullptr || !GTK_IS_WIDGET(w)) return 0; + int min = 0; + int nat = 0; + gtk_widget_measure(w, GTK_ORIENTATION_HORIZONTAL, -1, &min, &nat, nullptr, nullptr); + return min; + }; + const int side = min_w(app->side_box); + const int meas = min_w(app->measures_list); + const int curs = min_w(app->cursors_body); + const int worst = std::max(side, std::max(meas, curs)); + if (worst > app->dbg_min_w_worst) { + const int was = app->dbg_min_w_worst; + app->dbg_min_w_worst = worst; + if (was > 0 && worst - was > 32 && app->dbg_min_w_reports < 40) { + ++app->dbg_min_w_reports; + std::fprintf(stderr, + "[width-probe] frame %d min width grew %d -> %d (side=%d measures=%d " + "cursors=%d)\n", + app->dbg_axes_frames, was, worst, side, meas, curs); + } + } + } if (plot::Plot* p0 = app->rows->row(0).plot; p0 != nullptr && p0->viewport().w > 100.0f) { // steady state only const int ax = static_cast(p0->layout().axes.x); @@ -4258,6 +4959,29 @@ gboolean tick(gpointer user) { ++app->dbg_axes_w_changes; app->dbg_axes_w_change_frame = app->dbg_axes_frames; app->dbg_axes_w_change_side = gtk_widget_get_visible(app->side_box) ? 1 : 0; + // Each change, not just the last: a summary says the plot's width moved and cannot say + // *what* moved it, and a width that moves by itself is the "the plot flickers" report's + // first candidate. Named here beside the widths that can cause it -- the side column and + // the two panes whose contents the frame tick rewrites -- so the culprit is read rather + // than guessed. Capped, because the twitch bug would otherwise fill the log. + if (app->dbg_axes_w_changes <= 40) { + int side_w = 0; + int meas_w = 0; + int curs_w = 0; + if (app->side_box != nullptr) side_w = gtk_widget_get_width(app->side_box); + if (app->measures_list != nullptr) meas_w = gtk_widget_get_width(app->measures_list); + if (app->cursors_body != nullptr) curs_w = gtk_widget_get_width(app->cursors_body); + std::fprintf(stderr, + "[width-probe] frame %d axes.w %d -> %d viewport.w %d side=%dx(vis=%d) " + "measures_w=%d cursors_w=%d\n", + app->dbg_axes_frames, app->dbg_axes_w_prev, aw, + static_cast(p0->viewport().w), side_w, + app->dbg_axes_w_change_side, meas_w, curs_w); + // A step of this size is not the gutter settling, so name what demanded it. + if (std::abs(aw - app->dbg_axes_w_prev) > 100) { + report_wide_widgets(app->window, 900, 1); + } + } } app->dbg_axes_w_prev = aw; app->dbg_vp_w_min = std::min(app->dbg_vp_w_min, static_cast(p0->viewport().w)); @@ -4709,6 +5433,12 @@ GtkWidget* build_toolbar(App& app) { GtkWidget* bar = gtk_header_bar_new(); app.toolbar = bar; + // The app's own icons, before the first button asks for one. They are a GResource + // compiled into the binary (see CMakeLists), and this is what makes the theme look + // there: an unregistered path is not an error, it is a missing-image placeholder. + gtk_icon_theme_add_resource_path(gtk_icon_theme_get_for_display(gdk_display_get_default()), + "/org/udpscope/icons"); + gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), labeled_box_fixed("window", build_window_control(app))); refresh_window_limits(app); @@ -4744,7 +5474,7 @@ GtkWidget* build_toolbar(App& app) { gtk_box_append(GTK_BOX(menu_box), app.layout_toggle); app.measures_toggle = gtk_toggle_button_new(); - gtk_button_set_icon_name(GTK_BUTTON(app.measures_toggle), "edit-find-symbolic"); + gtk_button_set_icon_name(GTK_BUTTON(app.measures_toggle), "udpscope-measure-symbolic"); gtk_widget_set_tooltip_text(app.measures_toggle, "compute a value over the visible window of a signal, and " "optionally mark it on the plot"); @@ -4761,7 +5491,7 @@ GtkWidget* build_toolbar(App& app) { // (packed after math and before cursors below: see the order note at the menu) app.math_toggle = gtk_toggle_button_new(); - gtk_button_set_icon_name(GTK_BUTTON(app.math_toggle), "edit-find-replace-symbolic"); + gtk_button_set_icon_name(GTK_BUTTON(app.math_toggle), "udpscope-math-symbolic"); gtk_widget_set_tooltip_text(app.math_toggle, "channels derived from the others by a graph of operations: gain, " "absolute value, filters, sums and the rest. An enabled one is " @@ -4777,7 +5507,7 @@ GtkWidget* build_toolbar(App& app) { &app); app.cursors_toggle = gtk_toggle_button_new(); - gtk_button_set_icon_name(GTK_BUTTON(app.cursors_toggle), "find-location-symbolic"); + gtk_button_set_icon_name(GTK_BUTTON(app.cursors_toggle), "udpscope-cursors-symbolic"); gtk_widget_set_tooltip_text(app.cursors_toggle, "the cursor instruments: two times, two levels, or the two read " "off each trace where they cross"); @@ -4816,7 +5546,7 @@ GtkWidget* build_toolbar(App& app) { g_signal_connect(app.sidebar_toggle, "toggled", G_CALLBACK(on_sidebar_toggled), &app); app.trigger_toggle = gtk_toggle_button_new(); - gtk_button_set_icon_name(GTK_BUTTON(app.trigger_toggle), "media-record-symbolic"); + gtk_button_set_icon_name(GTK_BUTTON(app.trigger_toggle), "udpscope-trigger-symbolic"); gtk_widget_set_tooltip_text(app.trigger_toggle, "trigger mode: freeze the view and show one captured event " "at a time"); @@ -5212,6 +5942,34 @@ gboolean trig_trace_probe(gpointer user) { } +/// Headless self-check: waits for a *held* event, then lets the tick run the zoom sweep (see +/// `report_event_zoom`). Polling for `Held` rather than firing on a fixed delay is the same +/// lesson the disarm probe learned: a latch happens when the stream says so, and a sweep that +/// starts before one has nothing to zoom into. +gboolean eventzoom_wait(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + if (a->model->display_mode() != DisplayMode::Held) return G_SOURCE_CONTINUE; + // Eight halvings: a 2 s event down to about 8 ms, which crosses the pyramid's own + // raw/LOD gate on the way and so exercises both gather paths. Then the same again + // through the window control. + a->dbg_zoom_steps = kZoomGestureSteps + kZoomWindowSteps; + a->dbg_zoom_step_no = 0; + return G_SOURCE_REMOVE; +} + +/// Single trigger mode, armed: one event, held for as long as the sweep needs it. +gboolean eventzoom_probe(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + if (a->trigger_mode_drop != nullptr) + gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_mode_drop), 1); + if (a->trigger_toggle != nullptr) + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE); + g_timeout_add(50, eventzoom_wait, a); + return G_SOURCE_REMOVE; +} + /// Headless self-check: defines math channels the way the math pane will, so the *pipeline* can be /// checked without driving the editor -- the same graph reaches the producer either way. Two graphs /// on the first acquired channel: a gain of 2 and a low pass, which the dump then reports beside the @@ -5976,8 +6734,11 @@ void cursors_probe_report(App& a, const char* what) { std::string t_text; std::string lines; int n = 0; + // The row's own name too, not just its three cells: a `mix` line names the trace it reads, + // and a line that should not exist at all is only recognisable by *which* trace it claims. const auto cells = [](const App::CursorLine& line) { - return std::string(gtk_label_get_text(GTK_LABEL(line.a))) + " " + + std::string who = line.label != nullptr ? gtk_label_get_text(GTK_LABEL(line.label)) : ""; + return who + "=" + gtk_label_get_text(GTK_LABEL(line.a)) + " " + gtk_label_get_text(GTK_LABEL(line.b)) + " " + gtk_label_get_text(GTK_LABEL(line.d)); }; @@ -6454,6 +7215,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURE_MS")) { g_timeout_add(static_cast(std::atoi(env)), measure_probe, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_EVENTZOOM_MS")) { + g_timeout_add(static_cast(std::atoi(env)), eventzoom_probe, &app); + } if (const char* env = g_getenv("UDPSCOPE_PROBE_PAN_MS")) { g_timeout_add(static_cast(std::atoi(env)), pan_probe, &app); } @@ -6529,6 +7293,13 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit std::printf("[udpscope] reset button visible: %d\n", app.dbg_reset_visible); std::printf("[udpscope] render blank frames: %d of %d painted\n", app.dbg_render_blank_frames, app.dbg_frames_painted); + std::printf("[udpscope] traceless paints: %d (%d after the first bind, last at paint %d)\n", + app.dbg_notrace_frames, app.dbg_notrace_after_bound, app.dbg_notrace_last); + std::printf("[udpscope] worst side/pane min width: %d px\n", app.dbg_min_w_worst); + std::printf("[udpscope] trace ink: %d paints checked, %d collapsed, %d staircase, %d short, " + "worst cover %.1f%%\n", + app.dbg_ink_checked, app.dbg_ink_collapse, app.dbg_ink_staircase, + app.dbg_ink_short, app.dbg_ink_worst_cover * 100.0); std::printf("[udpscope] blank view frames: %d of %d\n", app.dbg_blank_frames, app.dbg_axes_frames); std::printf("[udpscope] trigger events: %llu total, %llu at the disarm (no event after it " @@ -6558,6 +7329,11 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit std::vector> wanted; { std::lock_guard lock(app.model->mutex()); + // The first *streamed* channel too: it is the one a saturated ring clamps at the front, and + // the order check below is the only place that shows it without a screenshot. + if (!app.model->channels().empty()) { + wanted.emplace_back(app.model->channels().front().name, 0); + } for (const MathChannel& m : app.model->math_channels()) { int idx = -1; for (std::size_t c = 0; c < app.model->channels().size(); ++c) { @@ -6584,9 +7360,17 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit // duplicate or a backwards step is what a wrap artifact looks like from the data side -- // the renderer's index window comes from a binary search over these, and a segment drawn // from the end of the ring to its beginning is a window that spans the wrap. + // A break can only be at the *front*: the window's oldest slots may be clamped to the oldest + // readable stamp (the published window is pinned while the rings slide under it), so a + // saturated ring legitimately shows a few non-increasing stamps at the start. A break + // anywhere else is a real one, so the *first* break's position is reported. std::size_t order_breaks = 0; + long long first_break = -1; for (std::size_t i = 1; ok && i < t.size(); ++i) { - if (!(t[i] > t[i - 1])) ++order_breaks; + if (!(t[i] > t[i - 1])) { + ++order_breaks; + if (first_break < 0) first_break = static_cast(i); + } } // `produced` beside the visible count: a derived channel is filled once per stored time // slot, so its ring must hold exactly as many samples as the time ring the plot reads it @@ -6599,10 +7383,24 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit slot_count = app.model->live().time().produced(); } std::printf("[udpscope] math '%s': channel %d, %llu samples vs %llu time slots, " - "%zu visible (order breaks %zu), y=[%.4f, %.4f]\n", + "%zu visible (order breaks %zu, first at %lld), y=[%.4f, %.4f]\n", name.c_str(), idx, static_cast(produced), static_cast(slot_count), ok ? v.size() : 0u, order_breaks, - ok ? lo : 0.0, ok ? hi : 0.0); + first_break, ok ? lo : 0.0, ok ? hi : 0.0); + } + // What the derived channels cost the *producer*. This is not a frame cost: the math runs + // between two DATA packets on the acquisition thread, so it never appears in `frame cost:` + // and shows up only as dropped packets once it exceeds the inter-packet budget. `ns/slot` + // is the figure to compare across runs; it is *sampled* (see `ScopeModel::math_slots()`), + // because timing every slot costs about as much as a slot and would measure itself. + if (app.model->math_timed() > 0) { + const double per_slot = static_cast(app.model->math_cost_ns()) / + static_cast(app.model->math_timed()); + std::printf("[udpscope] math cost: %llu slots, %.1f ns/slot (%llu timed), " + "%.2f ms total est\n", + static_cast(app.model->math_slots()), per_slot, + static_cast(app.model->math_timed()), + per_slot * static_cast(app.model->math_slots()) / 1.0e6); } } // Unconditional, unlike the pane's own lines below: this is about the instruments the app @@ -6613,6 +7411,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit // the window is destroyed, where a widget pointer is dangling and asking GTK about it is a // critical (which is how the first version of this line earned four of them). std::printf("[udpscope] panel controls: %s\n", app.dbg_panel_controls.c_str()); + std::printf("[udpscope] toolbar icons: %s\n", app.dbg_toolbar_icons.c_str()); // The measurements pane, with the numbers the engine computed: a probe adds measurements and // this is where their values are read, so a wrong threshold or a window that moved under a // stale result is visible in the run rather than only on screen. @@ -6628,9 +7427,26 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit std::printf("[udpscope] clamp fires: %d (a clamp that fires every frame is a re-frame loop)\n", app.rows->clamp_fires()); if (app.measure_passes > 0) { - std::printf("[udpscope] measure cost: %d recomputes, %.2f ms total, %.2f ms each\n", + std::printf("[udpscope] measure cost: %d recomputes, %.2f ms total, %.2f ms each, " + "data %.2f ms (%.0f%%), covered=%llu buckets=%llu raw=%llu (%.1f%%), " + "%.1f ns/covered sample\n", app.measure_passes, app.measure_cost_us / 1000.0, - app.measure_cost_us / 1000.0 / app.measure_passes); + app.measure_cost_us / 1000.0 / app.measure_passes, + app.measure_fetch_us / 1000.0, + app.measure_cost_us > 0 + ? 100.0 * static_cast(app.measure_fetch_us) / + static_cast(app.measure_cost_us) + : 0.0, + static_cast(app.measure_covered), + static_cast(app.measure_bucketed), + static_cast(app.measure_raw), + app.measure_covered > 0 ? 100.0 * static_cast(app.measure_raw) / + static_cast(app.measure_covered) + : 0.0, + app.measure_covered > 0 + ? 1000.0 * static_cast(app.measure_cost_us) / + static_cast(app.measure_covered) + : 0.0); } } std::printf("[udpscope] badge frames: lit=%d dim=%d idle=%d\n", app.dbg_badge_frames[0], diff --git a/tools/mock_udpstreamer.py b/tools/mock_udpstreamer.py index fa465f3..685384e 100644 --- a/tools/mock_udpstreamer.py +++ b/tools/mock_udpstreamer.py @@ -16,6 +16,7 @@ Everything is float64 and unquantised. import argparse import math +import random import socket import struct import time @@ -87,6 +88,11 @@ def main(): "clock advances ten times slower than the wire delivers samples") ap.add_argument("--declared-rate", type=float, default=-1.0, help="sampling_rate to put in CONFIG; -1 = the real rate, 0 = 'unknown'") + ap.add_argument("--clock-jitter", type=float, default=0.0, + help="per-packet stamp jitter, as a fraction of one packet's span: a device " + "that stamps each batch from its own clock does not land on a perfect " + "grid, so the stream's timestamps wobble about one. Non-cumulative " + "(the mean rate is unchanged); 0 = the ideal grid") args = ap.parse_args() dt = args.stream_scale / args.rate # stream seconds per sample @@ -134,9 +140,17 @@ def main(): time.sleep(min(next_send - now, 0.002)) continue - sock.sendto(build_data(counter, t_stream, dt, args.batch, args.freq), peer) + # The jitter varies each *packet's* own spacing rather than offsetting its start, because + # the stream's timestamps have to stay strictly increasing -- a jittered t0 can overlap the + # packet before it, and the buffer, the trigger and every binary search here assume a + # monotonic clock. The mean spacing is still `dt`, so the declared rate stays honest; what + # moves is the *local* spacing, which is what a uniform grid over the window cannot model. + pdt = dt + if args.clock_jitter > 0.0: + pdt = dt * (1.0 + random.uniform(-args.clock_jitter, args.clock_jitter)) + sock.sendto(build_data(counter, t_stream, pdt, args.batch, args.freq), peer) counter += 1 - t_stream += args.batch * dt + t_stream += args.batch * pdt next_send += period if args.duration > 0.0 and started is not None and now - started > args.duration: print("mock udpstreamer: done (%d packets)" % counter, flush=True)