Skip to content

Pipeline compilation: createPipeline traces multi-pass programs into fused, barrier-synchronized plans - #871

Merged
fuzzie360 merged 16 commits into
developfrom
feature/pipeline-compilation
Aug 3, 2026
Merged

Pipeline compilation: createPipeline traces multi-pass programs into fused, barrier-synchronized plans#871
fuzzie360 merged 16 commits into
developfrom
feature/pipeline-compilation

Conversation

@fuzzie360

@fuzzie360 fuzzie360 commented Aug 3, 2026

Copy link
Copy Markdown
Member

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.

const sweep = gpu.createKernel(function (u, q) { ... }, { constants: { hi }, output: [1024, 1024] });

const solve = gpu.createPipeline(function (u, q) {
  for (let s = 0; s < this.constants.sweeps; s++) {
    u = sweep(u, q);
  }
  return u;
}, { constants: { sweeps: 512 } });

const result = await solve(u0, q);   // one launch, fences inside, one readback

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)

Workload plain JS webasm per-pass pipeline generic pipeline fused-sync pipeline fused-threaded
jacobi 1024², 512 sweeps 1227 ms (1.63×) 1997 ms (1×) 2009 ms (0.99×) 1760 ms (1.13×) 387 ms (5.16×)
heat 1024², 1024 steps 2473 ms (2.05×) 5073 ms (1×) 4342 ms (1.17×) 3630 ms (1.40×) 890 ms (5.70×)

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

  • Core (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.random barred; 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 via clone()).
  • webasm fused executors (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 named fallbackReason.
  • Clone fidelity: plan clones inherit randomSeed, returnType, and user-pinned argumentTypes (new declaredArgumentTypes, distinct from build-inferred types) so pipelined kernels compute exactly like direct calls.
  • A backend-wide fix surfaced by review: texture.clone() on a mutable kernel's output was broken everywhere — copy-on-write only ran under immutable; the GL render path now honors outstanding clone refs unconditionally.
  • 89 pipeline tests + docs + 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-encoder

Added 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, one queue.submit, one mapAsync. Reuses WebGPUKernel's own WGSL/pipeline build; nothing forked. Seeded Math.random keeps bit-exact parity with direct calls.

Measured (scripts/benchmark-pipeline-webgpu.mjs, headed Chrome / Metal, checksums identical):

Workload webgpu per-pass pipeline generic pipeline fused-encoder
jacobi 1024², 512 sweeps 43.9 ms 497.3 ms 28.3 ms (1.55×)
heat 1024², 1024 steps 59.8 ms 255.2 ms 37.0 ms (1.62×)

Honest framing: webgpu per-pass is already a strong baseline (pipeline: true calls 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 latent Input-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, Input result-shape parity) — all reproduced in headed Chrome, fixed, and pinned by regression tests.

v1 exclusions (documented in the README)

No mid-plan readback (this.check reserved), 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

fuzzie360 and others added 11 commits August 3, 2026 12:40
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
…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
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
@fuzzie360

Copy link
Copy Markdown
Member Author

Findings from migrating the gpu.rocks benchmark suite to createPipeline. 19 multi-pass workloads were attempted, each migration then reviewed by an independent pass that re-ran the numbers.

Correctness is not the problem — every migration passed. Several were bit-exact against the plain-JS oracle (rel 0), the rest inside 1e-4. Trace rules, unrolling, ping-pong buffer assignment and re-trace on setConstants all behaved as the design contract says. WebGPU and WebASM both improve, sometimes a lot.

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 regression

Real browser, Apple M1 Max, Chrome. Recorded published-2.22.0 numbers against this branch with the row migrated:

row column 2.22.0 pipeline
heat WebGPU 46 ms 30 ms −35%
WebGL2 56 ms 472 ms +747%
WebGL 63 ms 1837 ms +2809%
WebASM 4468 ms 3917 ms −12%
life WebGPU 31 ms 29 ms −6%
WebGL2 65 ms 226 ms +248%
WebGL 75 ms 731 ms +869%
sobel WebGPU 22 ms 22 ms +1%
WebGL2 20 ms 78 ms +288%
WebGL 47 ms 159 ms +237%

Independently, agents measured the same shape at full size on other rows: jacobi WebGL 72.7 → 803.7 ms (11×), ising 66.5 → 984.9 ms (15×), bitonic-sort 121 → 3062 ms (25×).

Mechanism, confirmed by counting

_cloneKernel (src/pipeline.js) hard-codes immutable: true for every plan kernel on every backend:

const settings = {
  output: Array.from(kernel.output),
  pipeline: true,
  immutable: true,
  dynamicArguments: true,
};

On GL, immutable: true means each call allocates a fresh output texture and releases the previous one. Instrumenting the plan clone's own context (plan.kernels[0].clone.kernel.context) over a 200-step plan, steady state, after build:

executorKind : (generic)
clone settings: pipeline= true  immutable= true
steady-state run over 200 steps -> createTexture 198  deleteTexture 198  | per step 0.99

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. heat runs 1024 steps, so a plan run does ~1024 full-size create/destroy pairs where the old code did zero — which is the 29× on its WebGL cell.

dynamicArguments: true is also forced, which gives up per-seat specialisation; I did not isolate its share.

Why this blocks adoption rather than merely costing something

A workload here exposes one gpujs() used by all five columns — WebGPU, WebGL2, WebGL, WebASM, CPU. It cannot take the fused path on WebGPU/WebASM and keep the hand-rolled loop on GL. So every migration is all-or-nothing, and each one trades a 1.2–4.7× win on two columns for a 3–29× loss on two others.

Branching on gpu.mode inside the workload was considered and rejected: five cells in one row measuring two different orchestration strategies is exactly the incomparability the suite exists to avoid.

What would unblock it

Any one of these, roughly in order of preference:

  1. A fused GL executor, matching what WebGPU and WebASM already have.
  2. Stop forcing immutable: true in the generic executor. The plan already knows its buffer assignment statically — the ping-pong slots are computed at compile time — so the clones could use mutable output slots and reuse them, which is what the assignment is for. This looks like the smallest fix that recovers most of the loss.
  3. Failing both, recycle textures inside _executeGeneric rather than allocating per step.

Three secondary findings

backend() detection is now blind, which silently disables a safety net. Workloads probe kernel.kernel.constructor.mode to assert the backend that ran is the backend that was asked for — that is how this suite catches gpu.js silently degrading to CPU. Under a pipeline, the user's shortcut is not what executes; plan.kernels[i].clone is. Every migrated row's guard reported the requested mode regardless of what actually ran. plan.kernels[0].clone.kernel.constructor.mode works as a replacement, but the feature should probably expose the executed backend and fallbackReason on the pipeline itself, the way #868 added kernel.fallbackReason.

Threaded WebASM changes what a cell means. A migrated row reaching fused-threaded puts a multi-threaded number next to a single-threaded plain-JS baseline, and next to unmigrated rows that are single-threaded. That is a legitimate result but not a comparable one, and it is not a decision an individual workload can make. A way to pin a pipeline to the sync path — or simply a reliable way to read which executor ran — would let a benchmark keep the comparison honest.

Some rows should never be migrated, and that is fine. launch-overhead measures per-dispatch cost as the difference between the gpu.js column and a hand-written column that is already one encoder; fusing makes the two structurally identical and the difference goes to zero, deleting the measurement. residency exists to price setPipeline(true) as an opt-in, and the contract says inner kernels do not need it — so a traced version contains zero instances of the API the row measures. Both correctly declined. Worth knowing the feature has rows it should not touch.

Reproducing

The 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 createPipeline rewrite of an existing hand-rolled ping-pong with the kernel bodies untouched, so they are ready-made test cases: heat (1024 steps, 1 kernel, 2 buffers), gray-scott (768 steps, 2 kernels, 4 buffers), erosion (515 steps, 7 kernels, 5 buffers), wavefront (12289 steps), topk (1753 steps).

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
@fuzzie360

Copy link
Copy Markdown
Member Author

Landed as 0d5b105 — option 2, taken further than the comment asked, and measured on your own instrumentation method.

The generic executor no longer forces immutable: true. Static liveness was indeed the enabling fact: assignBuffers already guarantees no step reads a slot while that slot's writer renders, so the executor now clones per (kernel, seat signature, output slot) with immutable: false — each clone owns one output texture for the plan's life, which is mechanically the hand-rolled kA/kB two-texture pattern, generated. Two further layers fell out of profiling the same 200-step 1024² repro:

  • dynamicArguments: true was most of the loss that remained after the churn was gone — the signature-keyed clones make every seat's type plan-static, so it is now false (per-seat specialisation restored).
  • Array pipeline arguments were re-uploading per step (the 4 MB q array, 200 times); they now upload once per call through lazy identity kernels on backends where uploads cost — the upU/upQ idiom, also generated.
200-step jacobi ping-pong, 1024², headlessgl before after
createTexture per step (steady state) 0.99 0.00
pipeline generic 248 ms 51 ms
hand-rolled two-kernel loop (same session) 35–67 ms

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 fallbackReason treatment — it is already there, plus a backend accessor) and a way to pin the sync path for benchmark comparability are small and worth doing before merge; the launch-overhead/residency non-migrations are correct readings of what those rows measure. A fused GL executor remains the eventual right answer for option 1, but with generic at parity the urgency is gone.

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
@fuzzie360

Copy link
Copy Markdown
Member Author

Re-ran everything against 0d5b105 (mutable statically-typed clones). It fixes it. Same 13 migrated workloads, same machine, real browser.

The mechanism is gone

Instrumenting the plan's own GL context over a 200-step plan, steady state:

                    before (3b47188)    after (0d5b105)
createTexture              198                 0
deleteTexture              198                 0
per step                  0.99             0.000
genericClones                —                 4

The regression is gone

WebGL2 and WebGL, published-2.22.0 baseline → branch before the fix → branch after:

row GL2 2.22 GL2 was GL2 now GL 2.22 GL was GL now
heat 56 472 59 63 1837 66
gray-scott 52 393 62 120 1417 167
life 65 226 70 75 731 84
wavefront 490 1272 496 484 1277 483
spectral-filter 16 73 34 39 280 36
erosion 52 97 56 114 293 119
topk 144 238 125 420 425 380
compaction 393 127 109 987 257 150

Medians against the 2.22.0 baseline, across all 13:

column before after
WebGL2 +160% +8%
WebGL +158% +5%
WebGPU −23% −25%
WebASM −4% −7%

heat's WebGL cell went 1837 ms → 66 ms against a 63 ms baseline. The two columns the feature was for kept their wins: WebGPU median −25%, with wavefront −90% (674 → 69 ms, 12,289 passes) and compaction −70%.

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 cost

