[Web] WebGPU SkipLayerNormalization / SkipSimplifiedLayerNormalization return zeros past maxStorageBufferBindingSize

Closed Beginner friendly
#33,755 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 2 days

A pull request for this has already been merged.

  • #33760 by @mohdmahmodi — merged

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
76/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
cpp

Research direction

Read contrib_ops/webgpu/bert/skip_layer_norm.cc and compare its storage access with the GetByOffset / SetByOffset approach in layer_norm.cc. Check how those helpers handle tensors split across bindings, then build and run the supplied repro.html against both skip normalization ops with default WebGPU limits. Done means rows beyond the binding size match the reference instead of returning zeros.

Written by the indexing model from the issue text.

Description

.NET ep:WebGPU platform:web
Describe the issue

When a tensor is bigger than maxStorageBufferBindingSize, SkipLayerNormalization and SkipSimplifiedLayerNormalization on WebGPU return zeros for everything past the first binding. There's no error and no WebGPU validation message, the output is just wrong.

I ran into this with a 4B decoder model using fp32 activations: on a device with a 128 MiB binding limit, 16k-token prompts came back as all-zero logits. The hidden state is [16384, 2560] fp32 = 160 MiB, which goes over the limit, and there's a SkipSimplifiedLayerNormalization between every block.

Looking at the source, contrib_ops/webgpu/bert/skip_layer_norm.cc indexes the storage arrays directly (x[offset], skip[skip_offset], output[offset], input_skip_bias_sum[offset]) instead of going through GetByOffset / SetByOffset, so it never touches the extra bindings added in #25962.

LayerNormalization and SimplifiedLayerNormalization had the same bug in 1.31.0-dev.20260918-bc8e7ed75 but work in 1.31.1, I think because #32629 moved layer_norm.cc to GetByOffset / SetByOffset. skip_layer_norm.cc is still the same on main (74bda515ee), so probably the same change is needed there. I haven't built ORT to try it.

128 MiB is the WebGPU default and what a lot of phones report, so any model with fp32 activations hits this once seq_len * hidden * 4 goes past 128 MiB.

To reproduce

Save the page below as repro.html, serve the folder (e.g. python -m http.server 8020) and open it in Chrome. It requests a device with the default limits (binding = 134,217,728), passes it through the EP options ({ name: "webgpu", device }), runs each op on a [13200, 2560] fp32 input (135,168,000 bytes, just over the limit) and checks every row against a JS reference.

?limits=adapter uses the adapter's max limits instead, ?ort=<version> picks the onnxruntime-web version from jsDelivr. The models are single nodes with all tensors as graph inputs, embedded as base64 (make_models.py below generates them).

What I get (Chrome 156, Windows 11, RTX 3090 Ti, driver 595.79):

op 1.31.1, default limits 1.31.1, adapter limits 1.31.0-dev.20260918, default limits
SkipSimplifiedLayerNormalization 93 / 13200 rows wrong, 92 all zero ok 93 wrong, 92 all zero
SkipLayerNormalization 93 wrong, 92 all zero ok 93 wrong, 92 all zero
SimplifiedLayerNormalization ok ok 93 wrong, 92 all zero
LayerNormalization ok ok 93 wrong, 92 all zero

The first bad row is 13107, the one that straddles the 134,217,728-byte boundary, and every row after it is zero. No uncapturederror fires.

For comparison, MatMulNBits (fp32 input, 4-bit, block 32, K=2560/N=10240 and K=10240/N=2560) and elementwise Mul/Add/Neg gave correct results with the same setup, with tensors split over up to 5 bindings.

repro.html
<!doctype html>
<meta charset="utf-8">
<title>Norm ops past maxStorageBufferBindingSize</title>
<pre id="log">running...</pre>
<script type="module">
// Runs four norm ops on a [13200, 2560] fp32 tensor (135,168,000 bytes, just over the 128 MiB default
// maxStorageBufferBindingSize) and checks every row against a JS reference.
//   ?ort=<npm version>       onnxruntime-web version (default 1.31.1)
//   ?limits=default|adapter  device with the WebGPU default limits (default) or the adapter's maximums
const q = new URLSearchParams(location.search);
const version = q.get("ort") || "1.31.1";
const useAdapterLimits = q.get("limits") === "adapter";
const dist = `https://cdn.jsdelivr.net/npm/onnxruntime-web@${version}/dist/`;
const ort = await import(dist + "ort.webgpu.min.mjs");
ort.env.wasm.wasmPaths = dist;

