Pipeline compilation: createPipeline traces multi-pass programs into fused, barrier-synchronized plans - #871
Conversation
gpu.createPipeline(fn, { constants }) traces the orchestration function
once at first call against frozen Proxy handles, unrolls it into a static
plan (steps / argBindings / buffers / results per the design contract),
assigns buffers by static liveness with automatic double-buffering (the
ping-pong loop compiles to ONE kernel over two alternating slots), and
executes through per-pipeline kernel clones configured pipeline+immutable
so intermediates stay resident with a single final readback. Calls always
return a Promise and serialize on a tail; setConstants invalidates and
re-traces; destroy releases clones and is reachable from gpu.destroy via
the new pipeline registry. executorKind = 'generic' on every backend --
the webasm fused executors are phase 2.
Trace interception lives in kernel-run-shortcut (a call under an open
trace records instead of running), so user-visible kernels are never
monkey-patched. Trace violations (handle reads/arithmetic, Math.random,
foreign kernels, graphical, kernel maps, unsized kernels, bad returns)
reject the building call with named messages.
51 tests across trace-rules/correctness/buffers/lifecycle, correctness
proven against plain-JS references on cpu, webasm, and headlessgl; all
behavioral tests verified discriminating against 16 hand-applied mutations
(including GL texture-census and single-readback probes).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
Every plan step compiles to a wasm module over one shared memory laid out [pipeline args | literals | constants | plan buffers]; offsets bake per step, so the ping-pong loop lands on two instances of one kernel and intermediates never leave wasm memory between passes. Per call: one flattenTo per array argument, steps back-to-back synchronously, one readback at the end. Module assembly is reused from WebAssemblyKernel: _assembleModule takes an optional layout.totalBytes for the shared extent, and the SIMD row-span dispatch is extracted to a dispatchSpans static shared by kernel.run and the executor. Argument size/type drift recompiles the fused plan; anything the backend cannot take degrades to the generic executor with fallbackReason, exposed on the pipeline shortcut. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
…arriers Pool workers now execute the WHOLE fused plan when threads exist and the plan crosses the kernel's 4096-cell threading floor: each worker owns a contiguous cell-range slice of every step and advances step-to-step on a generation-counter barrier living in the shared wasm memory, so a pipeline call costs exactly one pool dispatch however many steps the plan unrolls to. The main thread Atomics.waitAsync-or-polls only the final generation (pinning the event loop itself, since waitAsync does not), and executorKind reports 'fused-threaded'. Failure containment: a dead worker rejects the run through the pool's die/retire machinery and an abort word releases the survivors' barriers; a barrier that can never fill without a death is bounded by a progress-based sanity timeout; pipeline.destroy() mid-run aborts the walk and rejects the in-flight call. Any threaded failure drops the executor so the next call compiles a fresh one. Benchmark (jacobi 5-point, 1024x1024, 512 passes, checksums bit-identical): fused-threaded 356ms vs fused-sync 1457ms vs generic 1789ms — 4.1x / 5.0x. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
…d first
Tracer soundness: async/generator orchestration functions throw the
named message instead of silently compiling an empty plan; handles gain
ownKeys/has/getOwnPropertyDescriptor traps so object spread and key
enumeration throw instead of reading empty; a handle cached across a
re-trace (or leaked between pipelines) throws instead of yielding {};
empty result objects throw.
Clone fidelity: plan clones inherit randomSeed, returnType, and the
types the user PINNED (a new declaredArgumentTypes captured at kernel
creation, distinct from build-inferred types), and the fused recompile
re-applies them instead of nulling; extra type-signature programs clone
from the plan's frozen clone, never the live user kernel, so a
setOutput between trace and recompile cannot bake the wrong shape.
Threaded barriers: generations are now MONOTONIC across the executor's
life -- nothing is ever reset under a laggard worker, and no ack-wait
precedes a dispatch (a silently terminated browser worker never acks;
the interim await-acks fix deadlocked exactly there). Aborts retire
every worker still owing acks -- the only place a silent browser death
is detectable -- and the abort flag clears on the next run's dispatch.
The stall backstop counts barrier arrivals as progress and defaults to
60s, so legitimately slow steps stop rejecting as wedged.
Lifecycle: gpu.destroy() awaits pipeline teardown inside its promise
(workers and shared memory are gone when it resolves); call-time
texture arguments snapshot via clone() with the clones released on
settlement -- which surfaced that clone() on a MUTABLE kernel's output
was broken backend-wide (copy-on-write only ran under immutable); the
render path now honors outstanding clone refs unconditionally.
The recycling suite's mutable-leak test now spies newTexture (the leak
signal) instead of beforeMutate (now the every-render refs check).
Every fix verified against the review's own reproduction scripts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
The pipeline test files never enumerated webgpu, so the generic executor's webgpu path (async kernel runs, async buffer-handle readback) had never executed. Every eachMode scenario now has a browser-only webgpu row pinned to executorKind 'generic', gated on GPU.isWebGPUSupported with the adapterless runtime-skip convention of test/features/webgpu, plus a row proving the fused compile declines webgpu on its own with fallbackReason, and a webgpu variant of the non-adjacent-liveness buffer test. Both awaits in _executeGeneric are proven load-bearing: removing either fails these rows on real WebGPU (headed ANGLE Metal). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
Lower compiled pipeline plans on webgpu to one command encoder: every plan step builds through WebGPUKernel's own WGSL machinery, then runs against persistent storage buffers — ping-pong steps land on static alternating bind groups, per-step params uniforms are created at compile. Per call: pipeline args and per-call scalars/seeds go up via queue.writeBuffer, every step records as a compute pass into ONE encoder, results copy to a single MAP_READ staging buffer in the same encoder, one submit, one mapAsync readback. Seeded Math.random keeps the direct-call contract (per-step draw when unpinned, baked when pinned); argument size/type drift recompiles like the webasm executor; GPU-resident handle arguments and vec intermediates degrade to the generic executor with a named fallbackReason. Also fixes call-time sampling of Input pipeline arguments: Input has a toArray(), so the texture duck-type branch in snapshotValue swallowed it before the copy (and the encoder's handle check declined it). Jacobi 512x512, 512 sweeps, one call (ANGLE Metal): generic ~118ms, fused-encoder ~11ms, identical checksums. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
Playwright driver running benchmark-pipeline.mjs's jacobi/heat workloads in headed Chromium (ANGLE Metal): per-pass pipeline:true chaining vs the generic and fused-encoder pipeline executors, checksum-gated against the plain-JS oracle, executorKind asserted, median of 5. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
Oversize pipeline arguments degrade to the generic executor with a named reason instead of resolving silent zeros -- past the device's storage-binding limit, createBuffer succeeds but the bind group fails async validation and every read maps zeros; the fused compile now runs the kernel's own size check first. An argument bound ONLY in the results never gets an arg region, so the resident-handle screens missed it: a GPU-resident handle in a result seat resolved as a deleted buffer. Result seats are now screened at compile and per call exactly like step-bound ones. An Input returned as a result resolved to the Input instance under the fused executors while the generic executor erected it to rows; both fused paths (webgpu and webasm) now unwrap toArray()-bearing result values for generic parity. All three reproduced in headed Chrome before fixing, re-verified after, and pinned by browser-gated regression tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
|
Findings from migrating the gpu.rocks benchmark suite to Correctness is not the problem — every migration passed. Several were bit-exact against the plain-JS oracle ( The blocker is the generic executor's cost on the GL backends, and it is large enough that not one of the 19 could be adopted. The regressionReal browser, Apple M1 Max, Chrome. Recorded published-2.22.0 numbers against this branch with the row migrated:
Independently, agents measured the same shape at full size on other rows: Mechanism, confirmed by counting
const settings = {
output: Array.from(kernel.output),
pipeline: true,
immutable: true,
dynamicArguments: true,
};On GL, One texture created and destroyed per plan step. The hand-rolled ping-pong these workloads previously used allocates two textures and reuses them for the life of the kernel.
Why this blocks adoption rather than merely costing somethingA workload here exposes one Branching on What would unblock itAny one of these, roughly in order of preference:
Three secondary findings
Threaded WebASM changes what a cell means. A migrated row reaching Some rows should never be migrated, and that is fine. ReproducingThe migrated workloads are kept on the gpu.rocks side rather than reverted, so the comparison can be re-run once the GL lowering changes. Each is a |
The generic executor forced immutable: true on its plan clones, which on GL allocated and destroyed one full-size texture PER PLAN STEP -- 0.99 create/delete pairs per step measured over a 200-step ping-pong, a 3-29x loss against hand-rolled two-kernel loops and an adoption blocker for GL columns (PR #871 review comment). Static liveness is what makes mutability safe: assignBuffers already guarantees no step reads a slot while that slot's writer renders. The executor now clones per (kernel, seat signature, output slot) with immutable: false and dynamicArguments: false -- each clone owns one output for the plan's life and sees one argument-type signature -- and array pipeline arguments upload ONCE per call through lazy identity kernels on backends where uploads cost (GL, webgpu). Together that is mechanically the hand-rolled upU/upQ + kA/kB pattern, generated. Measured on the comment's own instrumentation (200-step 1024^2 jacobi, headlessgl): texture churn 0.99/step -> 0.00/step; wall clock 248 ms -> 51 ms, now at parity-or-better with the hand-rolled loop (67 ms same session). Layer shares: mutable clones -35%, static types -35% more, once-per-call uploads -68% of the remainder. Static shapes need a drift story: argument size changes rebuild the generic clones (the fused executors' recompile contract), and cpu results copy on readback so a held result survives the next call. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
|
Landed as 0d5b105 — option 2, taken further than the comment asked, and measured on your own instrumentation method. The generic executor no longer forces
Checksums identical throughout. Parity-or-better with the hand-rolled loop rather than 7×-slower; the 3–29× GL cells should now be re-runnable. Static shapes gained an explicit drift story (size changes rebuild the generic clones, matching the fused executors' recompile contract) and cpu results copy on readback so a held result survives the next call. On the secondary findings: agreed on all three. Exposing the executed backend on the pipeline itself (the #868 |
The two pre-merge asks from the benchmark integration review: a pipeline can pin the webasm lowering to its sync path (threads: false) so single-threaded benchmark columns stay comparable, and pipeline.backend reports the mode of the clones that actually execute -- under degradation it says 'cpu', restoring the silent-degradation safety net suites probe on kernels. The executor already consulted _threadsDisabled; the setting now reaches it (a later re-init in the constructor was clobbering it, caught by the pin test). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
|
Re-ran everything against The mechanism is goneInstrumenting the plan's own GL context over a 200-step plan, steady state: The regression is goneWebGL2 and WebGL, published-2.22.0 baseline → branch before the fix → branch after:
Medians against the 2.22.0 baseline, across all 13:
Correctness held. No cell reported WRONG on any column of any of the 13 rows; the suite checksums every column against a plain-JS oracle at 1e-4 before timing it. One residual, and it looks like a fixed per-call costWhat is left is not proportional — it is roughly constant, so it only shows on rows that were already fast. WebGL2, sorted by how fast the row was to begin with: Median absolute delta is +5 ms for rows under 70 ms and −41 ms for rows over it. Not a blocker from our side — a few ms on a benchmark row is legible where a 29× was not — but if there is a cheap win in the per-call path, Where this leaves adoptionAdoptable now, as far as the GL columns are concerned. Before we record a saved run and publish it, two things from the earlier comment still stand and are worth a line each:
|
eager uploads pipeline.backend read plan.kernels[0].clone -- exactly the reverse-engineered path the benchmark integration warned breaks silently, and 0d5b105 had already made it stale (the mutable genericClones execute, not the plan clones). It now derives from the executor that ran: fused kinds name their backend, generic reports its writer clones' mode, and under degradation-inside-generic it says 'cpu'. The introspection surface (backend, executorKind, fallbackReason, threads: false) is documented in the README as supported API. The webasm fused executor's per-call argument check never screened GPU-resident handles -- the webgpu review's finding applied there too and a texture argument crashed flattenTo instead of degrading; it now recompiles-then-degrades with the named reason, end to end (webasm fused -> generic -> clone falls to cpu -> backend says 'cpu'). Short plans' fixed per-call cost: when the pipeline is quiescent, GL argument uploads run synchronously at call time, so the upload texture IS the call-time snapshot and the deep copy is skipped (the copy+ flatten double work is gone; overlapped calls keep the copying path). Call-time sampling semantics pinned by test on both paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
|
Both blockers are addressed as supported, documented API in 2b18318 (plus 358cdd9) — and your point about reverse-engineered accessors breaking silently was validated in the most direct way possible: the 1. Executed backend + degradation reason — supported API, README-documented:
Verified end-to-end on the nastiest chain: texture argument → webasm fused declines (this screen was missing on webasm — your report's probe found a real crash there, now recompile-then-degrade with a named reason) → generic → clone itself degrades → 2. Executor kind + sync pinning — both:
3. The fixed per-call cost — partially addressed, and I need your shape to finish it. When the pipeline is quiescent, GL argument uploads now run synchronously at call time, so the upload texture IS the call-time snapshot and the deep argument copy is skipped entirely (previously every call paid copy + flatten). On my repro (16-pass 2048², typed-array rows, headlessgl) the copy was only ~2 ms, so I cannot reproduce your +37 ms locally — which makes me suspect your sobel/canny arguments are plain nested JS arrays (slow to deep-copy) or the cost sits elsewhere in the browser path. If your 20-minute sweep still shows +30 ms on sobel/canny after this commit, the shape of those rows' Would appreciate the 13-row re-run whenever convenient — gates on this end are green (Node 2838/0, browser 4267/0, SwiftShader baseline unchanged). |
|
Re-ran the 13 rows on Medians vs published 2.22.0, all 13 migrated rows
Notable rows against the 2.22.0 baseline: No cell reported WRONG on any column of any row. The new API does exactly what we needed
The residual — your instinct about argument shapes was rightYour fix helped a little (
You asked for the shape of those rows'
That is a clean correlation: the rows paying the cost are exactly the ones handing the pipeline an array-of-typed-arrays, and the one handing it a flat typed array pays nothing. 2048 outer elements versus 128 versus 1, against +32 / +22 / +1 ms, also lines up with per-outer-element work rather than per-byte — all three carry ~4–16 MB total. Worth noting these rows are 2-D kernels, so the nested shape is what gpu.js wants for a Not blocking us — we can publish at +2% GL median. Flagging it because it is the one thing left that a user with an image-shaped kernel would hit. |
The quiescent fast path tested _executor === null, but the settled has-degraded-to-generic sentinel is FALSE, so eager uploads never engaged on any GL pipeline -- exactly the short-plan image rows the path was built for (the benchmark integration measured the residual scaling with the argument's OUTER element count: the per-call deep copy of an Array(2048)-of-rows, which the armed fast path skips). The sentinel is now pinned by test so a future change cannot re-deaden the path silently, and an eager upload declines on argument size drift (the tail rebuild owns that) instead of writing out of bounds into the previous size's upload kernel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
|
Your outer-element-count correlation cracked it — and the embarrassing half is that the fix I claimed in 2b18318 was dead code. The eager-upload fast path armed on Fixed in b85e21b: the fast path now actually arms (the sentinel is pinned by a test so a future refactor cannot re-deaden it silently), and on the quiescent sequential pattern your harness uses — What should remain on sobel/canny after this: the per-row Whenever you have 20 minutes for the re-run — and thank you for the correlation table; 'scales with outer elements, not bytes' was the whole diagnosis. |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
gpu.createPipeline(fn)— express a multi-kernel, multi-pass computation as a plain JavaScript orchestration function; gpu.js traces it once into a static plan and compiles the whole plan as a unit. On the webasm backend the plan fuses over one shared memory with workers advancing pass-to-pass on Atomics barriers — no main-thread round trip, no copy, per step.Design contract:
docs/design/pipeline-compilation.md(trace-once semantics, handle rules with named build-time errors, automatic double-buffering —u = sweep(u, q)compiles to ONE kernel over two alternating buffers, retiring the duplicate-kernel ping-pong idiom — and the v1 exclusions, stated).Measured (
scripts/benchmark-pipeline.mjs, gauntlet jacobi/heat shapes, Apple M1 Max, checksums identical)The rows the webasm backend used to lose to plain JavaScript are now 2.8–3.2× wins. The per-pass overheads the fusion deletes — a worker-pool round trip, argument re-upload, and a readback per step — are exactly what the gauntlet perf investigation measured as dominant on multi-pass workloads.
What ships
src/pipeline.js): frozen Proxy handles (element reads, arithmetic coercion, spread, and key enumeration all throw named messages), synchronous tracer (async/generator orchestration throws;Math.randombarred; stale handles across re-traces throw), plan IR with static-liveness buffer assignment, generic executor correct on cpu / webgl / webgl2 / headlessgl / webasm, tail-serialized Promise calls with call-time argument snapshots (textures snapshot viaclone()).src/backend/web-assembly/pipeline-executor.js): sync fusion over one shared memory (512-pass ping-pong dedupes to 3 wasm instances); threaded fusion with monotonic-generation Atomics barriers — nothing is ever reset under a laggard worker, aborts retire workers still owing acks (the only place a silently-terminated browser worker is detectable), stall backstop counts barrier arrivals as progress. Everything unfusable degrades to the generic executor with a namedfallbackReason.randomSeed,returnType, and user-pinnedargumentTypes(newdeclaredArgumentTypes, distinct from build-inferred types) so pipelined kernels compute exactly like direct calls.texture.clone()on a mutable kernel's output was broken everywhere — copy-on-write only ran underimmutable; the GL render path now honors outstanding clone refs unconditionally.index.d.ts; adversarial review ran three dimensions, 12 findings all reproduced-then-fixed, re-verified against the reviewers' own repro scripts (including 1500 back-to-back threaded runs with zero barrier faults).WebGPU lowering:
fused-encoderAdded after the initial push (the branch's last four commits): every plan step compiles against persistent storage buffers on the kernel's device — ping-pong as two static bind groups, per-step params uniforms — and per call the WHOLE plan records into one
GPUCommandEncoder: argument writes, every step as a compute pass, result copies to a single staging buffer, onequeue.submit, onemapAsync. ReusesWebGPUKernel's own WGSL/pipeline build; nothing forked. SeededMath.randomkeeps bit-exact parity with direct calls.Measured (
scripts/benchmark-pipeline-webgpu.mjs, headed Chrome / Metal, checksums identical):Honest framing: webgpu per-pass is already a strong baseline (
pipeline: truecalls resolve at submit, so the GPU queue pipelines all sweeps); the encoder fusion removes the remaining per-call JS/bind/submit overhead. The generic executor is the degradation path only — it syncs per step on webgpu and the docs say so. Verify-first paid off: the pipeline test matrix had never enumerated webgpu, so phase 1 put the generic executor on real WebGPU for the first time (it held; one latentInput-snapshot bug in the shared tracer was found live and fixed). The lowering's review confirmed 3 findings (oversize-argument silent zeros, result-only handle seats bypassing the resident-handle screen,Inputresult-shape parity) — all reproduced in headed Chrome, fixed, and pinned by regression tests.v1 exclusions (documented in the README)
No mid-plan readback (
this.checkreserved), no graphical kernels or kernel maps inside plans (named errors).Gates: Node 2833/0 · headed browser 4262/0 (webgpu pipeline rows genuinely executing) · SwiftShader failure set identical to baseline.
🤖 Generated with Claude Code
https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx