perf: a frozen frame is not re-rendered sixty times a second

The tick queued a GL render unconditionally. In `Held` and `Paused` the rows
are bound to an immutable snapshot on a pinned axis, so a frame in which none
of the picture's own terms moved is the same frame again. The gate keys on a
coarse signature of what a frozen view can be re-framed by, opens for
`kSettleRenders` frames on a change because the figure lays out per render, and
has a floor that renders anyway every few ticks. The layout editor is excluded:
its buttons are placed from the last frame's rects, so a skipped frame there is
a stale widget rather than a stale picture. `skips=` in the probe dump is the
reading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-13 14:50:55 +02:00
1 parent 84c158ff21
commit be71a1d4bb
5 files changed
+130 -4

No files matched your search

+37
View File
@@ -344,6 +344,43 @@ array of per-node structs was measured and bought **nothing** — at six nodes t
L1-resident either way. It was kept because a node's state belonging on one cache line is the 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. right shape for a graph that grows, not because it showed up.
### What the ingest filter and a still frame cost
Two costs that are paid once per *wire sample* and once per *tick* respectively, and so are the
largest two the application has that are not proportional to anything the user asked for.
**The ingest filter's group test.** Decimation at ingest is an average, never a stride, so the
filter reads every sample by design — and the test for "is this sample the last of its group"
was a modulo: `tick % period == period - 1`, once per channel per sample, plus once more for the
shared clock. `Partition::time_stride` is chosen at *runtime* (`plan_partition()` doubles it from
1 until the window fits), so that modulo is not a compile-time strength reduction but a real
64-bit `div` — and the code's own comment claimed the opposite. Both factors are powers of two
(every per-channel `stride[i]` is 1 by design), so the period minus one is a **mask** and the test
is `(tick & mask) == mask`. The masks are built where the accumulators are sized, keyed on the
partition, so a re-plan cannot leave them addressing the previous partition's groups; the
reserved-hole path got the same treatment, because a hole's samples go through the same
accumulators and the two phases have to agree sample for sample. The shared clock's test is now
computed once per sample and reused by the time ring, the channel loop and the math push — it was
three tests of the same thing.
**A frozen frame.** The tick queued a GL render unconditionally, sixty times a second, for the
whole life of the process. In `Held` and `Paused` that is the same picture again: the rows are
bound to an immutable snapshot on a pinned axis and the producer is feeding the live ring, not the
series on screen. On llvmpipe, or on a battery, re-drawing a frozen event at 60 Hz is the entire
cost of the application. The gate is now two parts, and the second is what makes the first safe to
keep coarse: a *signature* of what a frozen view can be re-framed by — a new snapshot, a pan or
zoom (the group's range, where both the gestures and the pin write), a row added or removed —
which renders the moment it changes, and a **floor** that renders anyway every four ticks (~15 Hz),
so anything the signature does not name is late rather than lost. A gate that can only be opened by
a list of state is a gate that goes wrong the first time the list is one item short. A change opens
the gate for three frames rather than one, because the figure lays itself out per *render* and its
left gutter is a monotone floor that takes two passes to converge. The layout editor is excluded
outright: its buttons are placed from the rects the last frame was laid out with, so there a
skipped frame is a stale *widget* and not just a stale picture. Interaction needs none of this —
`PlotArea` queues its own render from the pointer, scroll and drag handlers, so a crosshair and a
cursor drag stay immediate whatever the gate decides. `[pause-probe]`'s `skips=` field reports it,
cumulative: 0 on the live line, non-zero on the paused one.
### Statistics ### Statistics
**Statistics** under the measurements list adds a second line to every measurement: `n`, the **Statistics** under the measurements list adds a second line to every measurement: `n`, the
+12 -1
View File
@@ -148,7 +148,7 @@ precedent, not an exception.
- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are - `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* measured on a still picture. It now also asserts what a pause *is*
(`[pause-probe] <action> paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..] (`[pause-probe] <action> paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..]
glance=[..]` on stderr): skips=N glance=[..]` on stderr):
it presses the **button**, not `Acquisition::set_paused()`, because pausing is a display it presses the **button**, not `Acquisition::set_paused()`, because pausing is a display
state as well as an acquisition one and the view snapshot and axis pin live in the state as well as an acquisition one and the view snapshot and axis pin live in the
handler. Across the three lines **`ring_produced` must grow** (the producer never handler. Across the three lines **`ring_produced` must grow** (the producer never
@@ -168,6 +168,17 @@ precedent, not an exception.
stops while paused and "how long have I been looking at this" is a question about the user, not stops while paused and "how long have I been looking at this" is a question about the user, not
about the stream; it is written through `format_elapsed()` so the field steps to a coarser unit about the stream; it is written through `format_elapsed()` so the field steps to a coarser unit
instead of growing digits (see the layout rule). instead of growing digits (see the layout rule).
**`skips=` is the assertion for the render gate** (the end of `tick()`), cumulative over the
session: the `live` line must read **0** and the `paused` line must read **more than 0**. A
frozen view is bound to an immutable snapshot on a pinned axis, so a frame whose picture cannot
have changed is not drawn again — and this is the only line that can say whether that happened,
since a skipped frame is invisible in every other reading by construction. Non-zero on the
`live` line is the gate's condition inverted, which would cost dropped samples' worth of
picture; 0 on the `paused` line is the gate not engaging and the saving not being made.
The gate cannot stall the picture (`kStaticRenderEvery` renders every few ticks regardless),
so the renderer's own baselines below are unchanged by it: a measured run read `skips=0`,
then 33, then 45, with `columns == graded`, `render blank frames: 0`, and the layout floor
still converged (`y-gutter 104..104`).
- `UDPSCOPE_PROBE_COLOR_MS` — recolour one plotted signal and read the colour back out of the - `UDPSCOPE_PROBE_COLOR_MS` — recolour one plotted signal and read the colour back out of the
trace and the chip, round-trip it through the session writer, then clear it. Schedule trace and the chip, round-trip it through the session writer, then clear it. Schedule
`UDPSCOPE_ENABLE_ALL_MS` before it: with nothing bound it has no trace to recolour and asserts `UDPSCOPE_ENABLE_ALL_MS` before it: with nothing bound it has no trace to recolour and asserts
+45 -1
View File
@@ -3706,7 +3706,51 @@ gboolean tick(gpointer user) {
window_w < kSidebarRoom); window_w < kSidebarRoom);
} }
} }
if (app->area_widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(app->area_widget)); // The render, queued once a frame -- but not unconditionally. In `Held` and `Paused` the rows
// are bound to an immutable snapshot on a pinned axis and the producer is feeding the live ring,
// not the series on screen, so a frame in which none of the picture's own terms moved is the
// same frame again. Every other mode renders: `Live` and `Capturing` roll new samples under the
// axis, and `Waiting` moves the live epoch, so in all three the picture differs every frame.
//
// The gate is two parts, and the second is what makes the first safe to keep coarse:
// * a signature of what a frozen view *can* be re-framed by -- a new snapshot, a pan or zoom
// (the group's range, which is where the gestures and the pin both write), a row added or
// removed -- which renders the moment it changes;
// * `kStaticRenderEvery`, a floor that renders anyway every few ticks, so anything not in the
// signature is late rather than lost. A gate that can only be opened by a list of state is
// a gate that goes wrong the first time the list is one item short.
// Interaction needs neither: `PlotArea` queues its own render from the pointer, scroll and drag
// handlers, so a crosshair and a cursor drag are immediate whatever this decides.
//
// The layout editor is excluded outright: its buttons are placed from the rects the last frame
// was laid out with (`sync_layout_editor()` above), so it is the one state where a skipped frame
// is a stale *widget* and not just a stale picture.
if (app->area_widget != nullptr) {
const bool frozen = (display == DisplayMode::Held || display == DisplayMode::Paused) &&
!app->layout_editing;
const plot::Range view = app->rows->view_range();
const std::uint64_t seq = model.event_seq();
const int rows = app->rows->row_count();
const bool same = frozen && seq == app->render_event_seq && view.min == app->render_x_min &&
view.max == app->render_x_max && rows == app->render_rows;
// A change opens the gate for `kSettleRenders` frames, not one: the figure's left gutter is a
// monotone floor that converges over two layout passes (see the `layout over N frames`
// reading), and a layout pass happens per *render* -- so a single frame after a re-frame would
// leave the gutter one pass short until the floor below fired, 64 ms later.
if (!same) app->render_open = kSettleRenders;
if (app->render_open > 0 || app->render_skipped + 1 >= kStaticRenderEvery) {
if (app->render_open > 0) --app->render_open;
app->render_skipped = 0;
gtk_gl_area_queue_render(GTK_GL_AREA(app->area_widget));
} else {
++app->render_skipped;
++app->dbg_render_skips;
}
app->render_event_seq = seq;
app->render_x_min = view.min;
app->render_x_max = view.max;
app->render_rows = rows;
}
// What the side column *demands*, not what it got. A GTK box's minimum width is its // 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 // 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 // honoured by growing the toplevel -- which hands the plot an allocation wider than the window
+29
View File
@@ -503,6 +503,23 @@ struct App {
/// 0 until the first event of the session. /// 0 until the first event of the session.
gint64 last_latch_us = 0; gint64 last_latch_us = 0;
double timed_edge = 0.0; double timed_edge = 0.0;
/// The render gate's memory (see the end of `tick()`). A `Held` or `Paused` view is bound to
/// an immutable snapshot on a pinned axis, so a frame whose picture cannot have changed does
/// not need to be drawn again -- and on llvmpipe, or on a battery, re-drawing a frozen event
/// sixty times a second is the whole cost of the application. The first four are the coarse
/// signature of what a frozen picture can be re-framed by; a change to any of them opens
/// `render_open` for `kSettleRenders` frames, so the figure's layout floor has the passes it
/// needs to converge. `render_skipped` is the floor that makes everything the signature does
/// *not* name render soon anyway (within `kStaticRenderEvery` ticks), which is what keeps the
/// gate from being able to stall the picture.
std::uint64_t render_event_seq = 0;
double render_x_min = 0.0;
double render_x_max = 0.0;
int render_rows = -1;
int render_open = 0;
int render_skipped = 0;
/// Self-check: ticks that queued no render, so the gate is a number rather than a claim.
int dbg_render_skips = 0;
double window_limit_s = -1.0; ///< the cap the window control was last clamped against double window_limit_s = -1.0; ///< the cap the window control was last clamped against
/// The rate each channel actually *stored* over the last second, and the declared rate /// The rate each channel actually *stored* over the last second, and the declared rate
/// beside it. The two differ for two reasons now and both are worth seeing: the declared /// beside it. The two differ for two reasons now and both are worth seeing: the declared
@@ -1021,6 +1038,18 @@ struct RowMenuProbeState {
}; };
constexpr int kTickMs = 16; constexpr int kTickMs = 16;
/// How often a frozen view is re-drawn anyway, in ticks: the render gate's floor (see `tick()`
/// and `App::render_skipped`). Four is ~15 Hz, so anything the gate's signature does not name --
/// a recolour, a measurement's mark, a y range a fit changed -- reaches the screen within ~64 ms
/// of a still picture, which is below noticing. It is deliberately a small number: the gate's
/// purpose is to stop spending a GPU on an unchanging frame, not to shave the last frame off.
constexpr int kStaticRenderEvery = 4;
/// How many frames one change to the gate's signature is worth (see `tick()` and
/// `App::render_open`). More than one because the figure lays itself out *per render* and its left
/// gutter is a monotone floor that takes two passes to converge (the `layout over N frames`
/// reading): a re-framed frozen view drawn once would sit a pass short of its final gutter until
/// the floor above fired. Three leaves a pass in hand without being a frame budget.
constexpr int kSettleRenders = 3;
/// Below this window width the sidebar has no room beside the plot, and the split view overlays it /// Below this window width the sidebar has no room beside the plot, and the split view overlays it
/// instead of squeezing it. See `tick()`. /// instead of squeezing it. See `tick()`.
constexpr int kSidebarRoom = 900; constexpr int kSidebarRoom = 900;
+7 -2
View File
@@ -2030,11 +2030,16 @@ void pause_probe_report(App& a, const char* what) {
// The bar's own string, because "how long have I been paused" is a *status* field and the // The bar's own string, because "how long have I been paused" is a *status* field and the
// elapsed figure is the one part of it that no other line can show: the exit dump's // elapsed figure is the one part of it that no other line can show: the exit dump's
// `status bar:` is read live and the two `mode ...` lines are the live and triggered states. // `status bar:` is read live and the two `mode ...` lines are the live and triggered states.
// `skips` is the render gate (see the end of `tick()`), cumulative over the session: it must be
// 0 on the `live` report and have grown by the `paused` one, which is the gate being a number
// rather than a claim. A live view that skips frames is the gate's condition inverted; a paused
// view that skips none is the gate not engaging at all.
std::fprintf(stderr, std::fprintf(stderr,
"[pause-probe] %-9s paused=%d ring_produced=%zu event_size=%zu " "[pause-probe] %-9s paused=%d ring_produced=%zu event_size=%zu "
"frozen_x=[%.6f..%.6f] axis=[%.6f..%.6f] glance=[%s]\n", "frozen_x=[%.6f..%.6f] axis=[%.6f..%.6f] skips=%d glance=[%s]\n",
what, a.model->paused() ? 1 : 0, ring_produced, event_size, bounds_lo, bounds_hi, what, a.model->paused() ? 1 : 0, ring_produced, event_size, bounds_lo, bounds_hi,
double(axis.min), double(axis.max), a.dbg_glance_last.c_str()); double(axis.min), double(axis.max), a.dbg_render_skips,
a.dbg_glance_last.c_str());
std::fflush(stderr); std::fflush(stderr);
} }