const MODELS = {
 "SkipSimplifiedLayerNormalization": "CAo60wEKVAoBWAoBUwoFZ2FtbWESAVkiIFNraXBTaW1wbGlmaWVkTGF5ZXJOb3JtYWxpemF0aW9uKhEKB2Vwc2lsb24VvTeGNaABAToNY29tLm1pY3Jvc29mdBIgU2tpcFNpbXBsaWZpZWRMYXllck5vcm1hbGl6YXRpb25aFQoBWBIQCg4IARIKCgMSAU0KAwiAFFoVCgFTEhAKDggBEgoKAxIBTQoDCIAUWhQKBWdhbW1hEgsKCQgBEgUKAwiAFGIVCgFZEhAKDggBEgoKAxIBTQoDCIAUQgQKABAVQhEKDWNvbS5taWNyb3NvZnQQAQ==",
 "SkipLayerNormalization": "CAo62gEKUAoBWAoBUwoFZ2FtbWEKBGJldGESAVkiFlNraXBMYXllck5vcm1hbGl6YXRpb24qEQoHZXBzaWxvbhW9N4Y1oAEBOg1jb20ubWljcm9zb2Z0EhZTa2lwTGF5ZXJOb3JtYWxpemF0aW9uWhUKAVgSEAoOCAESCgoDEgFNCgMIgBRaFQoBUxIQCg4IARIKCgMSAU0KAwiAFFoUCgVnYW1tYRILCgkIARIFCgMIgBRaEwoEYmV0YRILCgkIARIFCgMIgBRiFQoBWRIQCg4IARIKCgMSAU0KAwiAFEIECgAQFUIRCg1jb20ubWljcm9zb2Z0EAE=",
 "SimplifiedLayerNormalization": "CAo6ugEKVgoBWAoFZ2FtbWESAVkiHFNpbXBsaWZpZWRMYXllck5vcm1hbGl6YXRpb24qFAoEYXhpcxj///////////8BoAECKhEKB2Vwc2lsb24VvTeGNaABAToAEhxTaW1wbGlmaWVkTGF5ZXJOb3JtYWxpemF0aW9uWhUKAVgSEAoOCAESCgoDEgFNCgMIgBRaFAoFZ2FtbWESCwoJCAESBQoDCIAUYhUKAVkSEAoOCAESCgoDEgFNCgMIgBRCBAoAEBVCEQoNY29tLm1pY3Jvc29mdBAB",
 "LayerNormalization": "CAo6wQEKUgoBWAoFZ2FtbWEKBGJldGESAVkiEkxheWVyTm9ybWFsaXphdGlvbioUCgRheGlzGP///////////wGgAQIqEQoHZXBzaWxvbhW9N4Y1oAEBOgASEkxheWVyTm9ybWFsaXphdGlvbloVCgFYEhAKDggBEgoKAxIBTQoDCIAUWhQKBWdhbW1hEgsKCQgBEgUKAwiAFFoTCgRiZXRhEgsKCQgBEgUKAwiAFGIVCgFZEhAKDggBEgoKAxIBTQoDCIAUQgQKABAVQhEKDWNvbS5taWNyb3NvZnQQAQ=="
};
const H = 2560, M = Number(q.get("rows")) || 13200, EPS = 1e-6;

const adapter = await navigator.gpu.requestAdapter();
const requiredLimits = useAdapterLimits
  ? { maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize, maxBufferSize: adapter.limits.maxBufferSize }
  : {};
const device = await adapter.requestDevice({
  requiredFeatures: adapter.features.has("shader-f16") ? ["shader-f16"] : [],
  requiredLimits,
});
const gpuErrors = [];
device.addEventListener("uncapturederror", (e) => gpuErrors.push(e.error.message));

const fill = (n, f) => { const a = new Float32Array(n); for (let i = 0; i < n; i++) a[i] = f(i); return a; };
const x = fill(M * H, (i) => ((i * 7919) % 1000) / 500 - 1);
const s = fill(M * H, (i) => ((i * 104729) % 1000) / 1000 - 0.5);
const gamma = fill(H, (j) => 1 + (j % 5) * 0.1);
const beta = fill(H, (j) => (j % 3) * 0.1);

function reference(op, row) {
  const skip = op.startsWith("Skip"), simplified = op.includes("Simplified");
  const v = new Float64Array(H);
  for (let j = 0; j < H; j++) v[j] = x[row * H + j] + (skip ? s[row * H + j] : 0);
  let mean = 0;
  if (!simplified) { for (const t of v) mean += t; mean /= H; }
  let sq = 0;
  for (const t of v) sq += (t - mean) ** 2;
  const inv = 1 / Math.sqrt(sq / H + EPS);
  return Array.from(v, (t, j) => (t - mean) * inv * gamma[j] + (simplified ? 0 : beta[j]));
}

