# WebGPU ran our in-browser cutout 6x faster, and drew the wrong mask

> Source: <https://dev.to/szp2005/webgpu-ran-our-in-browser-cutout-6x-faster-and-drew-the-wrong-mask-3166>
> Published: 2026-10-20 02:34:10+00:00

Our background remover runs a BiRefNet model inside the visitor's browser with ONNX Runtime Web. For a while the free tier used WebGPU when it was there and fell back to WASM when it wasn't. That fallback rests on an assumption most of us make without thinking: same model, same answer, one backend is just quicker.

On our model, with ORT-web 1.27, the assumption was false. WebGPU was fast and wrong, and none of our benchmarks noticed, because every one of them ran on CPU. We had never measured the path most visitors actually got.

I was checking a second matting pass in a real browser when the trimap's unknown region came out at about 95% of the image. Offline, the same image gave roughly 11%.

So I took the same model bytes, the same input and the same page, ran it three ways, and logged the logits and where the alpha values landed:

| Weights + backend | Lowest logit | alpha <= 0.05 | Soft band | Inference | 
|---|---|---|---|---|
| int8 + WASM | -20.70 | 76.0% | 0.8% | 16.8 s | 
| int8 + WebGPU | -5.34 | 2.2% | 75.1% | 2.8 s | 
| fp32 + WebGPU | -20.71 | 76.0% | 0.8% | 3.7 s | 

The negative logits got squashed. Positive ones were mostly fine, so the subject still looked right, but the background lost its confidence and about three quarters of the frame slid into the semi-transparent band. On screen that passes for a "softer edge" until you score it against ground truth. Setting `graphOptimizationLevel` to basic or disabled didn't save it (disabled was worse). fp32 on WebGPU matched WASM, which pointed at the int8 DequantizeLinear path in that backend, not at WebGPU as such.

The finished cutouts confirmed it. We ran seven opaque objects from the AIM-500 set through the full production pipeline in a real browser, and WASM beat WebGPU on every one, with a median SAD of 1.10 against 6.15. We turned WebGPU off that day, September 13.

Part of WASM's slowness was the same int8 weights. WASM has no integer acceleration, so dequantizing is extra work on every layer. Switching to fp16 weights brought 1024-input inference on the same image from 16.8 s down to 6.95 s, around 8 s end to end, which is roughly what WebGPU users had been seeing. The fp16 output matched fp32 closely; mean alpha difference was on the order of 1e-5.

Getting a working fp16 file took a custom pass. The converter left one branch in fp32, ORT refused to create a session with mixed-type nodes, and we had to insert a Cast on every node that took both precisions until none were left.

Then we tried that fp16 model on WebGPU, hoping to turn it back on. It failed the opposite way: the whole output collapsed to background, so the cutout was empty. For this model on this ORT-web version, fp32 was the only WebGPU configuration we found that computed correct numbers. `USE_WEBGPU` is still `false` in production.

When you change backends, compare the output distribution, not the timing. The raw logits go through a sigmoid to become alpha, so a few counters tell you whether two backends agree:

```
// logits: Float32Array straight from session.run()
function maskStats(logits) {
  let min = Infinity, max = -Infinity, bg = 0, soft = 0;
  for (const x of logits) {
    if (x < min) min = x;
    if (x > max) max = x;
    const a = 1 / (1 + Math.exp(-x));
    if (a <= 0.05) bg++;
    else if (a < 0.95) soft++;
  }
  return { min, max, bg: bg / logits.length, soft: soft / logits.length };
}
```

If the soft band jumps from around 1% to around 70%, the backend is computing a different function. It isn't being gentler with edges. Keep an fp32 build around as the referee. And create the session with exactly one provider, `executionProviders: ["webgpu"]`, so a silent fallback can't report the wrong backend and send you chasing a ghost.

Going WASM-only gives up WebGPU's best case. In our telemetry from before the shutdown, warm WebGPU runs on 8 to 11 core desktops had a p50 near 1.6 s, though that was only three samples. Cold first cutouts were another story, since session creation alone could eat several seconds. The current default tier (WASM, 1024, fp16) isn't in the telemetry summary yet, so I can't show you today's real-device numbers for it.

I also want to be clear about scope. This is one quantized model on ORT-web 1.27. It is not proof that WebGPU can't run quantized models; it's a reason to check value for value before you trust a speedup.

The full real-device table, split by backend, device type and CPU cores, is on our own page: [https://koutuxia.com/en/guide/webgpu-vs-wasm-background-removal?utm_source=devto](https://koutuxia.com/en/guide/webgpu-vs-wasm-background-removal?utm_source=devto)

Disclosure: I build Koutuxia.
