Creating an ONNX Runtime Web session with executionProviders: ['webgpu', 'wasm'] reads as "use the GPU if there is one, otherwise the CPU". That is what happens. What the line leaves out is that nothing tells you which of the two happened. With ONNX Runtime Web 1.27.0, in a desktop Chromium where navigator.gpu exists but requestAdapter() returns null, asking for ['webgpu'] alone throws in about 111 ms. Asking for ['webgpu', 'wasm'] in the same browser throws nothing: the session is created in 283 ms, inference runs on WASM, and the code holding the session has no field to read the backend from. If the backend matters to you, create the sessions yourself, one provider at a time, and keep the name of the one that succeeded.
The model in these runs is the RVM MobileNetV3 FP16 file (7,503,483 bytes) that the video background removal in ImgIng https://imging.ai/ loads for its compatible tier, run on an Apple M4 Mac in desktop Chromium with ONNX Runtime Web 1.27.0. That is one machine and one browser engine, and every number below is either a single run or a median of three on it. The input is one still frame fed repeatedly, with RVM's recurrent state passed from each output into the next input, so the timings say something about the backends and nothing about real footage.
Asking for webgpu alone gives a readable error
With only ['webgpu'] and no adapter, InferenceSession.create rejects, and the console also prints a warning, "No available adapters." This is the rejection message, verbatim:
no available backend found. ERR: [webgpu] Error: Failed to get GPU adapter. You may need to enable flag "--enable-unsafe-webgpu" if you are using Chrome.
The useful part is the middle. "Failed to get GPU adapter" means the WebGPU API was present and the adapter request came back empty, which is a different situation from a browser with no navigator.gpu at all. The flag hint at the end is the runtime's own boilerplate, and I would not hand it to users as advice, because an adapter can be missing for ordinary reasons. An earlier run in September on the same Mac, with hardware acceleration switched off in the browser, ended in the same null adapter. What counts is that you can catch it, log it and branch on it, and at about 111 ms it is cheap next to a 270 to 290 ms session creation on either backend.
The provider list swallows that error
Put 'wasm' after 'webgpu' and the error above never reaches your code. In the same adapter-less browser the session came up in 283 ms, the output alpha had 5.0% foreground pixels, the same as a plain WASM session on that frame, and the steady time was 136.5 ms over only three frames, a sample too small to compare with anything. The fallback did its job. What disappeared is the information. The InferenceSession you get back exposes input and output names, not the provider that won, and I found no public property in 1.27.0 that says "wasm". I may have missed one. Nothing in the run surfaced it either.
That gap matters when reading numbers after release. A latency figure from the field means little without the backend, since on this machine the same model differs by roughly 2 to 3 times between them, even though, as the last section shows, the faster WebGPU output for this model was empty. Memory lands in different places: in the WebGPU runs the GPU process grew from 164 to 240 MB between 512×288 and 1920×1080, while under WASM it stayed at about 54 MB and the page process carried the load, so the process you watch depends on the backend. And WASM speed itself depends on thread count, which depends on cross-origin isolation, so "it fell back" is not even one number.
Creating sessions one by one keeps the backend name
This is the version I would ship. It checks for an adapter first, tries a WebGPU-only session, catches the throw, then creates a WASM-only session, and returns the backend name next to the session. The log strings are Chinese because that is how the snippet was written; they read "WebGPU check", "WebGPU session created in", "WebGPU session failed, switching to WASM" and "WASM session created in (threads, crossOriginIsolated)". detectWebGPU() is a small helper that returns ok: false when navigator.gpu is missing or requestAdapter() returns null.
// ===== 片段 2:先试 WebGPU,失败切 WASM,并分别计时 =====
ort.env.wasm.numThreads = self.crossOriginIsolated ? Math.min(4, navigator.hardwareConcurrency) : 1;
async function createSession(modelBytes) {
const base = { graphOptimizationLevel: 'all', executionMode: 'sequential' };
const gpu = await detectWebGPU();
console.log('WebGPU 检测:', gpu.ok, gpu.why);
if (gpu.ok) {
try {
const t0 = performance.now();
const s = await ort.InferenceSession.create(modelBytes, { ...base, executionProviders: ['webgpu'] });
console.log(`WebGPU 建会话 ${(performance.now() - t0).toFixed(0)} ms`);
return { session: s, backend: 'webgpu' };
} catch (e) { console.log('WebGPU 建会话失败,切 WASM:', String(e.message).slice(0, 120)); }
}
const t0 = performance.now();
const s = await ort.InferenceSession.create(modelBytes, { ...base, executionProviders: ['wasm'] });
console.log(`WASM 建会话 ${(performance.now() - t0).toFixed(0)} ms(${ort.env.wasm.numThreads} 线程,crossOriginIsolated=${self.crossOriginIsolated})`);
return { session: s, backend: 'wasm' };
}
Two lines carry more than they look. The thread count is set before any session exists. Without crossOriginIsolated, meaning the page is served with COOP and COEP headers, WASM is stuck on one thread, and in the benchmark four threads were 2.6 to 3.0 times faster than one, 87.2 ms against 251.2 ms steady at 1280×720. The other line is the return value. Once the backend name travels with the session, it can go into every timing log and error report, and nobody has to guess afterwards which path produced a number.
Run in both kinds of browser, the snippet behaved as intended. Where the adapter was null, the WASM session took 292 ms with 4 threads and crossOriginIsolated=true, and the backend came back as wasm. Where WebGPU was available, the check reported an apple / metal-3 adapter, the WebGPU session took 302 ms and the backend came back as webgpu. That second run is where the trouble starts.
A WebGPU session that never throws can still be wrong
In that WebGPU run nothing failed. There was no throw at session creation and none during inference. The first frame took 139 ms and its alpha had 0.54% foreground pixels; by the last of 13 frames the steady time was 34.8 ms and the foreground was 0.00%. The WASM run on the same frame gave 5.04% and 5.03%, which matches the two people in the picture. So the fast backend returned an almost empty mask, and a try/catch had nothing to catch. Across the full benchmark the WebGPU steady times were 29.4, 35.0 and 43.6 ms at 512×288, 1280×720 and 1920×1080, against 89.4, 87.2 and 91.8 ms for WASM with four threads, and the WebGPU output was unusable at all three sizes, with frame 13 at 0.00% every time.
I can't say why. My guess is the way the WebGPU path handles some operator or FP16 precision in this recurrent network, and it stays a guess, because nobody compared the layers one by one. It is also narrow. ISNet INT8, an image matting model, gave matching output on both backends on this same Mac in September, with a largest difference of 1.3×10⁻⁶. What I can stand behind is one model on one machine giving an empty mask on WebGPU without any error.
ImgIng appears to have landed in the same place. Its two RVM tiers are pinned to WASM, and the page explains it, translated from Chinese, as using stable WASM inference "to avoid some WebGPU devices silently producing empty masks; processing is slower but results are more reliable". Its heaviest tier, MatAnyone2, tries WebGPU first and switches to WASM when that fails. It is the same idea as the snippet: try one provider, know the result, record it.
For my own code that means one more step after creating the session. Run a known frame, count alpha values above 0.5 and compare with what WASM gives on that frame (0.54% against 5.04% on the first frame here); if it is far off, treat it like a throw and fall through to WASM. It costs one extra inference at startup, next to a cold start of 406 to 427 ms on WebGPU (the runs with the empty output) or 492 to 544 ms on WASM with four threads. The very first WebGPU run on a machine may be noticeably slower than later ones, so I would keep this check away from any hard timeout.
What was measured and what is inferred
Measured on the one Apple M4 Mac: the error text and the throw with ['webgpu'] alone, the silent WASM run with ['webgpu', 'wasm'], the timings, the foreground percentages and the GPU process memory. Inferred: that the list form exposes the chosen backend nowhere, which rests on reading the session object and the public API rather than the source, and the cause of the empty mask. Not tested at all: Windows, discrete GPUs, Safari and Firefox, where the empty mask may not appear or may look different.
The rule I would put into a code review: if anything after session creation depends on the backend, skip the provider list. Create ['webgpu'], catch, create ['wasm'], return the name with the session, and check the first frame before believing the fast path.
Top comments (0)