Skip to content

Shared-context compositing: remove the ~12 live-chart ceiling (one GPU context per page) #407

Description

@Alek99

Summary

Browsers cap live WebGL contexts per page. xy already virtualizes around that cap with the context governor (§18), so chart count is unbounded — but the number of simultaneously live charts is not. Above the budget, extra charts fall back to a frozen snapshot image and only revive on demand.

This issue proposes the design the dossier already records as an unimplemented option: shared-context compositing — one WebGL context per page, each chart a scene subgraph drawn into a scissored viewport. That removes the live-chart ceiling entirely and unlocks shared GPU caches across charts of the same data.

Code anchors below are pinned to d505ef57 (main at time of writing).

The current ceiling, measured

Measured on macOS (Apple Silicon), Chrome 150.0.7871.188, ANGLE/Metal:

What Result
Raw browser cap (40 bare webgl2 canvases, no xy) exactly 16 alive, 24 LRU-evicted, 0 nulls
bench_dashboard.py at 20 and 50 charts, main health governed — 0 ungoverned evictions, every chart nonblank when scrolled to
24 xy charts, all visible at once, budget 12 12 live + 12 snapshot stand-ins, 0 blank; pointerenter revives

The governor is working as designed and there is no user-visible blanking today. The point of this issue is the ceiling behind that fallback, not a bug in it.

What the governor already solves — and what it doesn't

