Compiler optimizations: hoisting, inlining, unrolling and coordinate localization across every emitting tier - #872
Compiler optimizations: hoisting, inlining, unrolling and coordinate localization across every emitting tier#872fuzzie360 wants to merge 9 commits into
Conversation
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
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
|
T2 is now backend-conditional (12d83cd), per the open question above. Helper inlining is off on cpu and on everywhere else. The existing 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. |
Benchmark Gauntlet: 30 workloads × 6 columns,
|
| 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.
|
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 —
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: So One repro detail worth passing on: my first matmul harness used a literal 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 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- |
|
Ran it: same build, 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
|
| 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 armBoth 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
|
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.
|
| 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.
|
Ran My noise control was wrong, and it was too generousI set the significance bar from I now have two arms with
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 armsT1 was live in exactly one arm (
Your two questions
Two possible new ones, offered as leads and not findingsBoth show one slow arm, in 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 sideFor 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 |
Every tier that emits code now runs an AST optimization pass;
devis 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).Control:
kernel._optimizerDisabled(internal, the_fusionDisabledprecedent) and the publicloopUnrollLimitsetting (default 8,0disables T3). A build-time throw from the optimizer degrades to an unoptimized build with a loud warning andfallbackReason, 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:
addFunction's declaredreturnType/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.readsCanFaultis now true there, plus areadsFaultAtOneLevelflag because cpu's two-subscript shortcut does not hold for raw loads.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).buildWithOptimizercaught 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.loopUnrollLimitand_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):
astForStatementcrashes on a comma-foldedforinit, 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