What 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:

row               2.22 ms   now    delta
path-trace              5      8     +3
spectral-filter        16     34    +18
canny                  19     50    +31
sobel                  20     57    +37
erosion                52     56     +4
gray-scott             52     62    +10
heat                   56     59     +3
optical-flow           62     66     +4
life                   65     70     +5
reduction             128     65    -63
topk                  144    125    -19
compaction            393    109   -284
wavefront             490    496     +6

Median absolute delta is +5 ms for rows under 70 ms and −41 ms for rows over it. sobel (16 passes) and canny (12 passes) are the worst relative cases at +37 and +31 ms, while heat (1024 passes) costs +3 ms — so it is not per-step. It reads like a fixed per-pipeline-call cost, plausibly the argument sampling/upload the plan does before dispatching, which a 16-pass row cannot amortise and a 1024-pass row never notices.

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, sobel and canny would show it immediately.

Where this leaves adoption

Adoptable 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:

  • backend() is still blind. The suite asserts the backend that ran is the backend asked for, by reading kernel.kernel.constructor.mode — that is how it catches silent CPU degradation. Under a pipeline the user's shortcut is not what executes. plan.kernels[0].clone worked before this commit; with genericClones now holding the real writers, whatever the supported accessor should be, exposing the executed backend and fallbackReason on the pipeline would be better than either.
  • Threaded WebASM comparability. A migrated row reaching fused-threaded is multi-threaded next to a single-threaded plain-JS baseline and single-threaded unmigrated rows. A way to read which executor ran, or to pin the sync path, would let us keep that column honest.

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
@fuzzie360

Copy link
Copy Markdown
Member Author

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 backend accessor I added in 358cdd9 read plan.kernels[0].clone, which 0d5b105 had already made stale. It now derives from the executor that ran, never from plan internals.

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 → backend === 'cpu', correct results, reason populated.

2. Executor kind + sync pinning — both:

  • pipeline.executorKind ('fused-threaded' | 'fused-sync' | 'fused-encoder' | 'generic') — supported, documented.
  • createPipeline(fn, { threads: false }) pins the webasm lowering to fused-sync, for exactly your column-comparability case.

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' make() outputs is the next thing I'd want to see.