const rows = [];
for (const [op, b64] of Object.entries(MODELS)) {
  const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
  const session = await ort.InferenceSession.create(bytes, { executionProviders: [{ name: "webgpu", device }] });
  const feeds = {
    X: new ort.Tensor("float32", x, [M, H]), S: new ort.Tensor("float32", s, [M, H]),
    gamma: new ort.Tensor("float32", gamma, [H]), beta: new ort.Tensor("float32", beta, [H]),
  };
  for (const k of Object.keys(feeds)) if (!session.inputNames.includes(k)) delete feeds[k];
  const y = (await session.run(feeds)).Y.data;
  let wrongRows = 0, zeroRows = 0, firstWrongRow = -1;
  for (let r = 0; r < M; r++) {
    const want = reference(op, r);
    let wrong = false, zero = true;
    for (let j = 0; j < H; j++) {
      const got = y[r * H + j];
      if (got !== 0) zero = false;
      if (Math.abs(got - want[j]) > 1e-3 * Math.max(1, Math.abs(want[j]))) wrong = true;
    }
    if (wrong) { wrongRows++; if (firstWrongRow < 0) firstWrongRow = r; }
    if (zero) zeroRows++;
  }
  rows.push({ op, wrongRows, allZeroRows: zeroRows, firstWrongRow,
    firstWrongByte: firstWrongRow < 0 ? null : firstWrongRow * H * 4 });
  await session.release();
}

const result = {
  ort: ort.env.versions.web, userAgent: navigator.userAgent,
  adapter: adapter.info ? `${adapter.info.vendor} ${adapter.info.architecture}` : "",
  maxStorageBufferBindingSize: device.limits.maxStorageBufferBindingSize, maxBufferSize: device.limits.maxBufferSize,
  tensor: { shape: [M, H], bytes: M * H * 4 }, rows, gpuErrors,
};
document.getElementById("log").textContent = JSON.stringify(result, null, 2);
window.result = result;
</script>
make_models.py
# Builds the four one-node models used by repro.html and prints them as base64 (all tensors are graph inputs).
import base64, json, onnx
from onnx import helper, TensorProto as T

H = 2560
def model(op, inputs, domain="", **attrs):
    shapes = {"X": ["M", H], "S": ["M", H], "gamma": [H], "beta": [H]}
    node = helper.make_node(op, inputs, ["Y"], domain=domain, **attrs)
    g = helper.make_graph([node], op, [helper.make_tensor_value_info(n, T.FLOAT, shapes[n]) for n in inputs],
                          [helper.make_tensor_value_info("Y", T.FLOAT, ["M", H])])
    m = helper.make_model(g, opset_imports=[helper.make_opsetid("", 21), helper.make_opsetid("com.microsoft", 1)])
    m.ir_version = 10
    if op != "SimplifiedLayerNormalization":  # an onnxruntime op in the default domain, unknown to the checker
        onnx.checker.check_model(m)
    return base64.b64encode(m.SerializeToString()).decode()

print(json.dumps({
    "SkipSimplifiedLayerNormalization": model("SkipSimplifiedLayerNormalization", ["X", "S", "gamma"], "com.microsoft", epsilon=1e-6),
    "SkipLayerNormalization": model("SkipLayerNormalization", ["X", "S", "gamma", "beta"], "com.microsoft", epsilon=1e-6),
    "SimplifiedLayerNormalization": model("SimplifiedLayerNormalization", ["X", "gamma"], axis=-1, epsilon=1e-6),
    "LayerNormalization": model("LayerNormalization", ["X", "gamma", "beta"], axis=-1, epsilon=1e-6),
}, indent=1))
Urgency

Not blocking for me, I work around it by running long prompts in chunks so nothing goes over the binding limit. But the wrong output is silent, which makes it hard to track down.

ONNX Runtime Installation

Released Package

ONNX Runtime Version or Commit ID

1.31.1 (also 1.31.0-dev.20260918-bc8e7ed75)

Execution Provider

'webgpu' (WebGPU)

Dominant language
C++
Stars
22.2k
Forks
4.4k
Avg merge
3d 9h
Merged PRs (30d)
319

Getting set up

Open in Codespaces

Starts the project's dev container in your browser, under your own GitHub account.

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from microsoft/onnxruntime

All issues in microsoft/onnxruntime

Similar issues

More C++ issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.