Skip to content

Compiler optimizations: hoisting, inlining, unrolling and coordinate localization across every emitting tier - #872

Open
fuzzie360 wants to merge 9 commits into
developfrom
feature/compiler-optimizations
Open

Compiler optimizations: hoisting, inlining, unrolling and coordinate localization across every emitting tier#872
fuzzie360 wants to merge 9 commits into
developfrom
feature/compiler-optimizations

Conversation

@fuzzie360

Copy link
Copy Markdown
Member

Every tier that emits code now runs an AST optimization pass; dev is untouched, since it executes your actual function rather than emitting one. Four transforms, one shared module, and a hard bitwise-parity contract: optimized output is bit-identical to unoptimized output on cpu and webasm, and bit-identical-first with a 1-ULP band on GL/WGSL (where the driver's shader compiler may reassociate — the pass itself never does).

  • H — loop-invariant hoisting of pure reads (all tiers)
  • T1 — thread-coordinate localization (cpu only; wasm keeps thread ids in globals, GLSL/WGSL in builtins, so there is nothing to localize)
  • T2 — helper inlining (all tiers)
  • T3 — tiny literal-loop unrolling (all tiers)

Control: kernel._optimizerDisabled (internal, the _fusionDisabled precedent) and the public loopUnrollLimit setting (default 8, 0 disables T3). A build-time throw from the optimizer degrades to an unoptimized build with a loud warning and fallbackReason, per the #868 contract.

Measured (scripts/benchmark-optimizer.mjs, self-contained, M1 Max, median of 7)

cpu — T3 is the lever: literal 4-trip loop 4.58×, nested 3×3 5.95×, hoistable read + loop 4.41×, 3×3 stencil 2.95×.

webasm — T2 is the lever, and not for call overhead: the SIMD emitter lane-scalarizes helper calls, so inlining restores vectorization. Helper in a hot loop 6.48× (T2 alone 3.70×), helper chain 3 deep 4.22× (T2 alone 3.98×), nested literal loop 2.92×.

GL (desktop headless-gl) — ~1.0× across the board; the driver already does this. Kept on per the "all tiers where applicable" decision because desktop drivers are not mobile drivers, and BrowserStack (5/5 real devices, H phase) is the honest judge.

Full per-transform tables are in the README section.

An open question for review

T2 measures as a small net negative on cpu — the T2 column runs 0.78–1.02× across nine workloads (mean ≈0.93), because V8 already inlines small helpers and our inlining appears to disturb its own heuristics. The worst row, "helper chain 3 deep", ends at 0.85× overall. Options: leave it (uniformity, and the losses are single-digit percent), or make T2 backend-conditional — on for webasm/GL/WGSL, off for cpu. I lean toward the latter but did not want to narrow an explicitly requested scope unilaterally.

Review

An adversarial fan-out over three dimensions (semantic preservation, seeded randomness + SIMD, per-backend ordering) confirmed 10 findings, 4 critical — all reproduced before fixing and re-verified after:

  • T2 discarded addFunction's declared returnType/argumentTypes, and removed the emitter's first-call-site parameter coercion for multi-site helpers — both silently changed results. Such helpers now keep their calls.
  • T2 permuted the seeded PCG draw order when two inlinable calls shared a statement. A claimed site now blocks a sibling when the expansion has effects (a draw, or a write that escapes), with effects propagated along the call graph.
  • H hoisted reads out of maybe-zero-trip loops on webasm, where wasm loads trap rather than clamping. readsCanFault is now true there, plus a readsFaultAtOneLevel flag because cpu's two-subscript shortcut does not hold for raw loads.
  • T3 unrolled loops with a non-literal init (let i = -2), deleting the LOOP_MAX cap the emitters apply to exactly those; it now unrolls them only when every iteration would have run anyway. T3 also gained the cumulative node budget T2 already had — nesting is multiplicative, so per-loop trip counts bound nothing (a 3-deep nest was 17× slower on cpu and 300× slower to build on GL).
  • buildWithOptimizer caught every build-time throw, so ordinary user source errors were blamed on the optimizer and compiled twice; only tagged optimizer failures take that path now.
  • Pipeline plan clones dropped loopUnrollLimit and _optimizerDisabled.

Notably the 81-row parity battery was green and missed all ten — its shape coverage had holes exactly where the findings were. Rows for every one have been added.

Two pre-existing WebGPU bugs surfaced and are excluded from parity with comments (they fail identically with the optimizer off): astForStatement crashes on a comma-folded for init, and assigning to a scalar argument does not compile on WGSL (#867's WGSL twin).

Gates: Node 3093/0 · headed browser 4740/0 · SwiftShader failure set identical to the 131-line baseline · BrowserStack 5/5 real devices.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx

fuzzie360 and others added 8 commits August 5, 2026 00:22
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
src/backend/optimizer.js is a backend-agnostic AST pass every emitting
backend shares. It runs from FunctionNode.getJsAST, between de-minification
and the tracer -- so the declarations it introduces are the ones type
resolution registers, and every per-backend normalization (webgl's
linearization and do-while rotation, webasm's variance analysis and SIMD
emission) sees the shapes it leaves behind. `dev` never reaches it.

Transform H hoists a loop-invariant pure read to a const before the loop,
which is legal because a kernel cannot write its array arguments. Two
restrictions carry the whole safety argument: the read must be one the body
reaches unconditionally, and -- where an out-of-range read can FAULT rather
than yield a value, which is cpu only -- the loop must provably run once.
Together they mean the un-optimized build performs the read too, so hoisting
it can introduce neither an evaluation nor a crash that was not there.

`kernel._optimizerDisabled` is the internal hook (the `_fusionDisabled`
precedent), carried onto fallback and switched kernels. A build-time throw
from an optimized build is caught, the kernel rebuilds with the optimizer
off, warns, and records `fallbackReason` (#868).

test/features/optimizer/parity.js is the battery later phases add to: 30
shapes x 6 backends, optimized against `_optimizerDisabled`, compared through
Int32 views. cpu and webasm are held to the bit. The GL backends and webgpu
are not, and cannot be: a shader compiler is licensed to reassociate, and
hoisting a read out of `s += a[x] * (i + 1)` lets it factor the unrolled sum
where four separate fetches kept four adds -- one f32 ULP, measured. Those
four get bit-identity first and a 1e-6 relative bound second.

Measured (1M cells, median of 7+, all forms warmed before any is timed):
cpu 1.4x on a hoistable hot loop and 1.4x on a stencil, webasm 1.8x and 1.4x,
GL nothing on this desktop driver. The contract's open question -- how the
cpu 3.27x splits between hoisting and unrolling -- comes out H 1.33x,
unrolling 2.60x on top, 3.45x combined.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
…ordinates)

T3 replaces a `for` whose init, test and update are all integer literals with
one copy of its body per iteration, the counter substituted as its value. The
trip count is computed exactly rather than bounded, so the criteria are the
ones that make that computation sound: integer literals only -- a fractional
counter accumulates differently in f32 than in the f64 this pass simulates it
in -- a declared rather than assigned counter, no break or continue bound to
the loop, and nothing in the body that writes or shadows the counter. Each
iteration lands in a BlockStatement of its own, so a body that declares a
local still declares it once per iteration in its own scope, and the whole
LOOP_MAX safe-wrapping goes with the loop.

Unrolling runs innermost-first, which is also what makes shadowing work: an
inner loop that unrolled took its own counter with it, so the outer loop has
nothing left to substitute wrongly; one that did not unroll still declares
the name, and the outer loop skips.

`loopUnrollLimit` becomes the public setting the contract specifies -- trip
counts up to it unroll, `0` turns unrolling off -- with a setter, a README
entry and index.d.ts.

T1 gives the cpu backend's root kernel body the generated cell loop's own
counters in place of `_this.thread.x`, which is a property read on a shared
mutable object performed per access. Helpers and sub-kernels are emitted as
sibling function declarations where those counters are not in scope, so they
keep the property read. `this.constants.*` and `this.output.*` needed nothing:
both are already bound above the cell loop.

Making T3 general meant fixing four pre-existing emitter gaps it reaches --
each one is a hand-written kernel that does not compile today:

- `switch (1)` emits no discriminant declaration on webgl and throws on
  webasm and webgpu; a LiteralInteger discriminant is now an integer one
- `f(1 + 1)` against an Integer parameter, and `a[1 + 2]`, emit float where
  the type says integer, because a literal-only expression has nothing to
  tell it which way to build. The parameter's declared type and an index are
  both integer contexts, and now say so
- `2 - -2` emitted `2--2`, a syntax error in JavaScript and an l-value error
  in GLSL; a leading sign now carries its own parentheses

A loop that draws `Math.random()` does not unroll. The sequence is preserved
by construction -- same count, same order -- but the GL lowering is
`fract(sin(dot(...)) * 43758.5453)`, where one ULP of compiler reassociation
is a different number: measured 4.5e-4 apart on ANGLE/Metal.

Measured (1M cells / 256x256, median of 7, each workload in its own process):

  cpu     hoistable 8-trip loop  H 1.55x, T3 3.08x on top, 4.77x total
          nested literal 3x3     H 1.91x, T3 3.30x on top, 6.30x total
          coordinate-heavy       T1 1.82x
  webasm  hoistable 8-trip loop  H 1.96x, T3 1.31x on top, 2.57x total
          nested literal 3x3     H 1.90x, T3 1.54x on top, 2.92x total
  GL      nothing on this desktop driver, as in phase 1

The benchmark's cross-check used to flatten a million cells into a plain array
before comparing, which allocated 8MB per build and moved the numbers it was
checking -- the coordinate-heavy workload read 1.06x that way and 1.82x
without. It compares in place now, and each workload runs in a process of its
own so one workload's V8 state cannot decide another's answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
… tier

A call to a user helper is replaced by the helper's body: parameters bound
as fresh declarations in source order, locals renamed, and the expression it
returned left where the call was. An early return folds to a conditional
rather than a labeled block -- GLSL has no labeled break, and a result temp
would need a type this pass runs too early to ask for.

The decision is global rather than per site. gpu.js fixes a helper's
parameter types from whichever call site the emitter reaches first, so
removing one site can change what the surviving sites coerce their arguments
to; FunctionBuilder decides inlinability for the whole call graph up front,
all-or-nothing per helper, over raw ASTs that are never optimized or traced
early.

webasm is where this pays: the SIMD emitter has no vector form for a call
and lane-scalarizes one, so a helper in a hot loop un-vectorizes the loop
around it. Measured 3.3-4.0x there, and bit-exact seeded draws on every
dispatch path.

Also: the random plugin is now selected by matching the whole program rather
than the kernel alone, so Math.random() inside an addFunction helper
compiles on GL at all; and a division by a fractional literal skips
divWithIntCheck, which provably does nothing there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
The benchmark's complete per-backend tables, read with the control row
first: it moves 0.93x on cpu and 1.07x on webasm with nothing to optimize,
so the noise floor is around 7% and the whole headlessgl table sits inside
it. Saying so is the point -- a desktop GL driver already runs these
transforms itself, and the mobile question belongs to the device fleet.

Also settles what the design contract left open: the 3.27x cpu probe was
hoisting and unrolling at once. Timed apart, hoisting alone is 1.58x and
unrolling on top of it a further 2.42x.

index.d.ts needed nothing -- loopUnrollLimit already carries the property,
the setter, IKernelSettings and IFunctionSettings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
T2 correctness. A helper whose types the user DECLARED through addFunction
keeps its call: those types are coercions the emitter applies at the call
boundary, which inlining deletes. A helper reached from more than one call
site keeps its calls too -- gpu.js fixes its parameter types from the first
site and coerces the rest, so per-site inlining computes differently. An
early-return fold whose branches carry different literal kinds is refused
(GLSL has no implicit int/float conversion, so the ternary would not
compile). And a claimed call site now blocks a second site in the same
statement when the expansion has EFFECTS -- a draw or an outward write --
because expansion lifts bodies into a shared prefix and would permute the
seeded PCG stream; effects propagate along the call graph.

Memory safety. webasm reads are raw wasm loads that TRAP, so its function
node declares readsCanFault, and a new readsFaultAtOneLevel says the
two-subscript shortcut (true on cpu, where a one-level read yields
undefined) does not apply there.

T3 bounds. A non-literal init (`let i = -2` parses as a UnaryExpression)
falls outside the emitters' canonical-loop rule, so they wrap it in
LOOP_MAX; unrolling deletes that cap, and is now allowed only when every
iteration would have run anyway. Unrolling also gained the cumulative
node budget T2 already had -- nesting is multiplicative, so per-loop trip
counts bound nothing.

Attribution and plumbing. Only optimizer-originated throws take the #868
degrade path; an unsupported-construct error from the emitter reaches the
user unchanged instead of being blamed on the optimizer and compiled
twice. Pipeline plan clones inherit loopUnrollLimit and _optimizerDisabled.

Every finding reproduced before fixing and re-verified after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
T2 measured a consistent net loss on the cpu backend -- 0.78-1.02x
across the nine benchmark workloads, mean ~0.93, worst row 0.85x
overall -- because the emitted body is JavaScript and V8 already
inlines small helpers better than we do. It stays on for webasm, GL
and WebGPU, where a call is a real barrier: on webasm inlining is
worth 3.7-4.0x, since a helper call forces the SIMD emitter to
scalarize per lane.

The other three transforms are unchanged and carry the cpu gains
(nested literal loops 6.4x, hoistable read + loop 4.4x, literal
4-trip loop 3.8x, stencil 2.9x).

The existing kernel._inliningDisabled hook is what expresses it: the
cpu kernel defaults it on, before mergeSettings so an explicit setting
still wins, and the T2 suite's cpu rows opt back in to keep exercising
the inliner's mechanics through emitted JavaScript.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUTVDFaHav3uAdN3XfZLyx
@fuzzie360

Copy link
Copy Markdown
Member Author

T2 is now backend-conditional (12d83cd), per the open question above.

Helper inlining is off on cpu and on everywhere else. The existing kernel._inliningDisabled hook is what expresses it — the cpu kernel defaults it on, set before mergeSettings so an explicit setting still wins, and the T2 suite's cpu rows opt back in so the inliner's mechanics (parameter binding, renaming, early-return folding, evaluation order) keep being exercised through emitted JavaScript, which is by far the easiest emission to read.

Re-measured on the finished tree: cpu's T2 column now sits at ~1.00× where it previously dragged (0.78–1.02×, mean ~0.93), and the cpu totals still come from the other three transforms — nested literal loops 6.40×, hoistable read + loop 4.42×, literal 4-trip loop 3.83×, 3×3 stencil 2.94×. webasm keeps the prize: helper in a hot loop 6.48× (inlining alone 3.70×), helper chain 3 deep 4.22× (3.98×).

One honest caveat on reading the tables: the sub-1.0× cells that remain are all on the ~1 ms rows, where run-to-run noise is around ±10% — the straight-line control row swings that much with no transform applying to it at all. The multi-millisecond rows are where the signal is.

Gates re-run on the finished tree: Node 3093/0 · headed browser 4740/0 · SwiftShader failure set identical to the 131-line baseline. README tables refreshed.

@fuzzie360

Copy link
Copy Markdown
Member Author

Benchmark Gauntlet: 30 workloads × 6 columns, 12d83cd vs released 2.23.0

Ran the gpu.rocks suite against this branch. Headline: parity holds, and the pass is a clear net win — 20 gains against 5 regressions across 150 gpu.js cells. Three of the regressions look like real leads rather than noise, and they're the reason for this comment.

Setup. Apple M1 Max, 10 cores, Chrome 150, cross-origin isolated (SharedArrayBuffer available, so the WebAssembly worker pool is live). Every kernel built with asyncMode on. Each cell: 2 warm-ups, then the median of ≥3 timed reps, with rep count adapted to ~1s per cell. Every backend's output is checked against a plain-JavaScript implementation of the same workload — no gpu.js in it — at 1e-4 relative before it is timed; a mismatch prints WRONG instead of a number.

Baseline is released 2.23.0 from npm, recorded on the same machine one day earlier under the same conditions.

Parity

0 WRONG cells out of 180. 0 n/a, 0 unverified checksums.

optimizer.js's header sets the bar at bitwise parity with _optimizerDisabled — nothing reassociating floats, nothing changing how many times an operation runs, nothing moving a call. Across five backends and thirty workloads, including the seeded-Math.random rows where a reordered draw would desynchronize the stream, every answer still matched the oracle.

Also unchanged: no new fallback markers, and executor markers identical between the two runs ({generic: 39} both). So every difference below is emitted code getting faster or slower, not a different lowering being selected.

The noise floor

bare-js contains no gpu.js at all, so its spread between the two runs is pure machine noise and sets the bar everything else has to clear:

bare-js control spread:  median 0.4%,  p90 1.4%,  max 16.5%

The machine was steady. I've used ±20% as the cutoff below, which is deliberately conservative against that.

Per column

column median best row worst row
webgpu 1.16× gradient-descent 2.05× path-trace 0.81×
cpu 1.03× canny 2.12× ncc-template 0.59×
webgl2 1.02× fft 1.16× life 0.95×
webgl 1.02× monte-carlo 1.20× heat 0.66×
webasm 0.98× escape-time 1.41× matmul 0.43×
bare-js (control) 1.00×

Gains beyond noise (20)

cpu      canny             1059.1 ms ->  499.7 ms   2.12x
webgpu   gradient-descent    10.6 ms ->    5.2 ms   2.05x
webgpu   histogram           38.2 ms ->   21.0 ms   1.82x
webgpu   fft                 41.6 ms ->   23.5 ms   1.77x
webgpu   matmul              14.5 ms ->    8.7 ms   1.67x
webgpu   blur-separable      34.9 ms ->   21.0 ms   1.66x
webgpu   reduction           72.3 ms ->   45.0 ms   1.61x
cpu      reduction         5136.0 ms -> 3251.6 ms   1.58x
webgpu   canny               18.3 ms ->   11.9 ms   1.54x
webgpu   sobel               20.3 ms ->   13.4 ms   1.52x
webgpu   ode-rk4              3.9 ms ->    2.6 ms   1.49x
webgpu   residency           13.9 ms ->    9.4 ms   1.47x
webasm   escape-time         73.8 ms ->   52.4 ms   1.41x
webgpu   spectral-filter     11.5 ms ->    8.2 ms   1.39x
cpu      topk              6508.9 ms -> 4688.4 ms   1.39x
cpu      blur-separable     910.5 ms ->  716.0 ms   1.27x
webgpu   life                28.1 ms ->   22.5 ms   1.25x
cpu      matmul            2835.0 ms -> 2304.1 ms   1.23x
webasm   monte-carlo         47.7 ms ->   39.0 ms   1.22x
webgl    monte-carlo         37.4 ms ->   31.1 ms   1.20x

Regressions beyond noise (5)

webasm   matmul             130.9 ms ->  307.9 ms   0.43x
cpu      ncc-template       987.1 ms -> 1683.9 ms   0.59x
webgl    heat                61.2 ms ->   92.8 ms   0.66x
webasm   ncc-template       322.9 ms ->  470.2 ms   0.69x
webgpu   path-trace           2.2 ms ->    2.7 ms   0.81x

Three patterns worth a look:

1. ncc-template regresses on both scalar backends — CPU 0.59×, WebASM 0.69× — while its WebGPU and GL cells stay inside noise. Whatever shape the pass leaves that kernel in, it costs on the tiers that emit scalar loops. Shared cause across two very different emitters suggests the transform, not the lowering.

2. matmul moves in opposite directions from one source. WebASM 0.43× — the single worst cell in the run, and its adaptive rep count fell 7 → 3, consistent with the cell genuinely taking longer — against WebGPU 1.67× and CPU 1.23×. Same kernel, same AST going in, three different outcomes. If the pass is deciding to unroll or hoist based on something that reads differently per tier, this is where it shows.

3. The benefit is asymmetric across tiers. For a pass described as running at the FunctionBuilder stage for every emitting backend, WebGPU gets 1.16× and 11 of the 20 gains, while WebGL2 and WebGL get 1.02× and one gain each. Might be that the GL emitters already normalize away most of what the pass would do — but if the intent is uniform benefit, the GL tiers aren't seeing it.

Caveat on attribution

These numbers compare released 2.23.0 against this branch, not "optimizer on" against "optimizer off". The branch changes 421 lines across 15 files — optimizer.js is new, but every backend's function-node.js and kernel.js changed too. So the five regressions are attributable to the branch, and I have not yet isolated them to the pass itself.

_optimizerDisabled makes that separable on one build in one session, which is much stronger evidence than a cross-version comparison. Happy to run it and post the delta if useful — say the word.

Reproducing

git clone https://github.com/gpujs/gpu.rocks && cd gpu.rocks
yarn add "gpu.js@https://github.com/gpujs/gpu.js.git#feature/compiler-optimizations"
yarn build
node scripts/bench-record.mjs --label "your machine"

Or in a container with a GPU, docker compose run --rm bench --label "your machine" — see docker/README.md, which covers the flags needed to get hardware WebGPU out of headless Chromium (--disable-vulkan-surface is load-bearing on NVIDIA).

The recorder refuses to save if either renderer comes back as software, records crossOriginIsolated and the core count into every run, and stores the resolved git ref and commit as the version — this run is filed as 2.23.0-compiler-optimizations+12d83cd rather than as 2.23.0, since a branch build otherwise reports the release it was cut from.

@fuzzie360

Copy link
Copy Markdown
Member Author

Took you up on the isolation, and it changes the conclusion: in your exact configuration, the pass is a 1.07× win on the cell recorded at 0.43×.

I reproduced webasm matmul in a headed browser — crossOriginIsolated=true, 10 cores, asyncMode, pool of 10, path=threaded simd=true — same build, optimizer on vs off:

Node (threaded) Browser (your config)
_optimizerDisabled: true 115.2 ms 147.3 ms
optimizer on 117.8 ms (0.98×) 137.9 ms (1.07×)

Your 130.9 ms baseline sits right in that band; the 307.9 ms branch figure does not, and I cannot reproduce it. Same story for the other two leads, isolated on one build in Node:

cpu     matmul        off 3757.0 ms -> on  2.31x FASTER
cpu     ncc-template  off 1259.2 ms -> on  1.01x  (neutral)
webasm  ncc-template  off  606.2 ms -> on  1.00x  (neutral)

So ncc-template's twin regression is not the transform: it is flat on both scalar backends when the pass is the only variable. Checksums identical throughout, SIMD lowering unchanged, _lastRunPath identical.

One repro detail worth passing on: my first matmul harness used a literal 1024 loop bound where the real kernel uses this.constants.n. That alone moved the Node number from 1.10× to 0.98×, so faithful kernel text matters more than I expected when chasing these.

Where that leaves it. The regressions are attributable to the branch-vs-release delta rather than to the pass. The branch touches every backend's function-node.js and kernel.js, but all at build time — the readsCanFault flags, the buildWithOptimizer retry wrapper, plumbing — none of which should move steady-state throughput. So the A/B you offered is now the decisive experiment: same build, _optimizerDisabled on vs off, inside your harness, where the regression appears and mine does not. If it goes flat there too, the next suspect is the environment rather than the code — a sequential 30-workload page with three live backends and worker pools has thermal and resource dynamics my two-kernel A/B does not, and matmul's rep count dropping 7→3 fits contention as readily as it fits slower code.

On the asymmetry (your third pattern): I would call that correct rather than a shortfall. GL's 1.02× matches my own measurements exactly — desktop GLSL drivers already hoist, inline and unroll, so there is nothing left to win, and the transforms stay enabled there only because mobile drivers are weaker than desktop ones (BrowserStack, 5/5, is the check on that). WebGPU's 1.16× with 11 of the 20 gains is genuinely new information and suggests Tint inlines less aggressively than the GL drivers do; I had not measured that.

The parity result is the part I value most: 0 WRONG across 180 cells, seeded-Math.random rows included, is exactly the property the pass had to have — and precisely what four of the ten review findings were about breaking.

@fuzzie360

fuzzie360 commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Ran it: same build, _optimizerDisabled on vs off, both full 30-workload runs back to back in the harness. It reproduces, and it is the pass — for four of the five. The fifth is yours, but not for the reason either of us thought.

First, a correction to something in my last comment that I should have made explicit, because it changes how the numbers read.

The baseline is not the cpu column

cpu is gpu.js's CPU backend — your kernel, transpiled and new Function'd. It is one of the five gpu.js columns under test, not the reference.

The baseline is bare-js: a second, hand-written implementation of the same workload in plain JavaScript, with no gpu.js in it at all. Every workload in the suite is written twice on purpose — once as a gpu.js kernel, once by hand — and the hand-written one is what every speed-up is measured against and what every checksum is verified against.

That distinction matters for reading this A/B, because bare-js contains none of your code. It cannot be moved by the optimizer, so its spread between two runs is pure machine noise and it is the control that says whether a delta elsewhere is real. It also means the cpu column is routinely slower than the baseline — the CPU backend loses to the hand-written JavaScript it replaces on most rows — which is a real result about the backend, not a broken measurement.

The A/B

Both arms full 30-workload runs, consecutive, same build, same machine. 0 WRONG cells in both. Control (bare-js, which contains none of your code and cannot be moved by the pass): median 0.3%, p90 2.1%, max 6.6% — so ±20% is a conservative bar.

The pass is median-neutral on every column. OFF/ON, where >1 means the pass makes it slower:

column webgpu webgl2 webgl webasm cpu
OFF/ON median 0.998× 1.000× 1.003× 1.007× 1.022×

Which means the 1.16× I credited to the optimizer is not the optimizer. Decomposing that WebGPU median across the two steps:

released -> branch, pass OFF   1.127x   <- the non-optimizer branch changes
branch OFF -> branch ON        0.998x   <- optimizer.js itself

The WebGPU win is real and it is yours, but it is coming from the rest of the branch, not from the pass. That also contradicts the expectation that those changes are build-time only and cannot move steady-state throughput — they move it a lot, and mostly for the better.

My five, adjudicated

cell                  released      OFF        ON    OFF/ON   verdict
webasm/matmul            130.9     133.8     250.4    0.53x   THE PASS
webgl/heat                61.2      69.9     116.1    0.60x   THE PASS
cpu/ncc-template         987.1    1688.9    1692.1    1.00x   branch, not the pass
webasm/ncc-template      322.9     315.2     312.4    1.01x   noise — you were right
webgpu/path-trace          2.2       1.9       2.0    0.96x   noise — you were right

Two of my five were noise and your isolation was correct on both. cpu/ncc-template is flat across on/off exactly as you measured — but it is 1.7× slower than released in both arms, so it is a branch regression that your two-kernel probe and my cross-version diff each saw half of.

webasm/matmul and webgl/heat do reproduce, on one build with the pass as the only variable. matmul at 0.53× is not contention: the off arm lands on 133.8 ms against released 130.9 ms, and its adaptive rep count goes 6 → 3 as the cell gets slower.

Two the released-vs-branch diff could not see

cpu/histogram           5868.7    2317.8    5544.2    0.42x
webgl2/heat               58.3      55.9      67.7    0.83x

cpu/histogram is the worst cell in the run and it was invisible to my first report, because the two effects cancel: the branch makes it 2.5× faster with the pass off (5868.7 → 2317.8), and the pass then hands nearly all of it back (→ 5544.2). Net against released, it looks untouched.

The pass does win, on the tails

14 cells gain >20% from the pass, 4 lose >20%. The largest:

webgl/compaction         242.1 ->  127.3   1.90x
cpu/canny                845.3 ->  500.4   1.69x
webasm/histogram         617.2 ->  385.2   1.60x
webgl/path-trace           8.2 ->    5.1   1.60x
webgl2/gray-scott         77.5 ->   52.6   1.47x
webgl/gray-scott         163.0 ->  123.5   1.32x
webgl2/compaction        131.2 ->  100.8   1.30x
webasm/monte-carlo        49.6 ->   38.4   1.29x

So the shape is: neutral at the median, real wins and real losses at the tails. Worth noting against your GL point — the medians agree with you (1.000× and 1.003×), but compaction at 1.90× and gray-scott at 1.32–1.47× say the desktop GL drivers are not already doing everything the pass does. "Nothing left to win" holds on average, not universally.

heat is the row to look at first: it regresses on both GL tiers (0.60× and 0.83×) while gray-scott — a very similar stencil — gains on both. Two neighbouring shapes going opposite directions on the same emitter looks like the most tractable lead in here.

On your three points

The 1024 vs this.constants.n finding is the important one in your comment, and it cuts both ways: if that single substitution moved your Node number from 1.10× to 0.98×, then these deltas are sensitive enough that harness fidelity dominates the measurement. This suite runs the real kernel text, at the real sizes, with the real constants, which is the most likely reason it sees something a two-kernel probe does not.

On the GL asymmetry — you were right at the median and I was wrong to call it a shortfall. Off-vs-on comes out 1.000× and 1.003× on WebGL2/WebGL, so on average the desktop drivers really are already doing it, and keeping the transforms for weaker mobile drivers is the right call. The one qualification is the tail above: compaction 1.90× and gray-scott 1.32–1.47× are the pass winning on GL, so "nothing left to win" is true on average and not universally.

But the Tint reading needs re-examining, and that is on me for handing you a wrong premise. I reported WebGPU 1.16× as the pass; the decomposition says the pass is 0.998× there and the 1.127× belongs to the branch's other changes. So whatever produces the WebGPU win, it is not optimizer.js inlining what Tint does not — that explanation was built on my number, and my number was misattributed.

On ncc-template, your isolation was right on both cells and my inference was wrong. I argued a shared cause from the twin CPU/WebASM regression, on the reasoning that two unrelated emitters failing together points at the transform. Off-vs-on says neither is the transform: WebASM 1.01×, CPU 1.00×. What the harness adds is only that the CPU one is 1.7× slower than released in both arms — a branch regression, not a pass regression. The twin pattern that made my inference look strong was one real branch effect and one noise reading sitting on the same row.

Reproducing this A/B

The flag goes on the kernel, in the same place the harness already sets asyncMode:

const shortcut = gpu.createKernel(source, settings);
shortcut.kernel.setAsyncMode(true);
shortcut.kernel._optimizerDisabled = true;   // the off arm

Both arms: gpu.js at 12d83cd, Apple M1 Max, 10 cores, Chrome 150, cross-origin isolated, ~75% free memory throughout, two builds differing only in that line, runs consecutive.

Replacing `_this.thread.x` with the generated cell loop's own counter is
neutral on small outputs and a real loss at scale: 0.84x on a 3072x256
kernel with a 3072-trip inner loop (2048ms -> 2448ms), and 0.42x in the
gpu.rocks harness, where it was the worst cell in a 150-cell run. The
mechanism is plausible in hindsight -- `_this.thread.x` is a monomorphic
load on an object whose shape never changes, which V8 can hoist out of an
inner loop, while a `let` counter from an enclosing loop it must re-read
per iteration.

The transform stays behind `localizeThreadCoordinates` (default false, a
real setting so its tests still exercise it) rather than being deleted: a
better emission -- binding the counters once per cell above the body --
would plausibly win, but it has to be measured before it ships on.

Four emission assertions in the H and T2 suites incidentally depended on
T1's output; their patterns now accept either coordinate form, so they
test what they are named for.

Found by the gpu.rocks benchmark integration, whose optimizer-on/off A/B
on one build separated this from the branch's other changes.

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 A/B did the job — it convicted a transform, and it is T1, the one I added on my own initiative and argued would be the biggest cpu lever. Fixed in 154cbb0.

cpu/histogram — reproduced, diagnosed, fixed

Dumping the emitted JavaScript on vs off, the pass's only change to that kernel is T1:

- const user_b=_this.thread.y;      + const user_b=y;
- const user_y=_this.thread.x;      + const user_y=x;

And it is scale-dependent, which is why my two-kernel probe missed it:

histogram-partial optimizer off on
1024×64 65.9 ms 66.3 ms (0.99×)
3072×256 (real size) 2047.7 ms 2447.7 ms (0.84×)

The mechanism is plausible in hindsight and the opposite of my reasoning: _this.thread.x is a monomorphic load on an object whose shape never changes, which V8 can hoist out of an inner loop; a let counter from an enclosing loop it must re-read per iteration. I asserted the property read was the expensive one without measuring the loop that contains it.

T1 now ships off, behind localizeThreadCoordinates (default false) rather than deleted — a better emission, binding the counters once per cell above the body, would plausibly win, but it has to be measured first. With it off, the same cell reads 2053.3 vs 2054.4 ms — 1.00×.

webgl/heat — not the kernel, and I need your help narrowing it

heat's GL emission is byte-identical with the pass on and off (786 chars each, diff empty). So whatever costs 0.60× there is not the shader. heat runs through createPipeline, so the plan clones are the place to look — and this branch did change _cloneKernel's inherited-settings list to carry loopUnrollLimit, _optimizerDisabled and _inliningDisabled. If you can run the same A/B with solve.pipeline.plan.genericClones inspected (or simply compare executorKind and clone count between arms), that would localize it faster than I can guess. gray-scott gaining while heat loses on the same emitter is consistent with a pipeline-path difference rather than a codegen one, since the two have different plan shapes.

Corrections I owe you

"Build-time only" was wrong. I claimed the branch's non-optimizer changes could not move steady-state throughput. They can: pluginMatchSource() changes which plugin gets substituted into the shader — it now matches against the kernel plus its addFunction helpers, so a helper's Math.random() selects the plugin that a kernel-only match missed. That is emitted-code-changing, and it is the most likely source of your 1.127× WebGPU delta and worth checking against cpu/ncc-template's 1.7× regression too.

The Tint reading is withdrawn. It was built on the 1.16× figure; your decomposition puts the pass at 0.998× on WebGPU, so there is nothing there for it to explain.

"Nothing left to win" on GL was too strong, exactly as you say. compaction 1.90× and gray-scott 1.32–1.47× are the pass winning on desktop GL. Median-true, not universally true.

Gates on 154cbb0: Node 3093/0 · headed browser 4740/0 · SwiftShader failure set identical to the 131-line baseline.

One thing I would value in the next run, since my own suite is noisy at the ~1–2 ms rows: whether cpu/canny (1.69×) and webgl/compaction (1.90×) hold up with T1 off. Those are the pass's best cells and T1 was live in the run that produced them.

@fuzzie360

Copy link
Copy Markdown
Member Author

Ran 154cbb0 both ways, full 30-workload arms again. T1 is confirmed as the cause of two of the three, and your fix works in-suite. But running four arms let me measure something I should have measured before reporting anything, and it invalidates part of my last two comments.

My noise control was wrong, and it was too generous

I set the significance bar from bare-js spread — 0.3% median, 2.4% p90. That is the right control for whether the machine was steady, and the wrong one for whether a gpu.js cell is reproducible, because bare-js is a plain JS loop with no GPU, no driver, no pipeline and no worker pool underneath it.

I now have two arms with _optimizerDisabled: true on two different commits, i.e. two runs where the pass never executed. That is a reproducibility floor for each column:

column median p90 max worst cell
bare-js 0.3% 2.4% 6.7%
webgl2 1.0% 11.7% 30.6% gray-scott
cpu 0.6% 16.9% 137% histogram
webasm 2.4% 17.8% 35.8% histogram
webgpu 3.3% 23.8% 64.7% ode-rk4
webgl 2.3% 25.2% 47.7% compaction

14 of 150 gpu.js cells move more than 20% between two runs of identical code. My ±20% bar was approximately the p90 of these columns, so roughly a tenth of cells would clear it by chance. Every ±20%-level claim in my first comment — including several of the "20 gains" — has to be read against that, not against 2.4%.

What survives, on four arms

T1 was live in exactly one arm (12d83cd ON), so a cell caused by T1 should show one outlier in that column and agreement in the other three.

cell                  released  12d83cd:OFF  12d83cd:ON  154cbb0:OFF  154cbb0:ON
webasm/matmul            130.9        133.8       250.4        145.3       133.9
webgl/heat                61.2         69.9       116.1         60.6        59.9
cpu/ncc-template         987.1       1688.9      1692.1       1684.4      1684.5
cpu/histogram           5868.7       2317.8      5544.2       5499.5      2335.7
webgl/compaction         134.5        242.1       127.3        126.6       126.5
cpu/canny               1059.1        845.3       500.4        848.8       561.3

webasm/matmul and webgl/heat: confirmed T1, confirmed fixed. One outlier each, in the T1 arm, and three arms agreeing within noise. heat returns to 59.9 against a released 61.2, matmul to 133.9 against 130.9.

cpu/ncc-template: confirmed branch regression, and the most solid number I have. 1688.9 / 1692.1 / 1684.4 / 1684.5 — four arms within 0.5% of each other, against released 987.1. A stable 1.71× regression that is not the pass, not T1, and not noise. Your pluginMatchSource() lead is the thing to chase; ncc-template uses addFunction, which is exactly the case you describe as newly matching.

cpu/histogram: I have to withdraw my 0.42×. The four arms are 2317.8 / 5544.2 / 5499.5 / 2335.7 — two modes about 2.4× apart, assigned to arms with no relation to T1. It is bimodal in this harness, and my A/B assigned the modes to conditions by luck. Your own measurement is the trustworthy one: 0.84× at 3072×256, 0.99× at 1024×64. A real ~16% T1 cost, not the 2.4× I reported. The dump you did — T1 as the pass's only change to that kernel — stands regardless; it was my magnitude that was wrong.

Your two questions

cpu/canny holds. 845.3/500.4 then 848.8/561.3 — the OFF arms agree within 0.4%, so this is a real pass win of roughly 1.5–1.7×, and 1.51× with T1 off. Some of the original 1.69× was T1, most of it was not.

webgl/compaction does not hold, and never did. 242.1 / 127.3 / 126.6 / 126.5. Three arms agree; the single 242.1 in the first OFF arm produced the entire 1.90×. compaction is also the worst cell in the webgl column's floor at 48%. Please drop that one from anything you were weighing — it was my measurement error, not a property of the pass.

Two possible new ones, offered as leads and not findings

Both show one slow arm, in 154cbb0 ON:

webgl2/path-trace          8.7      4.7        4.7          4.7        8.1
webgpu/gradient-descent   10.6      5.2        5.1          5.0        7.0

Three arms agree, the T1-off ON arm is slow. That is the shape you would get if T1 had been masking a cost in the remaining transforms on those two cells — but it is also the shape of a single noisy arm, and both columns have p90 floors (11.7% and 23.8%) that make one sample untrustworthy. I would not act on either until they repeat. If it would help I can run repeat arms on just those two.

Method fix on my side

For anything further I will run each condition at least twice and report a cell only when both arms agree, rather than relying on a single arm plus a control column that turns out to be ten times quieter than the thing being controlled.

Gates on 154cbb0 in this harness: 0 WRONG in both arms, 180 cells each, no fallback markers, executorKind identical. Per-column OFF/ON medians 1.000–1.013×, so the pass remains median-neutral here with the wins and losses at the tails.

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