Repository navigation
feat(webgpu): add enableMatmulFp32Accumulation provider option for MatMulNBits - #29599
Conversation
β¦nt overflow Problem: - fp16 and q4f16 models (Gemma 3 270M, Whisper Q4, SmolLM2, kokoro-js) produce garbage output on WebGPU. f16 max is ~65504; summing 2048+ dot-product terms overflows to +Inf, which propagates as NaN through LayerNorm/Softmax. - CPU/WASM path is unaffected (MLAS accumulates in f32 internally). Fix (JSEP path): - matmulnbits.ts: both kernels (default and BlockwiseMatMulNBits32) now accumulate in f32 (workgroup_shared / inter_results). Explicit vec<f32>() casts per operand are required: Dawn/D3D12 re-demotes temporaries to f16 when 'enable f16;' is active. Output downcast to f16 only at the write. - matmul-shaders.ts (naive MatMul): values accumulator promoted to f32. - 3rd-party/matmul_packed_webgpu.ts (tiled MatMul, vec4 + scalar variants): acc promoted to f32; tiles (mm_Asub/mm_Bsub) stay in f16, so there is no shared-memory or bandwidth regression. Fix (native WebGPU EP): - contrib_ops/webgpu/quantization/matmul_nbits.wgsl.template: inter_results and the final reduction accumulate in f32 along K; downcast at output write. This aligns the kernel with matmul_nbits_wide_tile.wgsl.template, which already accumulates in f32. Testing: - scripts/verify_f16_fix.py: static source check - all checks PASS - cd js/web && npx tsc --noEmit: clean; prettier: clean - Manual browser test on Chrome 121+ with Gemma 3 270M FP16 and SmolLM2 360M Q4F16 via transformers.js Performance note: f32 accumulators only affect register-level computation; weights/activations remain f16/q4 in GPU memory, so memory bandwidth (the actual bottleneck) is unaffected. Fixes microsoft#26732 Related: microsoft#26367
|
@microsoft-github-policy-service agree |
Additional real-world validationI validated this fix end-to-end against a production consumer of Method: since this fix only touches the JSEP TypeScript path ( Result (whisper-small, q4, ~36s of Italian audio):
No Only whisper-small was tested this way so far; the fix is generic to the shared MatMul/MatMulNBits kernels rather than model-specific, so I'd expect the same result on tiny/large-v3-turbo (also q4) β happy to confirm those too if useful. Full write-up (methodology + how I'll re-enable WebGPU once this merges): https://github.com/RobertoReale/Voice-Message-Transcriber/blob/main/docs/webgpu-onnxruntime-fix.md |
|
Roberto (@RobertoReale), could you run |
Tianlei Wu (tianleiwu)
left a comment
There was a problem hiding this comment.
Thanks for the fix β the root-cause analysis (f16 dot-product accumulation saturating past 65504 for D >= 2048) is correct and the JSEP-side changes (promoting values/acc/workgroup_shared/inter_results to f32 and downcasting only at the final write) look right. A few points before this is complete:
1. The native WebGPU EP fix is incomplete (high priority). Only matmul_nbits.wgsl.template is patched, but the same overflow-prone f16 accumulation pattern exists in the sibling fused kernels that ORT dispatches for the same models:
matmul_nbits_mlp.wgsl.templateβgate_sum/up_sum = output_element_t(0)andgate_inter_results/up_inter_results : array<array<output_element_t, ...>>matmul_nbits_qkv.wgsl.templateβsum = q_output_element_t(0)andq/k/v_inter_results : array<array<q_output_element_t, ...>>dp4a_matmul_small_m.wgsl.templateβinter_results : array<array<output_element_t, ...>>
The MLP and QKV fused kernels are selected precisely for the fused MLP / attention-QKV subgraphs that Gemma 3 / SmolLM produce, so those paths can still emit garbage for the models this PR targets. Either extend the same f32-accumulation change to these templates or explicitly document why they are out of scope.
2. Shared-memory / occupancy claim is imprecise. "Zero bandwidth regression / no shared-memory regression" is accurate for the A/B tiles (they stay in the input dtype), but the accumulator workgroup arrays (inter_results, workgroup_shared) do double in size (f16 -> f32) for fp16/q4f16 outputs. On SLM-bound dispatch configs this can lower occupancy. Correctness rightly wins here, but the description/comments should acknowledge the accumulator SLM growth rather than claim zero regression.
3. Missing automated regression coverage. Validation currently relies on manual browser runs plus a static grep script. A numerical unit test (large-K fp16 MatMul / MatMulNBits compared against an f32 reference) in the existing WebGPU op test suite would guard against this regressing again and would exercise the actual runtime rather than source strings.
Inline comments below.
|
Hi everyone, |
Could you share the links to these models? |
β¦els, drop verify script - matmul_nbits_mlp.wgsl.template: accumulate gate/up projection sums and workgroup inter_results in f32; apply bias and SiLU activation in f32; downcast to output_element_t only at the final store. - matmul_nbits_qkv.wgsl.template: accumulate q/k/v projection sums and workgroup inter_results in f32; downcast at the final store. - dp4a_matmul_small_m.wgsl.template: accumulate inter_results and the final reduction in f32 (SDP8AI partial products are exact integer dots; the cross-tile accumulation over K was the overflow path); downcast at the final store. dp4a_matmul_common.wgsl.template is shared with other kernels and is intentionally left unchanged. - Remove scripts/verify_f16_fix.py per review feedback (static source check, not a runtime test; also failed lintrunner RUFF-FORMAT). Weights, activations and all buffer traffic remain fp16/q4; only register/workgroup accumulators are promoted, so memory bandwidth is unchanged.
Same overflow pattern as dp4a_matmul_small_m: per-lane register accumulators (lane_outputs / lane_output1..4) summed output_element_t partial dot products across the whole K loop. Promote them to f32 and downcast to output_element_t only at the final store; SDP8AI itself and all buffer types are unchanged.
|
Tianlei Wu (@tianleiwu) Thanks for the review β addressed in 2f2a4b3 and 53c9320: Fused/native kernels extended to f32 accumulation (same pattern as the generic kernel: buffers and dequantized weights stay fp16/q4, only accumulators are promoted, downcast at the final store):
Explicitly excluded: the
|
|
Jianhui Dai (@daijh) Thanks for taking a look. Model links (these are the reports collected in #26732 / #26367):
On register pressure β a few data points for the discussion:
That said, if your measurements on Intel show a significant regression, I'm open to gating β either per-vendor, or (probably better, since overflow risk scales with reduction length) promoting only when K exceeds a threshold. Looking forward to your repro results and numbers; happy to iterate on whatever the data shows. |
WGSL has no implicit conversions: constructing vec4<output_element_t> directly from f32 scalar components is a compile error when output_element_t is f16. Build a vec4<f32> first, then use the whole-vector conversion constructor. Verified with naga (wgpu-native): the direct mixed-scalar constructor is rejected, the vector-to-vector conversion compiles for both f16 and f32 output types.
|
Follow-up on 53c9320: while compile-validating the changed shader patterns with naga (wgpu-native), I caught a type error I had introduced in the |
I tested these two models using the WebGPU build with the fp16 accumulator from #29611, but I am unable to reproduce the reported issues.
Could you verify if this still happens on a recent ONNX Runtime build and share a test page to help us reproduce the issue? |
|
Jianhui Dai (@daijh) Thanks for testing β I believe I can explain the non-repro: the fp16 Gemma weights on the Hub today are not the ones the bug was reported against. On 2025-12-02 the fp16 exports of Reproduction with the same model, pinned to the pre-mitigation revision
const gen = await pipeline('text-generation', 'onnx-community/gemma-3-270m-it-ONNX', {
dtype: 'fp16',
device: 'webgpu',
revision: '2950c41f44e1799fa9a42c2dc9fd2648fee8fb4f', // last commit before the clip mitigation
});I just ran this on stock
Same weights: correct on WASM, garbage on WebGPU. The current Self-contained test page (serve locally, open in Chrome;
|
Roberto (@RobertoReale) By the way, regarding the previous issue, were you using the legacy JSEP or the WebGPU EP? |
Legacy JSEP β transformers.js 3.8.1 bundles So I re-ran everything on the native WebGPU EP with a recent build β transformers.js 4.2.0 β 1. On the native EP, the pre-clip Gemma model fails before it can overflow
2. A minimal test that needs no model download β and why it passes on IntelSelf-contained page (below): a 119-byte embedded ONNX model, On my Intel Iris Xe: WebGPU returns exact 0 β no overflow, even though the generated That looks like evidence that f16 accumulators are fine β until you probe the same device with raw WGSL:
The Intel shader compiler evaluates straight-line f16 chains at higher precision and only rounds to f16 across loop iterations. ORT's native MatMul kernels are heavily unrolled, so on Intel they get f32 accumulation silently, from the driver β which is why the f16-accumulator builds look correct there. The JSEP kernels accumulate in And it does not transfer across vendors. The same probes via wgpu-native:
Six configurations, six behaviors. This is all WGSL-conformant β implementations are allowed to evaluate floating-point expressions with extra intermediate precision β and that's precisely the problem: whether an f16 accumulator overflows is an accident of vendor Γ driver Γ code shape. A kernel validated with f16 accumulators on Intel says nothing about NVIDIA/Qualcomm/Apple, and even on Intel it flips between D3D12 and Vulkan and between looped and unrolled codegen. The explicit f32 accumulator is the only way to make the guarantee portable β and on hardware whose compiler already promotes intermediates (Intel), it changes essentially nothing, which is consistent with the neutral perf numbers discussed earlier. (Side note for scope: the native EP's plain fp16 Test page β save as .html, serve locally, open in Chrome (
|
|
This is a great deep dive! |
|
Thanks Jianhui Dai (@daijh) β really appreciate you not blocking, and the offer to follow up. One thing I want to correct before we settle on the gating, because I think it changes the conclusion: this isn't NVIDIA-specific. The failure that #26732 was actually reported against runs on Intel too β my original garbage repro ( The reason your test passes on Intel while mine fails on the same vendor is the piece I probed in the deep-dive, and it's the key point for gating:
So the property "f16 accumulation is safe here" isn't On cost: I don't think there's a measured regression to gate around yet. The change is accumulator-only (weights/activations stay f16/q4 in VRAM, downcast at store), and the in-tree wide-tile kernel already accumulated in f32 with the comment "minimal performance impact compared to an f16 accumulator" β which #29611 is now removing. If you do have Panther Lake numbers showing f32 accumulation costs measurably on a specific fast path, I'd genuinely like to see them β that's exactly the input that should drive any narrowing. And that's the constructive version I'd propose instead of a vendor gate: default to f32 (correct everywhere), and opt down to f16 only where it's both proven safe and proven to cost. Your Last thing, tying the two PRs together: if #29611 lands the wide-tile accumulator as |
|
IMO, JSEP actually gets less maintenance. I'm really looking forward to a test page where a language model inference session causes an overflow on an Intel GPU via either D3D or Vulkan, because of a |
|
Thanks Jianhui Dai (@daijh) β completely agree on JSEP getting less maintenance than the C++ Native WebGPU EP, and that is why this PR updates both paths (the TS generators in On why As explored in the raw-WGSL probes (#issuecomment-4923584353), the reason However, on Intel via Vulkan ( Because This is why I am 100% supportive of your idea to use |
|
Tianlei Wu (@tianleiwu) Jiajia Qin (@qjia7) Hariharan Seshadri (@hariharans29) Jianhui Dai (@daijh) β I've corrected this PR's description, because its central claim was wrong. I had been citing Gemma 3 270M as the real-model proof of an f16 accumulator overflow, and asserting that the Clip nodes added to the Hub model on 2025-12-02 were a workaround for it. I finally checked that instead of arguing it, and it is false:
So #26732's root cause is a model-level fp16 residual overflow, and this PR does not fix it. I've removed What this PR still rests on: whisper-small q4, which is a genuine end-to-end accumulator overflow on the JSEP path (garbage on WebGPU β word-for-word WASM parity with only the accumulator promoted), plus the portability argument for strict-rounding backends. That is narrower than what I originally wrote, and Jianhui Dai (@daijh) was right to keep pushing for a real test case rather than accept the synthetic ones. I'd rather you review this on what it actually demonstrates. If you'd prefer I re-scope it further (e.g. JSEP-only, or hold the native-EP hunks until Jianhui Dai (@daijh)'s A/B packages report), say so and I'll do it. |
Every MatMulNBits test in matmul_4bits_test.cc generates its inputs from Gaussian(0, 0.25), which keeps the running partial sum along K in the single digits even at K=11008, so none of them can reach the f16 ceiling of 65504. Add a case where the partial sum crosses it while the inputs and the exact result stay inside f16 range: A=8, dequantized B=+112 over the first half of K and -112 over the second, K=8192, M=1 so the dispatch lands on matmul_nbits.wgsl.template. A lane peaks at 114688 halfway through K and cancels back to an exact 0. With an f32 accumulator the output is exactly 0; with an f16 accumulator it saturates to +Inf. The block-local sum stays in output_element_t and peaks at 28672, so the test isolates the accumulator carried across K.
|
Two things I owed this PR from Tianlei Wu (@tianleiwu)'s review. The unit test is in, It's a guard, not a detector: on Intel D3D12 dxc promotes unrolled f16 chains by itself, so it can pass there even with an f16 accumulator. That's stated in the comment above the test. Second, the description claimed zero regression, which was only true of global memory. The accumulator workgroup arrays do double for fp16 outputs. Corrected. |
β¦tion
Adds the WebGPU EP provider option "preferredMatmulAccumulatorPrecision"
("f16" / "f32") proposed in microsoft#29611, and makes every MatMulNBits kernel this
branch touches read it instead of hardcoding f32.
Plumbing follows the existing kvCacheQuantizationBits option: constant and
value strings in webgpu_provider_options.h, parsing in
webgpu_provider_factory.cc, config field plus accessor on the EP, and an
inline forwarder on ComputeContext so kernels can read it.
The five WGSL templates gain a compile-time `acc_f32` parameter selecting
`alias acc_element_t = f32` or `= output_element_t`, and the accumulators,
their reduction and the bias adds use that alias. Only the accumulator moves:
the block-local dot products keep the precision they have today, so the f16
setting rounds at the block boundary, which is the same shape measured in the
Gemma writeup. acc_f32 is added to every affected CacheHint so the two
variants cannot collide in the program cache.
The shipped default lives on a single line
(WebGpuExecutionProviderConfig::matmul_accumulator_precision_f32) and is f32
here, which keeps current behaviour; flipping it to f16 is a one-line change
if that is where the review lands.
Not compiled: no build tree available. Verified by running the in-tree
tools/python/wgsl_template generator over contrib_ops/webgpu, which parses all
five templates and emits both alias branches for each. The JSEP shaders are
untouched: they read env.webgpu and never see EP provider options, so this
option does not reach them.
Matches the default agreed on microsoft#29611: the option ships opt-in to f32, the native kernels keep accumulating in output_element_t unless asked otherwise.
|
Done, pushed as Native side is the constant with One thing I ran into reading ahead to step 2. All four entries in Eight weeks in, |
There was a problem hiding this comment.
π‘ Changes recommended
DP4A still performs some scaling in f16 before widening, and several option-specific test paths are ineffective or uncovered.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
onnxruntime/contrib_ops/webgpu/quantization/dp4a_matmul_common.wgsl.template:638
- This overload has the same premature f16 multiplication for 4-bit and 2-bit paths: casting the already-scaled result to
acc_element_tcannot recover from overflow. Make the shared multiplication precision f32 wheneveracc_f32is enabled, without regressing the existing 8-bit f32 multiplication.
return acc_element_t(mul_precision(local_sum) * mul_precision(scale));
onnxruntime/test/contrib_ops/matmul_4bits_test.cc:1009
- In plugin/adapters builds this helper can return null when WebGPU is unavailable, so
RunTestreceives an invalid EP and fails instead of skipping. Check the provider before adding it to the vector, as other WebGPU-specific tests do.
execution_providers.push_back(WebGpuExecutionProviderWithOptions(config_options));
onnxruntime/contrib_ops/webgpu/quantization/matmul_nbits.wgsl.template:240
- Issue #26732 does not establish this Dawn/D3D12 temporary-demotion behavior, and the PR description explicitly retracts that issue's MatMulNBits attribution. Remove the stale issue reference so the implementation comment does not misstate the report's cause.
// Explicit cast: Dawn/D3D12 re-demotes temporaries to f16 when
// 'enable f16;' is active (issue #26732).
inter_results[local_row_offset + idy][idx] += acc_element_t(sum);
- Files reviewed: 23/23 changed files
- Comments generated: 6
- Review effort level: Balanced
wgsl_gen.py sorts template parameters, and a_length_per_tile sorts before acc_f32, so passing acc_f32 first made the designators run out of declaration order in the three APPLY lists that have both. C++20 requires designated initializers in declaration order; GCC, Clang and MSVC all rejected it (-Werror=missing-field-initializers / -Wreorder-init-list / C7560) in matmul_nbits.cc, matmul_nbits_mlp.cc and matmul_nbits_qkv.cc. The wide-tile and the two dp4a call sites were already correct, since acc_f32 sorts first when no a_length_per_tile is present. Also applies clang-format to the constructor initializer lists in dp4a_matmul_nbits.h and to the two new tests.
|
The pipelines ran, and they caught a real bug on the first attempt. Fix pushed as
The part worth recording: on I also merged |
|
Roberto (@RobertoReale) Please resolve Copilot's comments, thanks. |
β¦nel tests - dp4a_matmul_common: mul_precision now follows acc_element_t for 4- and 2-bit. It stayed at output_element_t there, so the scaled tile result rounded (and could saturate) in f16 before reaching an f32 accumulator, leaving the main DP4A path effectively ignoring the option. 8-bit keeps its unconditional f32. - matmul_4bits_test: build the WebGPU EP and skip before constructing the OpTester, since WebGpuExecutionProviderWithOptions returns nullptr on a dynamic-plugin build and both ConfigEps and the tester destructor object to it. - Float16_LargeK_AccumulatorOverflow now runs M=1 and M=8, so the wide-tile dispatch gets the same numerical case as the generic one. - New option-enabled fusion tests for the fused MLP and QKV decode kernels, which are only reachable through graph fusion and had no coverage with the option on. - Drop the microsoft#26732 attribution from the shader comments; the PR retracted that issue as motivation and the comments still claimed it.
|
Pushed The dp4a one was the consequential one.
The fused MLP and QKV comments were fair, and I was wrong in August when I said I had no entry point for them. I also dropped the The one I have not fully done is the discrimination in |
The 2026-09-07 run is the first that executed these two tests on a non-Apple
adapter, and both failed on the D3D12 lanes.
Float16_AccumulatorPrecisionOption_AllPaths: only the {M=8, K=4096,
accuracy_level=4} case fails, and it fails identically with the option off and
on, so it is not the accumulator. That case is the only one that actually
reaches CanApplyDP4AMatrixMatMulNBits, which int8-quantizes A while the CPU
reference does not; at K=4096 that quantization sets the error floor at ~0.08
against a 0.055 bound. Give the accuracy_level 4 cases 0.15/0.03 and leave the
others at 0.055/0.02. Upstream Float16_Large already allows 0.1 at this K for
accuracy_level 0.
MatMulNBitsMlpFusionMatchesUnfusedSimplifiedWebGpuResultsWithFp32Accumulation:
with acc_element_t = f32 the fused kernel keeps the gate and up sums, the
biases, the SiLU and the gate * up product in f32 and rounds once at the store,
while the unfused baseline rounds at gate_out, up_out, the Sigmoid output and
the SiLU Mul output. Four roundings against one, with the nonlinearity between
them, is a ~1.2% gap on the largest outputs, so 1e-3 was never the right bound
for the option-on comparison. Use 2e-2/2e-2. The QKV test keeps 2e-3/5e-3
because that fusion has no activation.
Also correct the dispatch comment: M=2 does not reach dp4a for an fp16 output,
because CanApplyDP4AMatrixMatMulNBits requires M >= kMinMForTileOptimization.
|
CI finally ran on
The MLP fusion failure is my mistake in the other direction: I copied 1e-3 from the option-off test. With These lanes are non-Apple, so dp4a executed for the first time. The other red WebGPU jobs are the gradle wrapper timing out on |
There was a problem hiding this comment.
π‘ Changes recommended
The generic kernel can still overflow locally before widening, and DP4A behavior lacks a discriminating regression test.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
onnxruntime/test/contrib_ops/matmul_4bits_test.cc:1022
- This test explicitly admits that an ignored flag still passes and that no DP4A case numerically distinguishes the two accumulator modes. Consequently, a missing DP4A propagation or cache-key discriminator can pass despite the earlier claim that this test catches it. Add a DP4A-specific overflow/precision case, or directly assert the generated variant/cache key, so the changed DP4A behavior is actually regression-tested.
// answer to within the tolerance, so a run that quietly ignored the flag would still pass here. The
// numerical discrimination lives in the test above, and only for the two dispatches it reaches. The
// dp4a paths have no equivalent case: A is int8-quantized before the kernel sees it, so a construction
// whose exact result stays in range while the f16 prefix saturates needs a different setup, and I have
// not written one.
- Files reviewed: 25/25 changed files
- Comments generated: 3
- Review effort level: Balanced
β¦rnel matmul_nbits.wgsl.template built its block-local `sum` in output_element_t and widened only when adding into inter_results, so with the option on a prefix of up to 32 products (64 for n_bits == 2) still ran in f16 and could saturate inside the tile. The wide-tile, QKV and MLP variants already widen at the dot operands; this makes the generic kernel do the same. The operands are widened through an explicit vec4<output_element_t> constructor: WGSL has no implicit f16 -> f32 conversion, so a component-wise vec4<f32>(vec2<f16>, vec2<f16>) is rejected. Checked with naga for both branches of the alias and all three component_a shapes. Also document that the fused epilogue follows the accumulator type: the fused MLP applies bias, SiLU and the gate/up product to the f32 accumulators and rounds once at the store, which is why its tolerance is looser with the option on. The provider option and the JS binding both said "only the accumulators change", which understated it.
|
Pushed
The other two were fair. "Only the accumulators change" is wrong for the fused MLP, where the bias, the SiLU and the gate/up product all run on the f32 accumulators and round once at the store, which is exactly what the 2e-2 tolerance is paying for. Corrected in the provider option header and in the JS binding, and in the comment above Unrelated: the 33 workflow runs on |
|
The full matrix ran on The one red check is That also retires what I wrote yesterday about the runs never starting: it was true of |
There was a problem hiding this comment.
π‘ Changes recommended
DP4A variants lack deterministic coverage, and the public documentation overstates the optionβs scope.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 25/25 changed files
- Comments generated: 5
- Review effort level: Balanced
|
Overall, this looks good to me. I only have a few cleanup comments. Copilotβs documentation comments are valid: please scope the fused-MLP epilogue description to the decode fast path and clarify that this option currently applies only to the native WebGPU EP. JSEP is being deprecated, so I do not expect this option to be implemented or followed up there; the public documentation only needs to make the current limitation clear. Regarding Copilotβs deterministic-coverage comments, I consider this a broader pre-existing testability limitation in WebGPU MatMulNBits. There are many implementations, and dispatch depends on the shape, output type, adapter capabilities, and vendor. We therefore cannot reliably select a particular algorithm across CI using shapes alone. A better long-term solution is to provide a test mechanism that explicitly selects or forces the intended implementation and verifies which program was dispatched. That mechanism can then cover the small-M DP4A path and the 2/4/8-bit accumulator variants deterministically. I think this should be tracked as follow-up work and should not block this PR. |
Hoist the enableMatmulFp32Accumulation read in ApplyMatMulNBits to a single acc_f32 before the wide-tile branch, shared by both programs and both cache hints. SDP8AI is declared -> acc_element_t, so the outer acc_element_t(...) at its call sites converted the result to the type it already had. Drop it from dp4a_matmul.wgsl.template and dp4a_matmul_small_m.wgsl.template. Scope the fused-MLP epilogue description to the decode fast path: shapes that fall back to ApplyUnfusedMlp materialize gate and up in the output element type before the activation, so their epilogue is unaffected. Also record in the JS option docs that only the native WebGPU EP reads this option; the JSEP backend forwards preferredLayout only and ignores it.
|
Pushed
You are right about On the docs, I scoped the epilogue paragraph to the decode fast path in both Agreed on the coverage comments as follow-up. The dp4a conversation from the earlier round is still open for the same reason as before: A is int8-quantized ahead of the kernel, so I have no numeric case that separates the two accumulators on that path. |
Jiajia Qin (qjia7)
left a comment
There was a problem hiding this comment.
LGTM if all CI pipelines are happy.
Hariharan Seshadri (@hariharans29) Please take a final look.
|
The one red check on Two other runs of that job died the same way earlier today, on branches that are not mine: 34309494961 at 05:36 UTC and 34314249011 at 06:45 UTC. Same annotation, same step 25, and in both cases the job had run about 31 minutes. For what it is worth on the js side, the only difference between A re-run of that single job should clear it. I cannot trigger one from a fork. |
Summary
Adds the WebGPU EP provider option
enableMatmulFp32Accumulation(boolean, defaultfalse), and routes every MatMulNBits kernel this PR touches through it instead of hardcoding the accumulator type. Weights and activations are untouched: only the register/workgroup accumulators change, so global memory traffic is identical either way.Default behaviour on
mainis unchanged by this PR.Scope of this PR, and what follows it
This is the first of the three changes agreed in #29599 (comment):
enableMatmulFp32Accumulationcovering MatMulNBits and its fused variants. The six kernels below, plus the EP plumbing and the JS bindings. The unquantizedMatMul,GemmandConvkernels undercore/providers/webgpu/math/andcore/providers/webgpu/nn/do not read this option and are unchanged by it.componentType = F16/resultComponentType = F32when the adapter reports exactly that configuration, and keep the fallback below for adapters that do not.MatMul,Gemm, the shared packed implementations and the MatMul-backedConvpaths β with the covered set defined and documented there.The option is named for the final scope rather than for what it covers today, so that (3) does not need a second rename. Until (3) lands, "enable f32 accumulation" means MatMulNBits and its fused variants only.
Session-scoped, not a model attribute. It is an EP provider option, so nobody has to re-export a model to use it and it does not depend on the model carrying an
accuracy_level.What I am not claiming
I have no real model that overflows one of these accumulators. Not Gemma 3 270M (the f16 partial sum inside all 126 MatMulNBits nodes peaks at ~324; the overflow there is the model-level fp16 residual stream). Not whisper-small q4 (that model has no fp16 tensor at all, so these hunks are inert on it; its real saturation is a
Powinside an unfused decomposed LayerNorm, filed separately as #31626). A/B runs on Qwen2.5-0.5B q4f16, gemma-3-270m and Phi-4-mini across two GPUs showed zero Inf and zero NaN either way, only rounding drift.What holds up is portability, and only that. WGSL permits extra intermediate precision: Intel's D3D12 compiler (dxc) promotes unrolled
acc +=chains to f32 on its own but rounds to f16 between loop iterations, while NVIDIA/Vulkan rounds strictly (probes). So "f16 accumulation is safe" is a property of vendor Γ backend Γ code shape, not of a vendor β which is why a vendor-string gate cannot express it, and why a kernel carrying an accumulator across a K loop has no headroom guarantee on strict-rounding configurations. That argues for a way back, which is what this option is. It does not argue for changing the default, and I am not asking for that.What changed
Native WebGPU EP (
onnxruntime/contrib_ops/webgpu/quantization/):matmul_nbits,matmul_nbits_wide_tile,matmul_nbits_mlp,matmul_nbits_qkv,dp4a_matmul,dp4a_matmul_small_m. Each template gains a compile-timeacc_f32parameter selectingalias acc_element_t = f32or the output element type; the accumulators, their reduction and the bias adds use that alias, and the downcast happens only at the store.acc_f32is added to every affectedCacheHintso the two variants cannot collide in the program cache.SDP8AIindp4a_matmul_common.wgsl.templatereturnsacc_element_tβ it is an exact integerdot4I8Packeddot product, but it must not downcast before the value reaches the accumulator. Themul_precisionalias in the same file, which scales that dot product, followsacc_element_ttoo for 4- and 2-bit weights; 8-bit keeps its unconditional f32 becausedot4I8Packedcan return an int32 that f16 cannot represent whatever the accumulator is. Without that, the scaled tile result rounded (and could saturate) in f16 on its way into an f32 accumulator, so the main DP4A path was not honouring the option.subgroup_matrix_matmul_nbits_*cannot honour an f32 request today: every fp16 entry insupported_subgroup_matrix_configshasresultComponentType == F16and the kernels declaresubgroup_matrix_result<f16, ...>. Rather than ignore the option there,CanApplySubgroupMatrixMatMulNBitsdeclines when f32 accumulation is requested for an fp16 output, so the dispatch falls through to a kernel that does honour it. fp32 outputs already accumulate in f32 and are unaffected. Follow-up (2) above replaces the decline with a mixed-precision config where the adapter offers one.kvCacheQuantizationBitsoption: constant and"0"/"1"value strings inwebgpu_provider_options.h, parsing inwebgpu_provider_factory.cc, a config field plus accessor on the EP, and an inline forwarder onComputeContext.JS bindings: the option is typed as a boolean in
js/common/lib/inference-session.ts, forwarded as"0"/"1"fromjs/web/lib/wasm/session-options.tsfollowing theenableGraphCaptureprecedent, and accepted as a boolean by the node bridge injs/node/src/session_options_helper.ccfollowingenableRobustness, soonnxruntime-webandonnxruntime-nodeusers can actually set it.The JSEP hunks have been removed (
matmulnbits.ts,matmul-shaders.ts,3rd-party/matmul_packed_webgpu.tsare back tomain). JSEP shaders readenv.webgpuand never see EP provider options, so the option cannot reach them; leaving them in would have meant shipping a PR where the native side is opt-in and the JSEP side is switched unconditionally for everyone. Adding a parallelenv.webgpuflag to restore the symmetry means putting an option into a path #29716 is removing, so if JSEP needs this before it goes away it should be its own change.Testing
MatMulNBits.Float16_LargeK_AccumulatorOverflowinmatmul_4bits_test.cc, built with the option on. A=8, dequantized B at +112 over the first half of K and -112 over the second, K=8192. It runs at M=1 and M=8, so it covers bothmatmul_nbits.wgsl.template(K is split overtile_size_k_veclanes, a lane peaks at 114688) andmatmul_nbits_wide_tile.wgsl.template(no cross-lane split,results[m]carries the whole prefix and peaks at 3670016, still exact in f32). Either way the sum cancels back to an exact 0, so f32 returns exactly 0 while f16 saturates to +Inf. Every other MatMulNBits test in that file draws its inputs from Gaussian(0, 0.25) and cannot reach 65504 at any K, which is why this class of bug had no coverage. It is a regression guard, not a universal detector: on Intel D3D12 it can pass with an f16 accumulator, and the comment above it says so.MatMulNBits.Float16_AccumulatorPrecisionOption_AllPathsruns the generic, wide-tile and dp4a dispatches with the option in both states on ordinary Gaussian inputs, so a variant that fails to compile, an unpropagated flag or a cache hint that cannot separate the two variants shows up as a test failure. On Gaussian inputs both accumulator widths agree inside the tolerance, so this one is a smoke test over the dispatches; the numerical discrimination is in the case above, and the dp4a paths have none because A is int8-quantized before the kernel sees it.MatMulNBitsMlpFusionMatchesUnfusedSimplifiedWebGpuResultsWithFp32AccumulationandMatMulNBitsQkvFusionMatchesUnfusedWebGpuResultsWithFp32Accumulationrun the existing fused-vs-unfused comparison with the option on for both the baseline and the fused session.tools/python/wgsl_gen.pyovercontrib_ops/webgpuparses all six templates and emits both alias branches for each. The changed shader shapes are compile-checked with naga in both accumulator states, with negative controls confirming the harness rejects an f32 accumulator stored to an f16 output without conversion.tsc --noEmitis clean onjs/commonandjs/web, and both changed TypeScript files passprettier --check.acc_f32was passed first in threeWGSL_TEMPLATE_APPLYlists, butwgsl_gen.pysorts parameters anda_length_per_tilesorts ahead ofacc_f32, so the designators were out of declaration order. GCC, Clang and MSVC all rejected it; fixed in9a2154314.webgpu / build-and-test (macOS arm64), which builds without-Werrorand so got past the ordering bug:Float16_LargeK_AccumulatorOverflow(216 ms) andFloat16_AccumulatorPrecisionOption_AllPaths(3004 ms) on Metal. I still have no local build tree, so the rest of the matrix is CI's word rather than mine.Performance
f32 accumulators cost registers and shared memory, not bandwidth β the accumulator workgroup arrays double for fp16 outputs, which can lower occupancy on SLM-bound dispatch configurations. Jianhui Dai (@daijh) measured that cost on Intel, and #29611 is the conclusion drawn from it. Since the option defaults to off, nobody pays for it unless they ask.
Related: #29611, #29716, #31626, #26732, #26367, #31703 (both touch
matmul_nbits_wide_tile.wgsl.template)