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)