Skip to content

Gate Plotter.compute on active-consumer interest#944

Closed
SimonHeybrock wants to merge 6 commits into
mainfrom
lazy-compute-on-active-tabs
Closed

Gate Plotter.compute on active-consumer interest#944
SimonHeybrock wants to merge 6 commits into
mainfrom
lazy-compute-on-active-tabs

Conversation

@SimonHeybrock

@SimonHeybrock SimonHeybrock commented May 26, 2026

Copy link
Copy Markdown
Member

Motivation

Plotter.compute() runs on the background ingestion thread for every layer on every Kafka delta — regardless of whether the layer's grid tab is visible, and regardless of whether any browser is even connected. On main this is ~5-8 ms per plot at N=100k (extract + autoscaler + hv.Curve build), scaling linearly in plot count, on a thread shared with JobOrchestrator and stale-session cleanup. This is the "even with no browser, the dashboard gets laggy" floor identified in the issue #940 investigation.

This PR does not fix the dominant freeze symptom — that's the ~100 ms-per-call pipe.send on the live document, which needs downsampling (issue #940 PR #942) and/or HoloViews patch-streaming. The value here is twofold: it removes the bg-thread floor for hidden tabs / no-browser sessions, and — more importantly — it is the foundation that lets the downsample in #942 land safely. Without lazy compute, the downsample doubles per-tick cost at N=600k, and that doubled cost is paid for every hidden tab.

What changed

Plotter.compute() is now a lazy submission point: it stashes the latest input and only invokes the heavy _build step while at least one consumer has acquired interest via plotter.set_active(token, True). The 0→1 active transition rebuilds synchronously from the stashed input, so a tab switch sees fresh state on the same polling pass that creates its presenter.

Interest is tracked via opaque tokens on the plotter (not via the presenter WeakSet): presenter creation already depends on has_cached_state, so tying the two together would deadlock the first build. The _poll_for_plot_updates loop in widgets/plot_grid_tabs.py drives set_active per (grid, layer), using the SessionLayer itself as the token.

Subclasses override _build rather than compute, so the gating is inherited uniformly across LinePlotter, ImagePlotter, Overlay1DPlotter, SlicerPlotter, BaseROIRequestPlotter, and FlattenPlotter. CorrelationHistogramPlotter (a duck-typed wrapper around a renderer) forwards set_active to its renderer — the renderer's element build is gated, but the wrapper's own histogram step still runs per data tick. The histogram step is modest compared to the renderer's element build, so the asymmetry is acceptable.

Trade-offs

Cost shifts from "continuous bg-thread work for every layer" to "one-shot synchronous rebuild on tab activation". Measured tab-switch latencies (pytest-benchmark, dev container):

Scenario Median
1 LinePlotter @ N=100k activated ~3.5 ms
1 ImagePlotter @ 1024² activated ~9.0 ms
10 LinePlotter @ N=100k activated (sequential) ~36 ms

All well below the ~100 ms perceptible-freeze threshold, even at 10 busy plots on a single tab.

Notes

Tests that construct plotters directly now have to acquire an interest token before compute() to observe a build — production semantics are no longer hidden behind a test-only eager-default mode. The migration is mechanical (plotter.set_active(object(), True) in fixtures / before inline compute() calls) and improves the parity between tests and production behaviour.

A bug in test_overlay_plotter_compute (passing PlotParams3d to Overlay1DPlotter.from_params, which wants PlotParams1d) is fixed as a drive-by.

Test plan

  • Existing dashboard test suite passes (2841+ passed, 42 pre-existing skips). Pre-existing Panel-deprecation failures under `tests/dashboard/widgets/` and `flatten_plotter_test.py::TestMakeFlattenParams` unchanged from `main`.
  • 18 new unit tests covering managed-mode transitions, multi-token activity, plotter replacement, and the tabs-widget helpers.
  • Tab-switch latency benchmarks (numbers above).
  • Manual smoke: run dashboard against `dummy` instrument with several timeseries plots in multiple grid tabs; confirm hidden-tab plots stop computing and tab switches still show fresh state.

🤖 Generated with Claude Code

Plotter.compute() used to run unconditionally on the background ingestion
thread for every layer on every Kafka delta, regardless of whether any
tab was showing the layer or whether a browser was even connected. For
long-running, data-heavy plots (timeseries at N=600k, 1k x 1k detector
images) this is O(N) per-tick work on a thread shared with JobOrchestrator
and stale-session cleanup — the hard floor identified in #940.

