Skip to content

[WebGPU] Accumulate MatMulNBits wide-tile in output_element_t - #29611

Open
daijh wants to merge 2 commits into
microsoft:mainfrom
daijh:matmulnbits-f16
Open

[WebGPU] Accumulate MatMulNBits wide-tile in output_element_t#29611
daijh wants to merge 2 commits into
microsoft:mainfrom
daijh:matmulnbits-f16

Conversation

@daijh

@daijh daijh commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Description

Accumulate directly in output_element_t (e.g., f16 for f16 models)
instead of hardcoding an f32 accumulator in the MatMulNBits wide-tile
shader.

Intel Panther Lake

Prefill Length Default Prefill TPS Optimized Prefill TPS Improvement
gpt-oss-20b-ONNX 128 305.70 344.49 113%
gpt-oss-20b-ONNX 1024 396.50 429.80 108%
Phi-4-mini-instruct-ONNX 128 515.90 592.36 115%
Phi-4-mini-instruct-ONNX 1024 615.39 753.40 122%

[1] https://huggingface.co/onnx-community/gpt-oss-20b-ONNX
[2] https://huggingface.co/onnx-community/Phi-4-mini-instruct-ONNX

Motivation and Context

See above.

@daijh

daijh commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

@qjia7 @hariharans29 PTAL

@qjia7

qjia7 commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Accumulate directly in output_element_t (e.g., f16 for f16 models) instead of hardcoding to f32.

Please check #29599, I think using f32 accumulators is the right direction.

Could you split the refactoring changes into a separate PR? That would make it much easier to review and focus on the optimization work.

@daijh
daijh force-pushed the matmulnbits-f16 branch from 01782f7 to d4b088f Compare July 8, 2026 07:56
@daijh daijh changed the title [WebGPU] Refactor and optimize MatMulNBits wide-tile shader [WebGPU] Optimize MatMulNBits wide-tile shader Jul 8, 2026
@daijh

daijh commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Let's leave this refactoring for a follow-up PR.

@hariharans29

Copy link
Copy Markdown
Member

Accumulate directly in output_element_t (e.g., f16 for f16 models) instead of hardcoding to f32.

Please check #29599, I think using f32 accumulators is the right direction.

Could you split the refactoring changes into a separate PR? That would make it much easier to review and focus on the optimization work.

I agree - isn't it safer to accumulate in fp32 ? Accumulating in fp16 universally across all workloads (if the op is fp16) seems risky ?

@RobertoReale

Copy link
Copy Markdown

Since #29599 was referenced here — a data point on the accumulator part of this change (the tile-layout optimization itself looks like a clear win and is orthogonal):

The f32 accumulator in the wide-tile kernel isn't just precision hygiene — it's load-bearing for correctness. With f16 accumulation, partial dot-product sums overflow 65504 and saturate to +Inf once K approaches ~2048 (deterministically, not as a gradual accuracy loss), which then propagates NaN through LayerNorm/Softmax. That's the failure class of #26732. Notably, both benchmark models here are in that range (gpt-oss-20b K≈2880, Phi-4-mini K=3072) — the table reports prefill TPS, but was output correctness checked against the f32-accumulator baseline on those runs?

Also, since the PR changes tile layout and accumulator precision together, it would be useful to benchmark them separately — the memory-access refactoring may account for most of the gain, in which case you could keep the speedup without reintroducing the overflow.

If f32 accumulation does turn out to be a measurable cost on Intel in isolation, gating the promotion on K above a threshold (overflow risk scales with reduction length) was floated in #29599 as a compromise that keeps small-K workloads untouched — happy to coordinate there.

@daijh

daijh commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Accumulate directly in output_element_t (e.g., f16 for f16 models) instead of hardcoding to f32.

Please check #29599, I think using f32 accumulators is the right direction.
Could you split the refactoring changes into a separate PR? That would make it much easier to review and focus on the optimization work.

I agree - isn't it safer to accumulate in fp32 ? Accumulating in fp16 universally across all workloads (if the op is fp16) seems risky ?

I think it would be helpful to reproduce the overflow issue in the LLM first, before we decide on our next steps.

Alternatively, we could use the accuracy_level attribute as a hint if accuracy or overflow is a concern. E.g. set accuracy_level to fp32 to enforce fp32 accumulation.
[1] https://github.com/microsoft/onnxruntime/blob/main/docs/ContribOperators.md#commicrosoftmatmulnbits

@RobertoReale

Copy link
Copy Markdown

I think it would be helpful to reproduce the overflow issue in the LLM first, before we decide on our next steps.

Done — details in #29599 (comment) (the non-repro had a concrete cause: the Hub's fp16 Gemma weights were re-exported on 2025-12-02 with Clip nodes inserted as a model-side workaround for this exact overflow). Summary: pinning onnx-community/gemma-3-270m-it-ONNX to the pre-mitigation revision 2950c41f, stock onnxruntime-web on WebGPU produces garbage output while the same revision is correct on WASM — and in my runs the WebGPU adapter was an Intel Iris Xe (gen-12lp), so the overflow is live on Intel hardware too.

