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:
1 parent
84c158ff21
commit
be71a1d4bb
5 files changed
+130
-4
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user