Split Plotter.compute into a lazy entry point + a _build override point.
In managed mode the heavy _build only runs while at least one consumer
has acquired interest via set_active(token, True); otherwise the latest
input is stashed and a dirty flag is set. The 0→1 transition rebuilds
synchronously, so the same polling pass that flips a tab to active sees
the fresh cached state.

Interest is tracked via opaque tokens on the plotter rather than via
presenters, because presenter creation already depends on has_cached_state
and tying the two together would deadlock the first build.

Plotters constructed outside the orchestrator (unit tests, scripts) stay
eager by default. The orchestrator calls set_active(self, False) at
registration time to switch production plotters into managed mode, so the
gating is in effect from the moment data starts flowing — including the
no-browser case.

Tab-switch latency microbenchmarks: 4.4 ms for one LinePlotter @ N=100k,
9 ms for one ImagePlotter @ 1024², 43 ms for ten LinePlotters @ N=100k.
Well below the perceptible-freeze threshold for realistic plot counts.
- Drop _managed dual-mode. Compute now always gates on active tokens.
  Tests construct plotters directly and must acquire an interest token
  (set_active(object(), True)) before compute() to observe a build. Removes
  ambient-mode coupling where production semantics depended on whether an
  unrelated method had been called first.
- Lock _pending/_dirty writes in compute() so the threading model no longer
  depends on GIL ordering being load-bearing across separate statements.
- Drop hasattr(plotter, 'set_active') hedges. All registered plotters now
  respond to the interest knob; new plotters that don't will fail fast.
- ROI _build returns None (drop speculative return primary).
- Log exceptions from default _build instead of silently substituting an
  Error text element.
- Fix the broken test_overlay_plotter_compute fixture (was passing
  PlotParams3d to Overlay1DPlotter.from_params which expects PlotParams1d).
The wrapper duck-typed Plotter but skipped the gating, so compute()'s
histogram step ran eagerly per Kafka delta even when no consumer was
watching the renderer. Mirror the same lazy machinery: stash extracted
data + renderer's active-token set as the gate, deferring the histogram
build until set_active(token, True) flips the renderer from inactive to
active.

Validation (presence of primary + axis roles) stays eager so bad input
still raises synchronously at the orchestrator; only the heavy histogram
+ renderer build is deferred. The stale plan-doc reference in the
set_active docstring is gone as a result.
The inline release of the previous plotter duplicated
_release_plotter_interest. Calling it directly keeps the release path
unified across orphan cleanup, shutdown, and plotter replacement.
Mirroring the full lazy machinery on a non-Plotter wrapper added more
code than the gain justified — the histogram step here is modest
compared with the renderer's element build, which is what set_active on
the wrapper already defers via the renderer.

Restores the previous forwarder shape; just refreshes the docstring to
no longer reference a plan doc that doesn't exist in tree.
The two static helpers on PlotGridTabs (sync/release plotter interest)
operate entirely on SessionLayer.active_plotter and don't touch any
tabs-widget state — they're @staticmethod precisely because they don't
need self. Make them public methods on SessionLayer, the object whose
state they mutate. The tabs widget calls them directly.

Drops the lazy_compute_widget_test.py file that was testing private
PlotGridTabs methods; equivalent coverage now lives in
session_layer_test.py against the public SessionLayer API.
@SimonHeybrock
SimonHeybrock deleted the lazy-compute-on-active-tabs branch May 27, 2026 07:27
SimonHeybrock added a commit that referenced this pull request Jun 3, 2026
Alternative shape to #944: same lazy-compute semantics, but the gate lives
on LayerStateMachine instead of inside Plotter. Plotter stays a pure
compute artifact -- no set_active, no compute/_build split, no internal
locking. Every Plotter subclass, static/correlation special-cases, and
~30 plotter test fixtures are left untouched.

LayerStateMachine gains a token set, pending input stash, and gate lock.
stash_pending() and set_active() return a ComputeTask when a build should
run now; callers (PlotOrchestrator._run_compute and the new
activate_layer entry point) invoke the task outside any lock. The 0->1
token transition flushes synchronously on the polling thread so the
same poll pass sees fresh has_cached_state. Plotter replacement via
job_started clears the stash so old input cannot be flushed through a
new plotter.

The polling loop in plot_grid_tabs now drives
orchestrator.activate_layer(layer_id, session_layer, is_active) once
per (grid, layer); orphan and shutdown paths release tokens.

Test migration is one helper update (add_cell_with_layer activates the
new layer) plus two inline activate_layer calls in tests that bypass the
helper. Plotter unit tests are unchanged. 8 new gate tests cover
stash-without-token, 0->1 flush, intermediate-update collapse,
multi-token ref counting, plotter replacement, and pre-job_started
stash behaviour.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant