Skip to content

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
claude/fe-1551-heatmap-magma-rasterfrom
claude/fe-1552-sweep-gpu-streaming
Draft

FE-1552: Run range sweeps on the GPU and rebuild the sweep drawer's streaming and rendering#9454
kube wants to merge 26 commits into
claude/fe-1551-heatmap-magma-rasterfrom
claude/fe-1552-sweep-gpu-streaming

Conversation

@kube

@kube kube commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

🌟 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

  • FE-1552 (internal)
  • FE-1553 (internal — carries the full capability analysis)

🔍 What does this change?

  • GPU range sweeps. The shader gains an optional per-run f32 parameter buffer: parameters a sweep varies read a per-run value, the rest stay baked literals. The sweep host routes range batches through the normal backend walk instead of forcing a CPU pool. This exposed a latent CPU bug — per-run draws are keyed by scenario parameter identifier while simulations read net parameters, so a draw only took effect when the names collided; each run's draws now re-evaluate the scenario's parameter overrides (initial state still holds at the range midpoint), with one uniform key set per batch so backends can lay values out in one buffer.
  • GPU fidelity. The shader now matches the CPU engine's rules: a memoryless per-frame firing test over dt with the draw consumed whether or not the transition fires (the old accumulated-idle-window test structurally diverged on intermittently enabled nets), metric frames aligned to the CPU's numbering with the initial state as frame 0, runs excluded from the frame they finish in, shared frame-count rounding — and typed places' initial token attributes are actually seeded (they were silently zeroed: 96.6% divergence on a battery-driven model, 2.6% after).
  • GPU metric streaming. After every dispatch chunk the runner reads back that chunk's histogram range and streams the frames, so GPU experiments fill charts live; the final decode stays authoritative.
  • Never a blank chart. A sweep selection change empties the record's frames until the new selection streams; the drawer previously unmounted its whole Metrics section. It now renders stable shells per metric before any data, and each plot snapshots its previous picture into an overlay on a selection change: the snapshot persists through the compute gap (dimmed by default) and fades out over 300 ms while the new selection's first frames draw in underneath. restreamGhost picks dim (default), hold, or off, and the drawer's first story (Simulate / ViewExperimentDrawer → SweepRestream) exists to compare them.
  • Streaming cost. The sweep session's full re-merge per store tick is identity-cached and live publishes coalesce behind a 40 ms leading-edge throttle; latestFramesById is 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.
  • Contour surface. Interpolation folds only new samples into a persistent accumulator (identical output, ~60× fewer distance evaluations over a walk), filled bands blit one image through a LUT instead of 6,400 fillRects, the field is cached so marker-only changes are near-free, paints coalesce per animation frame, and walk results flush once per frame.
  • Drawer UX. Data-carrying header, three-column summary without the debug Frame stat, no dead space under the parameter strip, aligned slider rows and status line, one-sentence tooltips.
  • Examples + parity. Café Queue (uncoloured) and Drone Patrol (typed: capacities, kernels, dynamics, token-reading rates) pass every GPU gate as shipped, pinned by tests, with sweepable scenario parameters. Dev / GpuParity runs one request through both backends and reports mean-difference and KS statistics, logging JSON for scripts.
  • Instant feedback + large budgets. HIR artifacts are memoized per experiment (the language worker re-lowered the whole net per rung per selection — the dominant latency between a slider move and new frames), and GPU dispatches ramp from 32 frames up to the configured chunk so the first columns stream in milliseconds on long runs. The refinement ladder already climbs past 1,000 (5,000, 10,000, …); the create drawer labels the budget "Max runs per selection" when sweeping. Measured: a 100,000-run two-parameter GPU range sweep fully samples in 3.1 s, streaming throughout.
  • Adaptive histogram bins. The bin count is computed per shader instead of a fixed 256: up to 1,024 bins for up to four metrics (the 16 KB baseline workgroup budget divides among metrics), shrinking to capacity + 1 when 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.
  • Run tiling. Experiments larger than the device's buffer ceiling or dispatch width run as sequential tiles over one shared histogram buffer (bins are sums, so the merge is free); per-run seeds derive from absolute indices, so a tiled run is bit-identical to an untiled one — verified on-device (8 tiles vs 1, equal frames/counts/statuses, duplicate-free streaming through the handle). Only two shapes still refuse: one run larger than the buffer ceiling, and a histogram that exceeds it. The RealGpuSweep story's run-count control now goes to 10,000,000; measured, a 10M-run two-parameter GPU sweep fully samples in ~32 s. Documented as an arch-docs page (GPU run tiling) with a D2 diagram.
  • Main-thread batch pipeline. Profiled at a 1,000,000-run range batch, the two longest main-thread tasks (1.16 s and 1.45 s) were batch instantiation — dominated by compiling the scenario once per run's draws. Four commits take total blocking from 3,007 ms to 178 ms (worst task 1,447 ms → 128 ms) and the 10M-run sweep from ~32 s to ~12 s: prepareScenarioCompiler hoists the value-independent half of scenario compilation (type checks, contexts, defaults) out of the per-call path; compileParameterValues/compileParameterNumbers skip the initial state and the string conversion per-run translation never uses; per-run values travel as one run-major Float64Array (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 the Parameter sweeps arch-docs page ("What a batch costs on the main thread").
  • Self-calibrating histograms. Bins map through a per-metric window (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 page GPU histogram sizing rewritten with the calibration loop.
  • Derived token capacities. Typed places without declared capacities no longer refuse: a creation-time probe (few runs, generous slabs, quadrupling on overflow under a 128 MB probe budget) measures per-run maxima, sizes each slab at 1.5× the observed max, and the full run detects mid-run overflow (exact post-fold check, status 3) and grows-and-re-runs. Heavy-tailed nets (outlier ≫ typical and an oversized slab) fall back to the CPU with the planned per-run token arena named as the follow-up. Derived bounds are sizing-only — they never block a firing the CPU would allow (declared capacities keep blocking semantics). Verified bit-identical to a declared-capacity run on Drone Patrol. New arch-docs page GPU capacity calibration with 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.
  • Contour surface. A walk restart keeps the previous picture dimmed (45%) until three fresh samples exist instead of flashing blank (Components / ContourSurface → Restarting demonstrates 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 the The sweep surface arch-docs page.
  • Review fixes. A 14-agent adversarial review pass confirmed and led to fixes for: ragged per-run key sets (silent midpoint-only batches / mid-sweep hard failures), capacity-overflow seeding, ghost frames leaking across experiments, a background-sampling race through the backend walk, parity-harness error handling and cancellation, and two misattributed comments. A second 51-agent pass over the bins/tiling commits confirmed and led to fixes for: a false "clamped, run on the CPU" warning on capacity-sized histograms (the top bin holds an exact, reachable count — histogramTopBinSaturates now 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):

model mean |Δmean| max final KS CPU GPU
SIR 0.22% 0.58% 0.019 722 ms 10 ms
Café Queue 1.29% 5.68% 0.014 605 ms 8 ms
Drone Patrol 2.55% 6.97% 0.016 900 ms 16 ms

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:

  • modifies an npm-publishable library and I have added a changeset file(s)

📜 Does this require a change to the docs?

The changes in this PR:

  • require changes to docs which are made as part of this PR

🕸️ Does this require a change to the Turbo Graph?

The changes in this PR:

  • do not affect the execution graph

⚠️ Known issues

  • The restreamGhost default is "dim"; the SweepRestream story exists to pick between dim/hold/off before this merges.
  • GPU wall times in the parity table exclude shader compilation (the harness measures the run, matching how batches amortize the pipeline).

🐾 Next steps

  • FE-1553 carries the capability analysis for the remaining GPU gates (device-side time aggregations, expression metrics, string/uuid interning, C(n,3)+ unranking, structural bounds for bin sizing and buffer sizing); adaptive histogram bins and run tiling from that analysis ship in this PR.

🛡 What tests cover this?

  • New: sweep-run-overrides.test.ts (uniform key sets, midpoint-drawing first run, direct-name passthrough), per-run shader emission tests in compile-net-shader.test.ts, gpu-ready pins for both examples in compilation-report.test.ts, both examples in the HIR coverage gate, histogramBinCount sizing tests (budget division, capacity ceilings, compiled-shader pins) and runsPerTile/describeBufferOverflow tiling tests in runner.test.ts.
  • Updated: the shader tests that pinned the old acceptance semantics now pin CPU parity; eligibility state-size expectations follow the elapsed-counter removal.
  • Existing @hashintel/petrinaut (300+) and @hashintel/petrinaut-core (1,100+) unit suites.
  • Manual: Dev / GpuParity on real hardware (numbers above); the RealGpuSweep story runs a two-parameter range sweep end-to-end on the GPU.

❓ How to test this?

  1. yarn workspace @hashintel/petrinaut storybook
  2. Simulate / SweepNavigator → Real GPU sweep: the caption reads "Batches ran on the GPU" with both parameters on Range.
  3. Simulate / ViewExperimentDrawer → SweepRestream: drag a slider; the charts hold the previous picture dimmed through the compute gap, then fade it out as the new stream draws in. Switch the restreamGhost control to compare hold/off.
  4. Dev / GpuParity: wait for the table; all KS values ≤ ~0.03 at 2,000 runs.
  5. Set runCount:100000 on the Real GPU sweep story args: the distribution refines 8 → 25 → … → 100,000 in a few seconds, visibly denoising.
  6. Load the Café Queue or Drone Patrol example from the hamburger menu, create an experiment with Run on GPU, and sweep the scenario parameters.

📹 Demo

Screenshots pending upload (before/after captures handed over separately).

🤖 Generated with Claude Code

@vercel

vercel Bot commented Aug 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hash Ready Ready Preview Aug 31, 2026 2:31am
petrinaut Ready Ready Preview Aug 31, 2026 2:31am
petrinaut-docs Ready Ready Preview Aug 31, 2026 2:31am
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
hashdotdesign-tokens Ignored Ignored Preview Aug 31, 2026 2:31am

Request Review

@github-actions github-actions Bot added area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > frontend Owned by the @frontend team type/eng > backend Owned by the @backend team area/apps > hash.design Affects the `hash.design` design site (app) labels Aug 30, 2026
kube added 11 commits August 30, 2026 13:08
…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.
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/apps > hash.design Affects the `hash.design` design site (app) area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > backend Owned by the @backend team type/eng > frontend Owned by the @frontend team

Development

Successfully merging this pull request may close these issues.

1 participant