Would appreciate the 13-row re-run whenever convenient — gates on this end are green (Node 2838/0, browser 4267/0, SwiftShader baseline unchanged).

@fuzzie360

Copy link
Copy Markdown
Member Author

Re-ran the 13 rows on 2b18318. Both blockers are closed on our side, and the GL columns are at parity. Also: your backend accessor going stale one commit later is the cleanest possible demonstration of why this needed to be supported API rather than something I read out of the plan — thank you for taking it seriously.

Medians vs published 2.22.0, all 13 migrated rows

column 3b47188 0d5b105 2b18318
WebGL2 +160% +8% +2%
WebGL +158% +5% +1%
WebGPU −23% −25% −25%
WebASM −4% −7% −8%

Notable rows against the 2.22.0 baseline: wavefront WebGPU 674 → 67 ms (−90%, 12,289 passes), compaction −70% on WebGPU and WebGL2 and WebGL, reduction WebGL2 128 → 59 (−54%), topk WebASM 13417 → 7871 (−41%), gray-scott WebGL 120 → 118 against 1417 two commits ago.

No cell reported WRONG on any column of any row.

The new API does exactly what we needed

pipeline.backend is wired into all 13 rows now, replacing accessors that were either reading the user's shortcut (blind) or plan.kernels[0].clone (stale). Verified it reports the executor that ran — heat on the cpu column returns 'cpu' with the oracle matching to rel 0.

threads: false is set on all 13, pinning the WebASM column to fused-sync so it means one thing across migrated and unmigrated rows. That is the right default for a benchmark; it is not a criticism of the threaded path, and if we later add a threaded column it will be because executorKind lets us label it honestly rather than silently mix it in.

The residual — your instinct about argument shapes was right

Your fix helped a little (sobel 57 → 52, canny 50 → 47) but the bulk remains, and it is confined to short-plan rows:

row passes 2.22 GL2 now delta
sobel 16 20 ms 52 ms +32 ms
canny 12 19 ms 47 ms +28 ms
spectral-filter 32 16 ms 38 ms +22 ms
path-trace 9 5 ms 8 ms +3 ms
heat 1024 56 ms 57 ms +1 ms

You asked for the shape of those rows' make() outputs. You were right — they are plain nested JS arrays, not typed arrays:

sobel            src: Float32Array(4194304) | rows: Array(2048) of Float32Array(2048)
canny            src: Float32Array(4194304) | rows: Array(2048) of Float32Array(2048)
spectral-filter  signal: Array(128) of Float32Array(16384) | ...
heat             u0: Float32Array(1048576)          <- flat, and costs +1 ms

sobel and canny pass rows — an Array(2048) whose elements are Float32Array(2048) — as the pipeline argument on every call (await solve(rows)). spectral-filter passes Array(128) of Float32Array(16384). heat, which passes a single flat Float32Array, costs +1 ms.

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 [n, n] input; it is not us being careless with the argument type. If the plan can hoist an unchanged nested argument's upload the way it now does for flat ones, that would close it.

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
@fuzzie360

Copy link
Copy Markdown
Member Author

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 _executor === null, but the settled-degraded-to-generic sentinel is false, so it never engaged on any GL pipeline. Your sobel 57 → 52 was measuring noise around a path that never ran; the +32 ms was the per-call deep copy of Array(2048)-of-rows (2048 slices + 2048 allocations per call, much heavier in a browser than the flat memcpy heat's single Float32Array gets — hence per-outer-element, exactly as you read it).

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 — await solve(rows) back-to-back — the deep copy is skipped entirely: the synchronous GL upload at call time is the call-time snapshot. Nested-rows arguments keep full call-time sampling semantics (mutate rows right after the un-awaited call and the result still reflects call-time contents — pinned by test), and an eager upload declines on size drift rather than writing out of bounds into the previous size's upload kernel (also caught by your test shape, also pinned).

What should remain on sobel/canny after this: the per-row flattenTo set inside the upload itself, which the 2.22.0 hand-rolled baseline pays identically — so those rows should land at or very near baseline. If the sweep still shows a gap beyond ~2–3 ms there, something else is in play and I want to know.

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.

@fuzzie360
fuzzie360 merged commit 06373be into develop Aug 3, 2026
1 check passed
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