[Web] WebGPU SkipLayerNormalization / SkipSimplifiedLayerNormalization return zeros past maxStorageBufferBindingSize
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
- Domain
- backend, machine-learning
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
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
Starts the project's dev container in your browser, under your own GitHub account.
- No Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from microsoft/onnxruntime
-
[Feature Request] [Web] Expose kvCacheQuantizationBits in the onnxruntime-web WebGPU EP optionsOpenep:WebGPU platform:web quantization
Difficulty 2/5 1-3 hours Newbie friendliness 66/100
microsoft/onnxruntime#33752 ·
Maintainers usually reply within 2 days
-
platform:web
Difficulty 1/5 Under an hour Newbie friendliness 82/100
microsoft/onnxruntime#33570 ·
Maintainers usually reply within 2 days
-
C++ tests fail to compilePossibly taken @winstonwen727 claimed this 15 days ago. Open
Difficulty 2/5 1-3 hours Newbie friendliness 62/100
microsoft/onnxruntime#33430 ·
Maintainers usually reply within 2 days
-
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
microsoft/onnxruntime#33300 ·
Maintainers usually reply within 2 days
-
LayerNormFusionL2 removes a public duplicate Sub outputPossibly taken @Yuhx141 claimed this 36 days ago. Open
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
microsoft/onnxruntime#32953 ·
Maintainers usually reply within 2 days
All issues in microsoft/onnxruntime
Similar issues
-
Difficulty 1/5 Under an hour Newbie friendliness 65/100
-
error not reported I/O low priority rt_lib
Difficulty 2/5 1-3 hours Newbie friendliness 67/100
Maintainers usually reply within 1 day
-
[XLA] hlo/pass/README.md says passes implement Run; since the NVI change they implement RunImplOpen
Difficulty 1/5 Under an hour Newbie friendliness 91/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
NVIDIA-RTX/NRI#312 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 67/100
Maintainers usually reply within 9 days