FE-1552: Run range sweeps on the GPU and rebuild the sweep drawer's streaming and rendering - #9454
Draft
kube wants to merge 26 commits into
Draft
FE-1552: Run range sweeps on the GPU and rebuild the sweep drawer's streaming and rendering#9454kube wants to merge 26 commits into
kube wants to merge 26 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
…r draws The shader gains an optional per-run f32 parameter buffer (binding 4): parameters a sweep varies read a hoisted per-run value instead of a baked literal, everything else stays inlined. The adapter accepts requests whose runs carry only numeric parameter values, and the sweep host routes range batches through the normal backend walk instead of forcing a CPU pool. Fixes a latent CPU bug the GPU work exposed: per-run draws are keyed by scenario parameter identifier, but simulations read net parameters, so a draw only took effect when the two names collided. Each run's draws now re-evaluate the scenario's parameter overrides (initial state still holds at the range midpoint), so both backends receive net-keyed values. Also raises the seeded-RNG tail test's timeout: 100k draws take ~5s alone and longer under suite parallelism, past vitest's 5s default.
A selection change empties the record's frames until the new selection's first batch arrives, and the drawer unmounted its whole Metrics section on empty frames — every chart flashed white per slider step. The section now mounts once metric specs exist, renders stable shells per metric before any data, and holds the last non-empty frames across the gap; the restreamGhost prop picks the treatment (dim, hold, or off). The drawer gains its first story: a fake sweep whose selection changes clear and restream frames the way the real session does, with the ghost variant as a control for choosing between the three.
Three hot spots from profiling the streaming path, all identity-cached or coalesced with no behaviour change: - The session re-merged every cached frame against every in-flight frame on both the metrics and the progress store tick of every message wave; the merge is now cached by the two arrays' identities, and live publishes coalesce behind a 40 ms leading-edge throttle (terminal publishes stay immediate; the trailing tick is cancelled at batch end so folded frames are never double-merged). - latestFramesById is memoized by frames identity, so progress-only publishes stop rebuilding it. - The heatmap plugin reuses its rasterized cells when uPlot redraws without new frames or a size change. - The provider additionally exposes a stable actions-only context, and the editor view subscribes to that instead of re-rendering with every streamed frame batch.
The surface repainted fully per resolved sample: brute-force IDW over all samples (O(raster × samples), quadratic over a walk), 6,400 fillRects each behind an rgb() string parse, ten marching-squares passes, and a canvas backing-store reallocation — and a walk over a cached slice resolved its cells back to back, repainting up to 121 times with no new picture. Now: interpolation folds only new samples into a persistent accumulator (identical output, ~60x fewer distance evaluations over a walk); the filled bands blit one raster-resolution image through a Blues lookup table; the field image and iso-line segments are cached per accumulator version, so a markers-only change never recomputes them; paints coalesce to one per animation frame; the backing store reallocates only on resize; and the surface walks flush resolved cells once per frame instead of one React commit each.
The histogram buffer is cumulative and each frame's bins are final once its dispatch retires, so after every chunk the runner copies that chunk's frame range into a reused readback buffer, decodes it, and hands the frames to an onFrames callback; the handle appends them to its metrics store, so GPU experiments stream distributions the way CPU experiments do instead of delivering everything at completion. The final full decode stays authoritative (it trims trailing empty frames) and replaces the streamed state; frames every metric sampled zero runs in are not streamed. The chunk and final decoders share one implementation.
Four structural divergences, each mirrored on the CPU code it now matches: - Firing acceptance tested the hazard over the transition's accumulated idle window with a draw committed only on fire; the CPU (since FE-1499) tests a memoryless per-frame Bernoulli over dt and consumes the draw every enabled frame. The shader now does the same; the per-transition elapsed counters this leaves dead are removed from the run state (state shrinks by one word per transition). - The histogram binned the post-step state at the step's own index and never sampled the initial state, so every frame compared one step off the CPU's. Decoded frames now carry frame f+1, and the host emits frame 0 itself from the exact initial marking. - A run was sampled in the frame it deadlocked or completed; the CPU's 'active' sampling excludes it. The histogram add now requires the run to still be active after the step. - frameLimit used plain rounding where the CPU snaps within an epsilon and otherwise ceils; both now share getMaxFrameNumber. Tests that pinned the old acceptance semantics cited CPU behaviour that FE-1499 had already changed; they now pin the corrected parity. Stale comments (the RNG-irreproducibility rationale, the 'no kernels on GPU' notes) are brought up to date.
- The drawer header carries the scenario, run budget, and dt instead of a static description. - The summary is a three-column grid without the debug-grade Frame stat; Errors appears only when a run actually errored; the progress bar sits close under its numbers. - A section whose body lives in its sticky band (the parameter navigator) no longer renders an empty content wrapper's ~20px of dead space. - The range slider tracks the same flexible column as the point slider, and the sampling status aligns under the sliders instead of the labels. - The backend badge tooltip and the Surface section tooltip say their piece in one sentence each; 'click to navigate' lives only in the surface caption. - docs/experiments.md follows the summary changes and documents the restream hold.
Run seeding wrote each run's place counts and RNG word and left every other state word zero — including the token slots, so a typed net's runs all started with every attribute at zero. On the new Drone Patrol example that meant no drone ever launched (launch rate scales with battery): 96.6% mean divergence from the CPU, against 2.6% once seeded. The shader now exposes its per-place token offsets and strides, the handle encodes the initial marking's token records into that layout (reals bitcast from f32, then discretes), and the seeder copies them into every run.
Café Queue (uncoloured service system) and Drone Patrol (typed fleet with capacities, kernels, dynamics, and token-reading rates) pass every GPU gate as shipped — pinned by compilation-report tests — and expose their rates as scenario parameters, so range sweeps run on the GPU out of the box. Registered in the examples index, the editor's Load-example menu, the HIR coverage gate, and the user guide; the AI docs summary's example list is brought up to date (it was missing Supply Chain Profit and named a nonexistent example). Dev / GpuParity is the committed form of the CPU-vs-GPU comparison the performance notes' parity table was measured with by hand: it runs one experiment request through both backends and reports per-frame mean differences and a final-distribution KS statistic, logging JSON for scripts. Measured on Apple silicon at 2,000 runs x 300 frames: SIR 0.22% mean difference (KS 0.019), Café Queue 1.3% (KS 0.014), Drone Patrol 2.6% (KS 0.016) — all within sampling noise at this run count.
… performance notes The parity narrative claimed both engines test the per-frame hazard over dt; that was true of the two CPU engines since FE-1499 but not of the shader until FE-1553. The added parenthesis places the historical 21% divergence figure and points at the committed Dev / GpuParity harness.
From the adversarial review pass, all verified against the code: - translateRangeRuns emitted a per-run key set diffed run by run, so a run whose draw compiled back to the batch midpoint (integer axes round onto it) carried fewer keys than its siblings. Keyed off run 0, the GPU handle then silently ran the whole batch at the midpoint literals; on a later batch the mismatch refusal hard-failed the sweep because the backend choice is pinned after batch 1. Runs now carry one uniform key set — the union of every name any run changed, base values filled in — and deriveRunParameters validates every run and unions ids itself. - An initial marking larger than a typed place's capacity would have been seeded past the place's slots into neighbouring state; the GPU handle now refuses it (the CPU still runs such nets). - Held ghost frames and size choices leaked across experiments when the drawer swapped records; ExperimentMetrics is keyed by experiment id. - The surface's background sampling could race the navigator's first batch through the backend-selection walk; background batches now use the single-worker CPU lane until a backend is chosen. - The parity harness treated error/cancelled terminals as results and never cancelled in-flight runs on remount; both fixed. - The rewritten RNG comment misattributed the pre-FE-1499 cycle collapse to the fixed LCG; the PCG choice is now stated as deliberate, and the user docs no longer claim WebGPU cannot reproduce the CPU generator.
kube
force-pushed
the
claude/fe-1552-sweep-gpu-streaming
branch
from
August 30, 2026 13:08
577d57a to
93a9cd0
Compare
…gets Two latencies stood between moving a slider and seeing data: - Every batch request re-lowered the whole net in the language worker — a sweep does this per rung per selection, and it dwarfed the GPU's own cost. The artifacts are a pure function of the experiment's frozen snapshot, so the provider now memoizes them per experiment (failures stay retryable). - Dispatches advanced a fixed 300 frames, so on a long simulation the first streamed frames waited for half a typical run. The per-dispatch frame count is now a config value and the runner ramps it (32, 64, 128, ... up to the configured chunk), putting the first columns on screen in milliseconds for at most three extra dispatch round-trips. The refinement ladder already climbs past 1,000 progressively (5,000, 10,000, 50,000, ...): the create drawer now labels the budget 'Max runs per selection' when sweeping, and the docs say large budgets are the point on the GPU. Measured: a 100,000-run two-parameter range sweep fully samples in 3.1 s on Apple silicon, streaming throughout.
…ng-aware state gate
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🌟 What is the purpose of this PR?
Range sweeps run on the WebGPU backend, distributions stream from both backends without ever blanking the charts, and the sweep drawer's streaming, contour rendering, and layout are rebuilt for speed and readability. Along the way the GPU backend's stochastic semantics are aligned to the CPU engine's and verified by a committed parity harness. Stacked on #9450.
🔗 Related links
🔍 What does this change?
restreamGhostpicks dim (default), hold, or off, and the drawer's first story (Simulate / ViewExperimentDrawer → SweepRestream) exists to compare them.latestFramesByIdis memoized; the heatmap plugin reuses its raster when uPlot redraws without new data; an actions-only context stops the editor view re-rendering per streamed batch.fillRects, the field is cached so marker-only changes are near-free, paints coalesce per animation frame, and walk results flush once per frame.Dev / GpuParityruns one request through both backends and reports mean-difference and KS statistics, logging JSON for scripts.capacity + 1when every sampled place has a hard count ceiling. This quadruples the count range for population models, removes the ≥17-metric pipeline failure, and shrinks the per-frame histogram loops for capacity-bounded models. Parity statistics and timings are unchanged within noise; the SIR parity run now executes at 1,024 bins. Documented as an arch-docs page (GPU histogram sizing) with a D2 diagram.GPU run tiling) with a D2 diagram.prepareScenarioCompilerhoists the value-independent half of scenario compilation (type checks, contexts, defaults) out of the per-call path;compileParameterValues/compileParameterNumbersskip the initial state and the string conversion per-run translation never uses; per-run values travel as one run-majorFloat64Array(ExperimentRequest.runPlan) that the GPU uploads directly and the CPU expands at the worker-pool boundary — a randomized test pins the plan's output exactly equal to the record form's, which remains as the fallback for non-numeric values; and the remaining draw/translation loops yield cooperatively on an 8 ms budget. Documented in theParameter sweepsarch-docs page ("What a batch costs on the main thread").lo + bin × stride) carried as shader uniforms; the device tracks each metric's observed min/max and counts both-end escapes, and any escape recalibrates the window and re-runs (deterministic seeds make one re-run exact). Large runs with unknown ranges probe first with a small run prefix whose frames stream to the charts. The "N samples were clamped" warnings and the initial-count refusal are gone — a 1,030-token SIR now runs on GPU with correct values. Arch-docs pageGPU histogram sizingrewritten with the calibration loop.GPU capacity calibrationwith a D2 diagram. With this plus forwarded kernel tokens and a tiling-aware state gate (4 KB → 1 MB per run), 7 of 8 bundled examples are fully GPU-ready — the last, Production Machines, consumes typed tokens from two places (cross-product enumeration, the weight > 2 family), pinned by an acceptance-matrix test.Components / ContourSurface → Restartingdemonstrates it), and the surface samples four cells concurrently from one coarse-to-fine queue — an 11×11 fill went from ~15 s to ~4 s. Documented in theThe sweep surfacearch-docs page.histogramTopBinSaturatesnow records which case the shader is in), progress/time sawtoothing across tiles (now a monotone overall fraction), a cancel at a tile boundary decoding the previous tile's summary as the new tile's runs, the handle retaining a second copy of every streamed frame (now a key set, with the merge done at the converted level), a stale "Try fewer runs" allocation message, and five stale doc/comment claims.Measured (Apple silicon, 2,000 runs × 300 frames):
KS resolution at 2,000 runs is ≈ 0.03, so all three agree within sampling noise.
Pre-Merge Checklist 🚀
🚢 Has this modified a publishable library?
This PR:
📜 Does this require a change to the docs?
The changes in this PR:
🕸️ Does this require a change to the Turbo Graph?
The changes in this PR:
restreamGhostdefault is "dim"; the SweepRestream story exists to pick between dim/hold/off before this merges.🐾 Next steps
🛡 What tests cover this?
sweep-run-overrides.test.ts(uniform key sets, midpoint-drawing first run, direct-name passthrough), per-run shader emission tests incompile-net-shader.test.ts, gpu-ready pins for both examples incompilation-report.test.ts, both examples in the HIR coverage gate,histogramBinCountsizing tests (budget division, capacity ceilings, compiled-shader pins) andrunsPerTile/describeBufferOverflowtiling tests inrunner.test.ts.@hashintel/petrinaut(300+) and@hashintel/petrinaut-core(1,100+) unit suites.Dev / GpuParityon real hardware (numbers above); the RealGpuSweep story runs a two-parameter range sweep end-to-end on the GPU.❓ How to test this?
yarn workspace @hashintel/petrinaut storybookrestreamGhostcontrol to compare hold/off.runCount:100000on the Real GPU sweep story args: the distribution refines 8 → 25 → … → 100,000 in a few seconds, visibly denoising.📹 Demo
Screenshots pending upload (before/after captures handed over separately).
🤖 Generated with Claude Code