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

+45 -1
View File
@@ -3706,7 +3706,51 @@ gboolean tick(gpointer user) {
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
// 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
+29
View File
@@ -503,6 +503,23 @@ struct App {
/// 0 until the first event of the session.
gint64 last_latch_us = 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
/// 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
@@ -1021,6 +1038,18 @@ struct RowMenuProbeState {
};
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
/// instead of squeezing it. See `tick()`.
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
// 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.
// `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,
"[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,
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);
}