On accuracy_level: using it as an explicit opt-down sounds workable — a model that knowingly tolerates f16 accumulation can set accuracy_level = 2 and keep the fastest path, similar to how the dp4a kernels already gate on accuracy_level = 4. But two constraints for it to actually fix #26732:

  1. The unset default (0) must mean f32 accumulation. None of the affected models in the wild set the attribute (transformers.js exports don't), so an f16-when-unset default leaves all of them broken.
  2. It only covers MatMulNBits. The plain fp16 MatMul path — which is what the Gemma fp16 case goes through — has no accuracy_level attribute, so it needs the f32 accumulator unconditionally regardless.

@RobertoReale

Copy link
Copy Markdown

Data point relevant to the f16-accumulator question here: following up on the repro request, I ran raw-WGSL f16 accumulation probes across adapters and backends — full write-up in #29599 (comment).

Short version: on Intel (D3D12) the shader compiler evaluates straight-line/unrolled f16 acc += chains at higher precision and only rounds across loop iterations, so ORT's unrolled native kernels get f32 accumulation silently from the driver — a synthetic MatMul whose f16 partial sums provably exceed 65504 still returns the exact result on the native EP on my Iris Xe. On NVIDIA (Vulkan) the same two-term f16 expression rounds strictly and yields Inf. Both behaviors are WGSL-conformant.

The implication for this PR: correctness results obtained with f16 accumulators on Intel don't transfer to other vendors (or even to Intel on a different backend, or to looped codegen). Demoting the wide-tile accumulator to output_element_t may well benchmark clean and produce correct outputs on Panther Lake, while overflowing on hardware that rounds f16 strictly. An explicit f32 accumulator is the only portable guarantee — and on drivers that already promote intermediates it should be close to free, which matches the neutral perf discussion in #29599.

@daijh

daijh commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

As #29599 (comment)

Since this is an NVIDIA-specific issue, could you gate the fix behind a vendor string check?
Please let me know if any issues arise on Intel hardware, and I'd be happy to follow up.

Since the NVIDIA-specific logic requires fp32 accumulators and will be gated by a vendor string check.
I am fine moving forward with the current PR.
We can leave the fp32 accumulators implementation to PR #29599.

@RobertoReale

Copy link
Copy Markdown

Flagging one interaction for whoever reviews this PR, since the accumulator part is being deferred to #29599 (full reasoning in #29599 (comment)):

The proposal on #29599 is to gate the f32 accumulator behind an NVIDIA vendor-string check. If that lands and this PR demotes the wide-tile accumulator to output_element_t, then q4f16 models routed through the wide-tile kernel overflow on any non-NVIDIA config that rounds f16 strictly — with no f32 fallback. That's the SmolLM2 / Gemma-q4f16 failure class of #26732, and it's not NVIDIA-specific: my original repro of that overflow was on an Intel Iris Xe (via JSEP's looped codegen), and the same Iris Xe rounds strictly on the Vulkan backend. f16 accumulation happening to be safe on Intel/D3D12 here is a property of the driver promoting unrolled chains, not of the vendor.

The tile-layout half of this PR looks like a clean, orthogonal win. The only ask is to keep the accumulator in f32 (or make the demotion opt-in via accuracy_level, defaulting to f32) so the two PRs don't combine into a wide-tile overflow regression. Happy to help with a separate layout-only-vs-accumulator benchmark if that's useful.

@daijh

daijh commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

The proposal on #29599 is to gate the f32 accumulator behind an NVIDIA vendor-string check. If that lands and this PR demotes the wide-tile accumulator to output_element_t, then q4f16 models routed through the wide-tile kernel overflow on any non-NVIDIA config that rounds f16 strictly — with no f32 fallback. That's the SmolLM2 / Gemma-q4f16 failure class of #26732, and it's not NVIDIA-specific: my original repro of that overflow was on an Intel Iris Xe (via JSEP's looped codegen), and the same Iris Xe rounds strictly on the Vulkan backend. f16 accumulation happening to be safe on Intel/D3D12 here is a property of the driver promoting unrolled chains, not of the vendor.

Could you provide a link to the test page where you observed the failure with SmolLM2 / Gemma-q4f16?
I am referring to the LLM inference using the design prompt that produced unexpected results.
I have not been able to reproduce the issue on my end.

@qjia7

qjia7 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

@daijh For the overall 121% improvement for Phi-4 1024, how much did each of these two optimizations contribute individually? I'd like to understand the impact of using FP32 on Intel devices.

@daijh

daijh commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Let me further divide the current PR into multiple smaller ones, with each focusing on a specific optimization.

@daijh
daijh force-pushed the matmulnbits-f16 branch from d4b088f to 77e70a5 Compare July 13, 2026 08:39
@daijh

daijh commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Save original description


Description

  • Store the A workgroup tile as [kTileM][kBlockVecs/2][2] pairs of vecs so the two vecs consumed per dequantized weight column are read in one access.
  • Accumulate directly in output_element_t (e.g., f16 for f16 models) instead of hardcoding to f32.

Intel Panther Lake

Prefill Length Prefill TPS Optimized Prefill TPS Improvement
gpt-oss-20b-ONNX 128 305.70 347.71 114%
gpt-oss-20b-ONNX 1024 396.50 451.51 114%
Phi-4-mini-instruct-ONNX 128 515.90 597.81 116%
Phi-4-mini-instruct-ONNX 1024 615.39 747.07 121%

[1] https://huggingface.co/onnx-community/gpt-oss-20b-ONNX
[2] https://huggingface.co/onnx-community/Phi-4-mini-instruct-ONNX

Motivation and Context

See above.

@RobertoReale

Copy link
Copy Markdown

@daijh @qjia7 — you asked for an Intel repro of an f16 accumulator overflow in MatMulNBits. Here it is, at the level that matters for this PR: the wide-tile kernel's own accumulation shape, on Intel Iris Xe (gen-12lp) through Chrome/Dawn/D3D12 — the same vendor and backend you benchmark on.

Why an LLM-level repro of this cannot exist today

Worth stating plainly, because I think it explains the loop we've been in: matmul_nbits_wide_tile.wgsl.template on main already accumulates in f32. No shipping ORT build can exhibit a wide-tile f16 overflow — the failure this PR is being asked to reproduce is one that this PR introduces. So the honest test is not "run an LLM on stock ORT and see garbage", it is "run the kernel as this PR rewrites it and see whether it saturates". That is what I did.

The probe

I took the inner loop of matmul_nbits_wide_tile.wgsl.template verbatim — block32 tiles, mat2x4 dequantized weights, the two dot() per column, results[] accumulated across n_blocks_per_col — and ran it with results : array<f32, kTileM> (main) vs results : array<output_element_t, kTileM> (this PR), at K = 3072 (Phi-4-mini's reduction length; gpt-oss-20b is K≈2880). Operand values come from a storage buffer, so nothing constant-folds.

Chrome 1xx, Dawn → D3D12, adapter vendor=intel architecture=gen-12lp, shader-f16: true:

case (K=3072) exact results: array<f32> (main) results: array<output_element_t> (this PR)
A — all products positive, a=6.0, dequantized w=4.0 73728 73728 Inf
B — first half of K positive, second half negative (a=6.0, w=8.0) 0 0 Inf

Same shader shape on wgpu-native/Vulkan: Intel Iris Xe → Inf, NVIDIA RTX 3050 Ti → Inf. So this is not NVIDIA-specific, and it is not Vulkan-specific.

Note how ordinary the inputs are. At K = 3072 you only need a mean |a·w| ≥ 21.3 to cross 65504 — an activation of 6 against a dequantized weight of 4 does it. Case B is the more insidious one: the mathematically correct output is 0, well inside f16 range, and the f16 accumulator still returns Inf, which then propagates NaN through LayerNorm/Softmax. That is exactly the #26732 failure signature, and no output-value inspection of the result would predict it.

Why this doesn't contradict your non-repro on Intel

It's consistent with the WGSL probes in #29599 (comment): Intel's D3D12 compiler evaluates straight-line / unrolled f16 acc += chains at higher internal precision, but rounds across loop iterations. In the wide-tile kernel, results[] is carried across the for (block_idx …) loop — so it gets no promotion, and it saturates. The unrolled kernels you tested got f32 accumulation for free from the driver; this one won't. Same GPU, same backend, opposite outcome, purely a function of code shape — which is also why a vendor-string gate can't express the property.

On the benchmark numbers

Branch 77e70a5 now contains only the accumulator demotion — the [kTileM][kBlockVecs/2][2] tile-layout change is gone from the diff (5 additions / 7 deletions, one file). So the 114–121% table in the description no longer measures what this PR does, which I think is precisely @qjia7's question. Splitting the layout optimization into its own PR is the right call and I'd expect it to carry most of that gain — it's a clean win and I have no objection to it whatsoever.

That leaves the accumulator as a standalone question with, as of now, no isolated number attached. The comment this PR deletes says "Utilizing an f32 accumulator mitigated precision loss with minimal performance impact compared to an f16 accumulator" — if Panther Lake now shows otherwise, those numbers would be very interesting and would genuinely change the discussion. Absent them, the trade is a measurable-but-unquantified speedup against a deterministic Inf at K ≥ ~2048 on the exact hardware in the benchmark table.

If f16 accumulation is worth real throughput on Intel, accuracy_level = 2 as an explicit opt-down (as you proposed) gives you all of it on models that declare they tolerate it, mirroring the existing dp4a gate on level 4 — with unset (0) staying f32, since no export in the wild sets the attribute. I'm happy to implement that gate on top of #29599.

Self-contained repro page (save as .html, open in Chrome; no model download, runs in ~1s)
<!doctype html>
<html><head><meta charset="utf-8"><title>MatMulNBits wide-tile accumulator probe</title></head>
<body><pre id="log">running</pre>
<script type="module">
const log = s => { document.getElementById('log').textContent += '\n' + s; };
const K = 3072, NB = K / 32, TM = 4;   // K = Phi-4-mini reduction length; block32
// Inner loop of matmul_nbits_wide_tile.wgsl.template, accumulator parameterized.
const shader = (acc) => `
enable f16;
@group(0) @binding(0) var<storage, read> params : array<f16>;
@group(0) @binding(1) var<storage, read_write> outp : array<f32>;
const KAVecSizeForBlock32 = 8u;
const kTileM : u32 = ${TM}u;
var<workgroup> a_data_tile : array<array<vec4<f16>, KAVecSizeForBlock32>, kTileM>;
@compute @workgroup_size(64)
fn main(@builtin(local_invocation_index) local_idx : u32) {
  let a_val = params[0]; let w_val = params[1]; let flip = params[2];
  var results : array<${acc}, kTileM>;
  for (var block_idx = 0u; block_idx < ${NB}u; block_idx++) {
    let a_row_idx = local_idx / KAVecSizeForBlock32;
    let a_col_idx = local_idx % KAVecSizeForBlock32;
    if (a_row_idx < kTileM) { a_data_tile[a_row_idx][a_col_idx] = vec4<f16>(a_val); }
    workgroupBarrier();
    var s = f16(1.0);
    if (flip != f16(0.0) && block_idx >= ${NB}u / 2u) { s = f16(-1.0); }
    let w = w_val * s;
    let b_dequantized = mat2x4<f16>(w, w, w, w, w, w, w, w);
    for (var b_idx = 0u; b_idx < 4u; b_idx++) {
      for (var m_idx = 0u; m_idx < kTileM; m_idx++) {
        let a_data0 = a_data_tile[m_idx][b_idx * 2u];
        let a_data1 = a_data_tile[m_idx][b_idx * 2u + 1u];
        results[m_idx] += ${acc}(dot(a_data0, b_dequantized[0])) +
                          ${acc}(dot(a_data1, b_dequantized[1]));
      }
    }
    workgroupBarrier();
  }
  if (local_idx == 0u) {
    for (var m_idx = 0u; m_idx < kTileM; m_idx++) { outp[m_idx] = f32(results[m_idx]); }
  }
}`;
async function run(device, acc, params) {
  const module = device.createShaderModule({ code: shader(acc) });
  const f32tof16 = v => { const f = new Float32Array([v]); const u = new Uint32Array(f.buffer)[0];
    const s = (u >> 16) & 0x8000, e = ((u >> 23) & 0xff) - 112, m = (u >> 13) & 0x3ff;
    return v === 0 ? 0 : s | (e << 10) | m; };
  const p = device.createBuffer({ size: 8, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
  device.queue.writeBuffer(p, 0, new Uint16Array([...params.map(f32tof16), 0]));
  const o = device.createBuffer({ size: TM * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
  const rd = device.createBuffer({ size: TM * 4, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
  const pipe = device.createComputePipeline({ layout: 'auto', compute: { module, entryPoint: 'main' } });
  const bg = device.createBindGroup({ layout: pipe.getBindGroupLayout(0),
    entries: [{ binding: 0, resource: { buffer: p } }, { binding: 1, resource: { buffer: o } }] });
  const enc = device.createCommandEncoder();
  const cp = enc.beginComputePass(); cp.setPipeline(pipe); cp.setBindGroup(0, bg);
  cp.dispatchWorkgroups(1); cp.end();
  enc.copyBufferToBuffer(o, 0, rd, 0, TM * 4);
  device.queue.submit([enc.finish()]);
  await rd.mapAsync(GPUMapMode.READ);
  const v = new Float32Array(rd.getMappedRange().slice(0))[0]; rd.unmap();
  return v;
}
try {
  const adapter = await navigator.gpu.requestAdapter();
  const i = adapter.info || {};
  log(`adapter: vendor=${i.vendor} architecture=${i.architecture}  shader-f16=${adapter.features.has('shader-f16')}`);
  const device = await adapter.requestDevice({ requiredFeatures: ['shader-f16'] });
  for (const [name, params, exact] of [
    ['A all-positive  a=6 w=4', [6, 4, 0], 32 * 6 * 4 * NB],
    ['B half+ half-   a=6 w=8', [6, 8, 1], 0],
  ]) {
    const f32 = await run(device, 'f32', params);   // main
    const f16 = await run(device, 'f16', params);   // this PR (output_element_t = f16)
    log(`${name}: exact=${exact}  acc_f32=${f32}  acc_f16=${f16}`);
  }
  log('DONE');
} catch (e) { log('ERROR: ' + e.message); }
</script></body></html>

@daijh daijh changed the title [WebGPU] Optimize MatMulNBits wide-tile shader [WebGPU] Accumulate MatMulNBits wide-tile in output_element_t Jul 13, 2026
@daijh

daijh commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Why an LLM-level repro of this cannot exist today
Worth stating plainly, because I think it explains the loop we've been in: matmul_nbits_wide_tile.wgsl.template on main already accumulates in f32. No shipping ORT build can exhibit a wide-tile f16 overflow — the failure this PR is being asked to reproduce is one that this PR introduces. So the honest test is not "run an LLM on stock ORT and see garbage", it is "run the kernel as this PR rewrites it and see whether it saturates". That is what I did.

I can build two onnxruntime-web packages—one with f16-accumulator and another with f32-accumulator—to confirm the issue on GPUs from Intel and other vendors.

It is worth noting that MatMulNbits-DP4A in the released onnxruntime-web package also uses an f16 accumulator, which might exhibit the same overflow issue.

@RobertoReale

Copy link
Copy Markdown

I can build two onnxruntime-web packages—one with f16-accumulator and another with f32-accumulator—to confirm the issue on GPUs from Intel and other vendors.

That's exactly the right experiment — thank you. Two A/B packages differing only in the accumulator, run on real models across vendors, settles both the correctness question and the cost question at once. If it helps, my falsifiable prediction for it: the f16 package produces Inf/NaN (garbage tokens, or a hard [Music]-style loop) on any q4f16 / fp16 model with K ≥ ~2048 once activations get large, on strict-rounding configs and on Intel wherever the accumulator is carried across a loop; the f32 package matches WASM word-for-word. Happy to run both packages on my side too — Intel Iris Xe (D3D12 + Vulkan) and NVIDIA RTX 3050 Ti — and report back.

It is worth noting that MatMulNbits-DP4A in the released onnxruntime-web package also uses an f16 accumulator, which might exhibit the same overflow issue.

Confirmed — and it does. Same probe as above, this time replicating the shipped dp4a_matmul.wgsl.template shape verbatim: lane_output1..4 : vec4<output_element_t> accumulating the return of SDP8AI() (which is output_element_t(mul_precision(local_sum) * mul_precision(scale)), and for n_bits == 4 mul_precision = output_element_t) across the kidx_v loop over K.

Chrome / Dawn / D3D12, Intel Iris Xe (gen-12lp), K = 3072, int8 A, dequantized q4 B, scale = 1:

case exact lane_output : vec4<f32> (#29599) lane_output : vec4<output_element_t> (shipped today)
A — all-positive, a_i8 = 6, b_i8 = 4 73728 73728 Inf
B — first half of K positive, second half negative, a_i8 = 12, b_i8 = 4 0 0 Inf

Note these are unremarkable quantized values — an int8 activation of 6 or 12 against a dequantized 4-bit weight of 4. dot4I8Packed itself is exact (integer); what saturates is the f16 accumulation of the scaled per-tile partials across K, which is precisely the path you identified. Case B is the dangerous one: the true output is 0, comfortably inside f16 range, and the kernel still returns Inf → NaN through the next LayerNorm/Softmax. That is the #26732 signature in shipped code, not in a hypothetical.

This is already fixed in #29599: dp4a_matmul.wgsl.template (lane_outputs / lane_output1..4) and dp4a_matmul_small_m.wgsl.template (inter_results and the final reduction) both accumulate in f32 there, with the downcast at the store; SDP8AI is left alone since the integer dot product is exact. So if your A/B build confirms DP4A on real models, the fix for it is already written and reviewed — happy to rebase or split it out into its own PR if you'd rather land the DP4A part first, independently of the wide-tile discussion.

DP4A repro page (save as .html, open in Chrome; no model, no download)
<!doctype html>
<html><head><meta charset="utf-8"><title>dp4a f16 accumulator probe</title></head>
<body><pre id="log">running</pre>
<script type="module">
const log = s => { document.getElementById('log').textContent += '\n' + s; };
const K = 3072, ITERS = K / 32;   // each SDP8AI covers 32 k-values (a1+a2 = 8 u32 = 32 int8)
// Verbatim shape of the shipped dp4a_matmul.wgsl.template accumulation.
const shader = (acc) => `
enable f16;
@group(0) @binding(0) var<storage, read> ab : array<u32>;
@group(0) @binding(1) var<storage, read> sc : array<f16>;
@group(0) @binding(2) var<storage, read_write> outp : array<f32>;
alias output_element_t = f16;             // f16 model
alias mul_precision = output_element_t;   // n_bits == 4, per dp4a_matmul_common.wgsl.template
fn SDP8AI(a1:vec4<u32>, b1:vec4<u32>, a2:vec4<u32>, b2:vec4<u32>, scale:output_element_t) -> output_element_t {
  var local_sum = dot4I8Packed(a1[0], b1[0]);
  local_sum += dot4I8Packed(a1[1], b1[1]);
  local_sum += dot4I8Packed(a1[2], b1[2]);
  local_sum += dot4I8Packed(a1[3], b1[3]);
  local_sum += dot4I8Packed(a2[0], b2[0]);
  local_sum += dot4I8Packed(a2[1], b2[1]);
  local_sum += dot4I8Packed(a2[2], b2[2]);
  local_sum += dot4I8Packed(a2[3], b2[3]);
  return output_element_t(mul_precision(local_sum) * mul_precision(scale));
}
@compute @workgroup_size(1)
fn main() {
  let a = vec4<u32>(ab[0], ab[1], ab[2], ab[3]);
  let b = vec4<u32>(ab[4], ab[5], ab[6], ab[7]);
  var lane_output1 : vec4<${acc}>;
  for (var kidx = 0u; kidx < ${ITERS}u; kidx++) {
    var s = sc[0];
    if (sc[1] != f16(0.0) && kidx >= ${ITERS}u / 2u) { s = -s; }
    lane_output1[0] += ${acc}(SDP8AI(a, b, a, b, s));
  }
  outp[0] = f32(lane_output1[0]);
}`;
async function run(device, acc, aVal, bVal, scale, flip) {
  const module = device.createShaderModule({ code: shader(acc) });
  const pack = v => { const b = (v & 0xff) >>> 0; return (b | (b << 8) | (b << 16) | (b << 24)) >>> 0; };
  const f32tof16 = v => { const f = new Float32Array([v]); const u = new Uint32Array(f.buffer)[0];
    const s = (u >> 16) & 0x8000, e = ((u >> 23) & 0xff) - 112, m = (u >> 13) & 0x3ff;
    return v === 0 ? 0 : s | (e << 10) | m; };
  const abBuf = device.createBuffer({ size: 32, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
  device.queue.writeBuffer(abBuf, 0, new Uint32Array([pack(aVal), pack(aVal), pack(aVal), pack(aVal),
                                                      pack(bVal), pack(bVal), pack(bVal), pack(bVal)]));
  const scBuf = device.createBuffer({ size: 8, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
  device.queue.writeBuffer(scBuf, 0, new Uint16Array([f32tof16(scale), f32tof16(flip ? 1 : 0), 0, 0]));
  const o = device.createBuffer({ size: 16, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
  const rd = device.createBuffer({ size: 16, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
  const pipe = device.createComputePipeline({ layout: 'auto', compute: { module, entryPoint: 'main' } });
  const bg = device.createBindGroup({ layout: pipe.getBindGroupLayout(0), entries: [
    { binding: 0, resource: { buffer: abBuf } }, { binding: 1, resource: { buffer: scBuf } },
    { binding: 2, resource: { buffer: o } }] });
  const enc = device.createCommandEncoder();
  const cp = enc.beginComputePass(); cp.setPipeline(pipe); cp.setBindGroup(0, bg);
  cp.dispatchWorkgroups(1); cp.end();
  enc.copyBufferToBuffer(o, 0, rd, 0, 16);
  device.queue.submit([enc.finish()]);
  await rd.mapAsync(GPUMapMode.READ);
  const v = new Float32Array(rd.getMappedRange().slice(0))[0]; rd.unmap();
  return v;
}
try {
  const adapter = await navigator.gpu.requestAdapter();
  const i = adapter.info || {};
  log(`adapter: vendor=${i.vendor} architecture=${i.architecture} shader-f16=${adapter.features.has('shader-f16')}`);
  const device = await adapter.requestDevice({ requiredFeatures: ['shader-f16'] });
  const partial = 32 * 6 * 4;
  for (const [name, a8, flip, exact] of [
    ['A all-positive (a_i8=6,  b_i8=4)', 6,  false, partial * ITERS],
    ['B half+ half-  (a_i8=12, b_i8=4)', 12, true,  0],
  ]) {
    const f32 = await run(device, 'f32', a8, 4, 1, flip);   // #29599
    const f16 = await run(device, 'f16', a8, 4, 1, flip);   // shipped
    log(`${name}: exact=${exact}  lane_output_f32=${f32}  lane_output_f16=${f16}`);
  }
  log('DONE');
} catch (e) { log('ERROR: ' + e.message); }
</script></body></html>

@daijh

daijh commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Could you provide a link to the test page where you observed the failure with SmolLM2 / Gemma-q4f16?
I am referring to the LLM inference using the design prompt that produced unexpected results.

I insist that we reproduce the reported issue using real LLM inference with the MatMulNBits operation and an FP16 accumulator.
A custom shader with random values does not accurately reflect the environment.
While I'm open to seeing evidence to the contrary, standard LLMs normalize Q/K/V tensors, making an FP16 overflow (> 65504) highly unlikely in practice.
Please provide a genuine LLM test case that proves this overflow actually occurs.

@RobertoReale

Copy link
Copy Markdown

Please provide a genuine LLM test case that proves this overflow actually occurs.

There is one — a real model, real MatMulNBits, no synthetic values. It's already in this thread (comment); here it is as a runnable recipe:

  • Model: onnx-community/gemma-3-270m-it-ONNX, pinned to revision 2950c41f (the last revision before 2025-12-02).
  • Run stock onnxruntime-web with the same prompt twice: WebGPU vs WASM.
  • WASM is correct; WebGPU emits garbage. My adapter was an Intel Iris Xe (gen-12lp) — so this is live on Intel, not only NVIDIA.

The reason the current Hub model doesn't repro isn't that the overflow is absent — it's that those fp16 Gemma weights were re-exported on 2025-12-02 with Clip nodes inserted specifically to clamp this overflow model-side. Pinning to 2950c41f removes that mitigation and the failure returns. A model shipping a workaround for a bug is evidence the bug is real, not that it isn't.

On normalization: LayerNorm/RMSNorm bounds the activations going into MatMulNBits, but the overflow is neither in the activations nor in the final output — it's in the partial sum accumulated across K inside the kernel. At K ≈ 2048–3072 a mean |a·w| ≈ 21 is enough for the running sum to cross 65504 even when every input and the true output sit comfortably in f16 range. Case B in the probe is exactly that: normalized-scale inputs, true output 0, f16 accumulator still returns Inf. Q/K/V normalization can't prevent it because it doesn't touch the reduction — and MatMulNBits here is the quantized weight matmul (MLP / projections), not the attention-score matmul, so the normalized Q/K/V tensors aren't even the operands.

That said, your A/B onnxruntime-web packages remain the cleanest way to settle it end-to-end, and I'm glad you're building them. The pinned-2950c41f Gemma run above is a ready-made target for the f16 package, and I'll run both on Intel Iris Xe (D3D12 + Vulkan) and NVIDIA RTX 3050 Ti on my side and report back.

@RobertoReale

Copy link
Copy Markdown

Correction — I was wrong about Gemma, and @daijh was right to insist

I went back and checked my own claim against the model instead of arguing for it. It does not hold, and I'm retracting it.

What I actually measured

I loaded onnx-community/gemma-3-270m-it-ONNX at 2950c41f (the pre-mitigation revision I cited), captured the real activations feeding all 126 MatMulNBits nodes on the CPU EP with a real prompt, dequantized the real q4 weights from the model file, and accumulated in f16 in the kernel's order (block32 partial sums carried across K — the assumption most favourable to f16, since dot() within a block is evaluated at higher precision and rounded only at the block boundary).

Peak running partial sum across all 126 nodes: ~324. Not one comes within two orders of magnitude of 65504. down_proj — the only K=2048 matmul in the model, and the one whose input (gelu(gate) * up) is not normalized — peaks at 37.

Your normalization argument is correct for this model. My "mean |a·w| ≈ 21 is enough" was arithmetically true and practically irrelevant: the real mean |a·w| here is ~0.01–0.1.

What the 2025-12-02 Clip nodes actually clamp

Not MatMulNBits. They are 18 nodes, one per layer, on /model/layers.N/Add_1 — the residual stream — bounded at ±32752 (exactly half of 65504, leaving headroom for the next residual add). No Clip touches a matmul output.

And when I trace that residual in fp16 on the pre-Clip revision, it grows monotonically and crosses the fp16 ceiling between layers 7 and 8:

max abs value (fp16)
L5/Add_2 40,000
L6/Add_2 52,096
L7/Add_1 52,352
L7/Add_2 Inf
L8/Add_1 65,472

That is a model-level fp16 activation-range overflow in the residual stream — the known Gemma-3 fp16 behaviour that the original reporter of #26732 flagged himself in the issue (he linked the unsloth writeup: "activations become infinity for float16"). The Clips fix that. An f32 matmul accumulator does not, and I should not have cited them as evidence for it.

Consequences I accept

What still stands — stated narrowly this time

The one real-model, end-to-end case I have is whisper-small q4 (details): unusable on WebGPU, then word-for-word parity with the WASM EP after promoting only the accumulator, ~2x faster, nothing else changed. That is a genuine f16-accumulator overflow on a real model — and it is the "genuine LLM test case" you asked for. But it exercises the JSEP MatMulNBits path, not the native wide-tile kernel this PR touches, and I'm not going to stretch it into something it doesn't cover.

So for the wide-tile accumulator specifically, what I have is the portability argument (WGSL permits extra intermediate precision; Intel/D3D12 promotes unrolled acc += chains to f32 while strict-rounding backends don't — so f16-accumulation safety is a property of vendor × backend × code shape) plus a shader-level probe. I do not have a real model that overflows it. That is a weaker case than the one I have been making, and you were right to keep asking for the stronger one.

Your A/B packages remain the right way to settle it, and my prediction is now explicitly falsifiable: if the f16 package matches the f32 package on real models across vendors, the wide-tile demotion is safe and I have no objection to it. I'll run both on Intel Iris Xe (D3D12 + Vulkan) and NVIDIA RTX 3050 Ti and report whatever they say, including if it contradicts me.

Apologies for the noise on the Gemma line — it cost you several rounds of review time.

@daijh

daijh commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

@RobertoReale
Please let me know if any issues arise on Intel hardware, and I'd be happy to follow up.

@RobertoReale

Copy link
Copy Markdown

@daijh Thanks! Just to confirm on next steps - are you currently preparing the two A/B onnxruntime-web packages (f16 vs f32 accumulators) you mentioned in #29611 (comment)? As soon as you share the links or instructions, I will run the multi-vendor suite across both Intel Iris Xe and NVIDIA RTX 3050 Ti and report the exact numbers here.

@daijh

daijh commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

@daijh Thanks! Just to confirm on next steps - are you currently preparing the two A/B onnxruntime-web packages (f16 vs f32 accumulators) you mentioned in #29611 (comment)? As soon as you share the links or instructions, I will run the multi-vendor suite across both Intel Iris Xe and NVIDIA RTX 3050 Ti and report the exact numbers here.

@qjia7 @hariharans29
Please help to start the CI to build a web package for this checking.

@daijh

daijh commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

@RobertoReale

Please get the package from the CI with this PR, and compare with default package.

@RobertoReale

Copy link
Copy Markdown

Before running the A/B I set your package up locally, and the build linked from run 29236183541 doesn't actually exercise the f16 accumulator yet — it still compiles the f32 wide-tile shader.

I dropped its ort-wasm-simd-threaded.jsep.wasm into a working onnxruntime-web and dumped the WebGPU shader on D3D12. MatMulNBitsWideTile still has var results : array<f32, kTileM> with results[m_idx] += f32(dot(...)), byte-for-byte identical to today's dev build. But the template at 77e70a5 already uses array<output_element_t>. To rule out my setup, I ran the in-tree tools/python/wgsl_template generator on both revisions: 77e70a5 emits array<output_element_t>, main emits array<f32>. So a clean build of your branch should carry the f16 path, and the artifact just doesn't match its own commit. That run is attempt 2 and ended in failure, which is likely why the wgsl→header codegen came out stale.

Could you re-trigger CI and confirm the shipped shader has the f16 accumulator? If you can also drop the matching f32 build from the same commit, I'll run both on Iris Xe (D3D12 + Vulkan) and RTX 3050 Ti and post numbers right away.

@daijh

daijh commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Please follow the steps below:

Build onnxruntime-web

git clone https://github.com/microsoft/onnxruntime.git

# ensure the path exist
mkdir onnxruntime\js\web\dist

cd onnxruntime\js

1. build f32-accumulators, the default
build_webgpu.bat r

## onnxruntime\js\web\dist\ort-wasm-simd-threaded.jspi.mjs  
## onnxruntime\js\web\dist\ort-wasm-simd-threaded.jspi.wasm

2. apply this PR, and build f16-accumulators
build_webgpu.bat r

## onnxruntime\js\web\dist\ort-wasm-simd-threaded.jspi.mjs  
## onnxruntime\js\web\dist\ort-wasm-simd-threaded.jspi.wasm

onnxruntime-web llm demo

git clone https://github.com/daijh/onnxruntime_llm

# Download models into `onnxruntime_llm\models`:
# `onnxruntime_llm\models\HuggingFaceTB\SmolLM2-360M-Instruct`
# `onnxruntime_llm\models\onnx-community\gemma-3-270m-it-ONNX`
# `onnxruntime_llm\models\onnx-community\Phi-4-mini-instruct-ONNX`
# `onnxruntime_llm\models\onnx-community\gpt-oss-20b-ONNX`

cd onnxruntime_llm/web

# Install JavaScript dependencies (onnxruntime-web, @huggingface/transformers)
npm install

# Serve the repository root over HTTP (the script serves the parent dir)
npm run serve

# Open http://localhost:8080/web/llm_runner/, it will scan models under `onnxruntime_llm\models` 

A/B Testing

Copy your onnxruntime-web
onnxruntime\js\web\dist\ort-wasm-simd-threaded.jspi.mjs
onnxruntime\js\web\dist\ort-wasm-simd-threaded.jspi.wasm
into
onnxruntime_llm\web\node_modules\onnxruntime-web\dist.

Restart npm/browser, disable cache to ensure the new onnxruntime-web is loaded.
Run http://localhost:8080/web/llm_runner/ testing.

Enable the Verbose to see shade dump.

@daijh
daijh force-pushed the matmulnbits-f16 branch from 77e70a5 to 8f6d4a8 Compare July 24, 2026 05:57
@RobertoReale

Copy link
Copy Markdown

I ran the A/B, and the f16 accumulator does not saturate on the two models I tested.

I skipped the two source builds. The WGSL sits in ort-wasm-simd-threaded.jspi.wasm as plain strings and the accumulator swap happens to be length preserving, so I patched the shipped 1.29.0-dev package in place: var results : array<f32, kTileM> becomes array<f16, kTileM>, and the two f32(dot(...)) casts become f16(...). Everything else is byte identical, so the packages differ only in the accumulator. On an f16 model that is exactly what output_element_t resolves to.

Two controls, because a patch you haven't verified is worthless. The verbose dump prints array<f16, kTileM> for the patched package and array<f32, kTileM> for stock. And a third package where I patched the type to an invalid f1x fails pipeline creation at /model/layers.0/attn/k_proj/MatMul_Quant, which is how I know the wide-tile program is the one actually serving these nodes.

256-token prefill of real text, single forward pass, Chrome/Dawn/D3D12.

model, q4f16 GPU f32 acc f16 acc argmax agreement max abs Δlogit
Qwen2.5-0.5B-Instruct Iris Xe gen-12lp no Inf/NaN no Inf/NaN 244/256 0.65
Qwen2.5-0.5B-Instruct RTX 3050 Ti (ampere) no Inf/NaN no Inf/NaN 244/256 0.56
gemma-3-270m-it Iris Xe gen-12lp no Inf/NaN no Inf/NaN 253/256 0.53
gemma-3-270m-it RTX 3050 Ti (ampere) no Inf/NaN no Inf/NaN 255/256 0.43

On Qwen, MatMulNBitsWideTile is dispatched 168 times and is the only MatMulNBits program in the log, so every quantized matmul in the graph goes through it, down_proj at K=4864 included. That is a longer reduction than the K=3072 I used in my shader probe, and it still stays in range.

So I have no wide-tile counterexample, and on this evidence the demotion is safe. What is left is rounding, not saturation: 12 of 256 argmax positions flip on Qwen. Worth knowing, but it is not the failure I argued for.

I'm not quoting timings, one run each proves nothing about throughput. If you want a longer reduction than 4864 I can run Phi-4-mini the same way, and the binary patch above lets you reproduce the A/B without waiting on CI.

@daijh

daijh commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

@qjia7 @hariharans29
As the issue has been clarified, could we move forward this PR?

@hariharans29

Copy link
Copy Markdown
Member

@qjia7 @hariharans29 As the issue has been clarified, could we move forward this PR?

@qjia7 is currently OOF. I prefer is she takes a look at this. Please allow some time. Meanwhile, I have kicked off CI.

@qjia7

qjia7 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

@daijh @RobertoReale @tianleiwu @hariharans29 — combining a few threads here.

I checked PyTorch and the CUDA EP: both default to f32 accumulation for fp16 GEMM and expose an opt-in for f16. I don't think that translates directly to WebGPU though. On modern CUDA hardware (Ampere and later), f16-accumulate and f32-accumulate tensor-core MMAs run at the same rate, so "safe by default" costs little to nothing there. WebGPU has no such hardware guarantee — f32 accumulation is a real ALU/register cost, especially on mobile/integrated GPUs, and @daijh's numbers show a meaningful f16 win on Intel.

Also worth noting: every other MatMulNBits variant in the WebGPU EP (default, dp4a, subgroup) already accumulates in output_element_t. Wide-tile was the outlier — this PR brings it in line rather than introducing a new risk profile.

On correctness: the repros in the thread are synthetic inputs, not real-model divergence (Gemma / SmolLM2 were walked back). Without a real model showing bad output, I'd rather keep f16 as the default and match the rest of the family.

So I'm good with approving this PR (#29611) as-is.

For @RobertoReale's #29599, I'd suggest we land it behind a new WebGPU EP provider option — e.g. preferredMatmulAccumulatorPrecision with values "f16" (default) / "f32". A provider option (rather than a node attribute) means users don't have to modify or re-export the model — one session-option flip is enough. This is consistent with how the CUDA EP exposes knobs like ORT_CUDA_GEMM_OPTIONS / use_tf32.

If we later encounter a real model with a correctness issue, we can simply change the default value of the provider option from "f16" to "f32" — no code churn, no model changes for users. WDYT?

@daijh

daijh commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks.
I have no objection to adding f32-acc as a security fallback in case any corner cases arise in real LLMs.

@RobertoReale

Copy link
Copy Markdown

The provider option is a better shape than the accuracy_level gate I proposed, and I'm happy to implement it in #29599. One session-option flip, and unlike a node attribute it also covers plain fp16 MatMul, which has nowhere to hang a hint.

On the default, one correction. Gemma and SmolLM2 were walked back, by me, and I'm not reopening either. whisper-small q4 was not: unusable on WebGPU on the released build, then word-for-word identical to the WASM EP once only the accumulator is promoted (methodology). That is a real model producing bad output today, not a synthetic input. It runs through the JSEP MatMulNBits path rather than the wide-tile kernel, so it is no argument against this PR, and I've already said I have no wide-tile counterexample.

With "f16" as the default that model stays broken, and the people hitting it are transformers.js users who will never learn the option exists. Changing the default later only helps whoever is still reporting the bug by then.

So: could the default be "f32", with "f16" as the opt-in on the paths where you measured the win? If the register cost is broad enough that you'd rather not, I'll take the option with an f16 default and raise whisper separately.

@RobertoReale

Copy link
Copy Markdown

Correcting something in my last comment before anyone acts on it. I went to check how the option would actually reach the kernels: the JSEP shaders read env.webgpu, and nothing under js/ reads ep.webgpuexecutionprovider.*. A WebGPU EP provider option does not reach that path at all.

That matters for what I argued. whisper-small q4 runs through JSEP. If the option is EP-only and the JSEP accumulator promotion in #29599 lands unconditionally, then whisper is fixed whatever the default is, and my "an f16 default leaves that model broken" only holds if the option is also meant to gate JSEP. I don't know which you had in mind, and I stated it as though I did.

So the question I need answered first is what the option governs. If it should gate the whole PR, JSEP needs a parallel env.webgpu knob and the default does decide whisper. If it is EP-only, the default is only about the native kernels, where I've already said I have no counterexample and I'll take "f16".

I have the EP-side plumbing written locally, modelled on kvCacheQuantizationBits, and can push it as soon as the scope is settled.

@qjia7

qjia7 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

@RobertoReale, can you build the latest ort-web based on wasm+ webgpu instead of wasm+jsep to see whether the issue still exists for whisper-small q4? The jsep will be deprecated soon and not maintained activity. See #29716.

@RobertoReale

Copy link
Copy Markdown

The answer is no, and for a worse reason than I expected: whisper-small q4 has no f16 accumulator to overflow. I read the file this time instead of assuming. onnx-community/whisper-small at dtype q4, the model my extension actually ships, contains zero fp16 tensors — input_features, last_hidden_state, every MatMulNBits A and its scales are FLOAT, in the encoder and in the merged decoder. output_element_t is f32 there, so the accumulator was already f32 on every path, JSEP included, and #29599 is a no-op on that model. The July result was a misattribution: the broken half of that comparison was the published 1.22.0-dev the extension pins, the working half was a source build of the PR branch, and those two differ by a release cycle of unrelated changes.

So the whisper argument for an f32 default is withdrawn, and "f16" is fine by me. I'll write preferredMatmulAccumulatorPrecision as an EP-only option over the native kernels. Given #29716 I'd rather drop the JSEP hunks from #29599 than plumb a parallel env.webgpu knob into a path you're removing, unless you'd prefer otherwise.

I ran it on wasm+webgpu anyway, 1.29.0-dev jspi package, on 11 s and 30 s of audio: token-for-token identical to the WASM EP in the same package and to the CPU EP, no NaN, no Inf, on Iris Xe (gen-12lp) and RTX 3050 Ti. MatMulNBitsWideTile does serve those nodes — patching its accumulator to an invalid type kills the run at /layers.0/self_attn/k_proj/MatMul_Q4.

One loose end, which I am not calling a bug. The q4f16 export of the same model does diverge on webgpu: a comma becomes a period, then Ask what you can do for your country. repeats to the token limit, identically on both GPUs, with no Inf or NaN anywhere. Replaying f16 accumulation over the real activations of all 72 encoder MatMulNBits nodes peaks at 780 against 65504, so it is not saturation, and the WASM side of that comparison runs through fp32 casts, so it is not an accumulator A/B either. I'll isolate it before saying anything more about it.

@RobertoReale

Copy link
Copy Markdown

Isolated, and it corrects what I said about it. There is saturation in that model — just not in an accumulator.

whisper-small q4f16 keeps LayerNorm decomposed: ReduceMean/Sub/Pow/ReduceMean/Sqrt/Div, every edge fp16. From layer 7 on, the residual stream carries outliers of |x - mean| ~ 795, and 795² is 630,000 against an f16 ceiling of 65,504. I added the intermediates as graph outputs and read them back off the GPU: /layers.7/self_attn_layer_norm/Pow_output_0 holds 92 +Inf, the variance ReduceMean_1 holds 49, so 49 of 1500 positions normalise to their bias alone. last_hidden_state still reports zero Inf and zero NaN — encoder max|x| just moves 32.33 to 38.47, and the decoder loops.

Two controls, both through the EP's own forceCpuNodeNames. All 72 encoder MatMulNBits on CPU: no change, 38.44. The whole normalisation chain on CPU: 32.31, and the decode terminates at EOT instead of running to the token limit.

So it is not the matmul accumulator, #29599 would not fix it, and it is not an argument for an f32 default. It is an fp16 activation overflow, the #26732 family, and WASM and CPU only look correct because ORT inserts fp32 casts around those ops. Happy to file it separately if that is useful.

@daijh

daijh commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

@RobertoReale
Thanks for pushing hard to root-cause the issue.
I'm happy to look into it if any other issues arise with f16-acc.

@qjia7 @hariharans29
Regarding the CI failure, this PR doesn't modify any documentation.
Should I rebase onto main, or leave it as is?

@qjia7

qjia7 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Please merge latest main. Then rerun CI again.

daijh added 2 commits August 5, 2026 13:01
- Store the A workgroup tile as [kTileM][KAVecSizeForBlock32/2][2] pairs
  of vecs so the two vecs consumed per dequantized weight column are
  read in one access.
- Accumulate directly in output_element_t (e.g., f16 for f16 models)
  instead of hardcoding to f32.
Accumulate directly in output_element_t (e.g., f16 for f16 models)
instead of hardcoding an f32 accumulator in the MatMulNBits wide-tile
shader.
@daijh
daijh force-pushed the matmulnbits-f16 branch from 8f6d4a8 to 75d2668 Compare August 5, 2026 05:03
@daijh

daijh commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Copilot checked the CI failure:

The detailed error location points to the Terser compression phase, so updating dependencies and clearing caches should resolve the issue.

Could you please re-run the CI to see if it passes this time?

@RobertoReale

Copy link
Copy Markdown

Re-running won't clear it, and updating dependencies is what caused it.

The E2E step fails inside npm run build:w:esmjs, and the stack frame is in build/js/e2e/node_modules/terser: AST_DynamicImport._size, Cannot read properties of null (reading 'length'). js/web's own terserMinify runs fine earlier in the same job, because that one resolves through a lockfile. js/web/test/e2e has none — webpack-cli pulls terser-webpack-plugin pulls terser@^5, resolved fresh on every run.

It isn't your change. Web CI Pipeline on main was last green on f6d5554e4 (2026-08-04 05:55 UTC) and has failed on every run since, beginning with 2ed68162b, which only touches CUDA plugin CI build flags. That window matches terser 5.49.1, published 07:12 UTC the same day.

#31652 already adds "overrides": { "terser": "5.49.0" } to js/web/test/e2e/package.json, which looks like the right shape of fix. Until it lands web_Release stays red on anything touching web, mine included, so it's worth getting in ahead of the next rerun rather than after it.

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.

4 participants