Shipped in js/src/50_chartview.ts (XY_CONTEXT_GOVERNOR, budget 12, window.XY_CONTEXT_BUDGET overrides; cross-frame budget sharing over BroadcastChannel from #149).

Solved: unbounded chart count, no browser-side eviction, no blank charts, recoverable state, observable decisions via data-xy-ctx.

Not solved — the motivation here:

  1. Only ~12 charts are live at once. Beyond that a chart is a static image (_snapshotBeforeRelease). It does not animate, does not paint streaming updates, and does not respond to theme changes until something revives it.
  2. Real-time dashboards degrade. A governed release sets _glLost, so draw paths early-return; incoming kernel/streaming data isn't painted while released and only re-syncs on restore (_scheduleViewRequest in the restored handler). For a Reflex app pushing live data into 30 charts, only ~12 are actually live at any moment.
  3. Interaction pays a revive cost. Hovering a released chart triggers _recoverContext() → full GPU rebuild from spec + payload before it responds.
  4. GPU memory is duplicated. N charts over the same DataFrame hold N independent copies of the same vertex buffers — there is no cross-chart cache today.
  5. Cross-origin iframes can't share the budget (BroadcastChannel scope), so they fall back to per-document accounting.

Raising XY_CONTEXT_BUDGET to 16 helps marginally and steals the headroom that keeps host-page GL (maps, editors) from evicting chart contexts. OffscreenCanvas does not help: worker contexts draw from the same per-process pool. The cap cannot be lifted from JS. The only real answer is to stop needing one context per chart.

Proposal

One renderer, one GPU context per page. Each ChartView becomes a client of a shared renderer host: it owns a scene subgraph and a target rectangle, and the host owns the context, the program cache, and the GPU resource pool.

Two compositing modes from the dossier:

  • (a) Blit mode — the host draws a chart into an offscreen target, then blits into that chart's own canvas (drawImage, or transferControlToOffscreen where layout allows). Preserves today's DOM model exactly: every chart keeps its own element, z-index, scrolling container, and stacking context. Costs one copy per chart per frame. Ship this first.
  • (b) Single full-page canvas behind the DOM, charts drawn into scissored viewports — no copy, cheapest, but requires charts to share one stacking context with no DOM interleaving. Opt-in for dense grids.

Why this is tractable in this codebase

The architecture is already most of the way there, which is the main argument for attempting it:

  • 40_gl.ts is already stateless. Shaders are module-level constants and makeProgram(gl, vs, fs) takes the context as a parameter. Nothing to untangle — programs become host-owned and are compiled once for the whole page instead of once per chart.
  • ATTR_SLOTS pins attribute locations globally (40_gl.ts:22) so one VAO already serves multiple programs. The sharing discipline exists.
  • Chrome is not GL. Axes, grid, labels, legends, and overlays paint on separate 2D canvases (this.chrome, this.overlay). Only the plot rect is WebGL — compositing never has to deal with text or axis rendering.
  • There is exactly one draw seam. _drawNow() already opens with gl.bindFramebuffer(FRAMEBUFFER, null) + gl.viewport(0, 0, canvas.width, canvas.height). Substituting a host-provided target + scissored rect is a local change at a seam that already exists.
  • §27 guarantees all GPU state is a rebuildable cache derived from CPU-side f64 + spec, and that rebuild path is already exercised in production by context loss/restore (_rebuildEvictedContext). Moving resources between contexts is a solved problem here.

The bulk of the work is mechanical: 467 .gl references in 50_chartview.ts (66 of them this.gl), 39 in 45_lod.ts, 18 in 54_kernel.ts, 6 in 53_interaction.ts.

Suggested phasing

Each phase is independently reviewable and shippable.

Phase 0 — baseline + instrumentation. Add an all-visible grid scenario to benchmarks/bench_dashboard.py (today's scroll-based pass lets the governor win by construction; the interesting case is N charts visible simultaneously). Report live/released/lost per chart and a fully_live metric. This produces the before/after number everything else is measured against.

Phase 1 — introduce a GLHost; pure refactor. Host owns the context, program cache, and the shared quad/VAO. ChartView takes a host reference instead of calling canvas.getContext("webgl2") itself (_initGl, 50_chartview.ts:3585). Still one host per ChartView — zero behavior change, every existing test stays green. This is the large diff; landing it alone keeps the risky part small.

Phase 2 — explicit render targets. Replace the implicit "default framebuffer is my canvas" assumption in _drawNow and _renderPick with host.beginChart(view) / host.endChart() setting viewport + scissor. Still one context per chart; this proves the seam.

Phase 3 — many charts, one host. Page-level host in blit mode (a). The governor's budget now applies to hosts, not charts.

Phase 4 — shared GPU caches. Key trace buffers by (column identity, dtype, normalization) so charts over the same columns share buffers, extending the existing _fcId buffer-identity machinery into a host-level refcounted cache. This is where "20 charts of one 10M-row table hold the data once" comes from — a memory characteristic Plotly cannot express at all.

Phase 5 — governor as fallback. Keep it for cross-origin iframes and any host that can't share. The BroadcastChannel budget sharing still matters, just with much smaller N.

Hard parts (please don't underestimate these)

  1. GL state hygiene. Today a chart sets gl.enable(gl.BLEND) / blendFunc once in _initGl (50_chartview.ts:3611-3612) and relies on it forever. With one shared context, every chart must either fully specify the state it depends on each frame or restore what it changed. This is the classic shared-context bug source — leaked scissor, bound VAO, active texture unit, blend func. Worth a debug-mode assertion that state matches a known baseline at endChart().
  2. Pick target. _initPickTarget allocates a texture sized to that chart's backing store, and the pick pass is lazily rendered and cached via _pickDirty. Sharing one host-sized pick target with scissor is better for memory but has to preserve that laziness.
  3. The context becomes a single point of failure. Today one lost context blanks one chart; shared, it blanks the page. The host must orchestrate rebuild across every client. Must not regress switching tabs and switching back loses all chart view settings #156tests/test_context_loss_preserves_view.py (per-chart pan/zoom preserved across loss) has to stay green.
  4. Heterogeneous chart sizes and DPR. The host target must cover the largest chart's backing store, or the blit has to scale. Simplest correct approach: size the host FBO to the largest chart, draw one chart at a time, blit immediately.
  5. Frame scheduling. Charts currently RAF independently. A host should coalesce into one RAF over dirty charts (a perf win), but view._drawNow() must keep working synchronously for a single chart — tests, benchmarks, and browser probes all call it and read pixels on the next line.
  6. Snapshot and export paths. _snapshotBeforeRelease reads the default framebuffer with readPixels (50_chartview.ts:1973); under sharing it reads a host sub-rect. Check the static export path and scripts/render_smoke_nonumpy.py too.
  7. Blit cost must be measured, not assumed. One drawImage per chart per frame at 50 charts may or may not beat today's 12-live-contexts steady-state redraw. Phase 0's baseline is what decides whether mode (a) is acceptable or mode (b) is required for dense grids.

Acceptance criteria

  • A page with 50 simultaneously visible xy charts has all 50 painting live: no data-xy-ctx="released", no snapshot stand-ins, bench_dashboard.py reports render_status: "complete" and fully_nonblank: true on the all-visible scenario.
  • Streaming updates paint in every chart on the page, not just the ~12 that hold contexts.
  • Steady-state redraw p95 across 50 live charts stays within an agreed factor of the Phase 0 baseline (set the number in Phase 0; don't hand-wave it).
  • Context-loss recovery still restores every chart to its settled pan/zoom (switching tabs and switching back loses all chart view settings #156 regression test green).
  • Phase 4: measurable GPU/JS-heap reduction for N charts over one shared DataFrame vs today.
  • Full suite green, including tests/test_context_loss_preserves_view.py, scripts/render_smoke_nonumpy.py, scripts/abi_smoke.py.
  • spec/design-dossier.md §18 updated — the "Unimplemented design option" section becomes the shipped design (repo rule: a change is incomplete while its spec is stale).

Repro / verification commands

# Governor behavior today (scroll-based; reports health "governed")
PYTHONPATH=python .venv/bin/python benchmarks/bench_dashboard.py \
  --chart-counts 10,20,50 --chromium "<chrome>" --json dashboard.json
python scripts/verify_benchmark_report.py dashboard.json --kind dashboard-browser

Raw cap check (any browser, no xy involved) — create 40 canvases, call getContext("webgl2") on each, wait a task, then count contexts where !gl.isContextLost(). Chrome 150 returns 16.

Per-chart state is observable at any time via data-xy-ctx on each canvas (live | released | lost) and data-xy-context-state on the chart root.

Non-goals

  • Raising the browser's context cap — not possible from JS, and OffscreenCanvas shares the same pool.
  • Removing the context governor — it stays as the fallback for cross-origin iframes and any non-sharing host.
  • WebGPU migration — separate track, though a host abstraction is a prerequisite for it too.

References

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions