Prefer WASM on firefox cause WebGPU is incredibly slow there.

This commit is contained in:
Lucas Winther
2026-07-17 16:35:49 +02:00
parent 74858f63fc
commit f6019b3a42
7 changed files with 486 additions and 27 deletions
+99 -12
View File
@@ -18,6 +18,7 @@ import {
import { resolveDefaultDtype } from "../lib/models";
import {
deviceAttempts,
dtypeRequiresF16,
isUiToWorkerMessage,
TranscribeProgressTracker,
type DeviceCapabilities,
@@ -54,13 +55,27 @@ if (env.backends.onnx.wasm) {
* therefore may run single-threaded — correct, just slower; the next worker
* respawn (cancel does one, D-018) picks the full count up again.
*/
function desiredWasmThreads(): number {
return globalThis.crossOriginIsolated
? Math.max(1, Math.min((navigator.hardwareConcurrency || 4) - 1, 12))
: 1;
}
function configureWasmThreads(device: InferenceDevice): void {
const wasm = env.backends.onnx.wasm;
if (!wasm) return;
wasm.numThreads =
device === "wasm" && globalThis.crossOriginIsolated
? Math.max(1, Math.min((navigator.hardwareConcurrency || 4) - 1, 12))
: 1;
wasm.numThreads = device === "wasm" ? desiredWasmThreads() : 1;
}
/**
* Thrown to abort a load whose WASM fallback would be stuck single-threaded
* (D-025). The worker has already posted `load-restart`; the UI terminates
* this worker and retries in a fresh one, so nothing is reported as an error.
*/
class RestartRequested extends Error {
constructor() {
super("load aborted pending worker restart (D-025)");
}
}
// `lib: ["DOM"]` types `self` as Window; give it the worker-scope shape we use.
@@ -117,8 +132,22 @@ function forwardProgress(info: ProgressInfo): void {
// fallback policy live in the pure `deviceAttempts` (protocol.ts, unit-tested).
// ---------------------------------------------------------------------------
interface GpuAdapterLike {
features?: { has(name: string): boolean };
/** Pre-2024 spec location of the software-adapter flag. */
isFallbackAdapter?: boolean;
/** Current spec location (GPUAdapterInfo). */
info?: { isFallbackAdapter?: boolean };
}
/** True for software adapters (SwiftShader, llvmpipe, WARP) — GPU-shaped but
* slower than multithreaded WASM, so never worth an attempt (D-025). */
function isSoftwareAdapter(adapter: GpuAdapterLike): boolean {
return Boolean(adapter.isFallbackAdapter ?? adapter.info?.isFallbackAdapter);
}
interface NavigatorWithHardware {
gpu?: { requestAdapter(): Promise<unknown | null> };
gpu?: { requestAdapter(): Promise<GpuAdapterLike | null> };
ml?: unknown;
}
@@ -134,9 +163,30 @@ async function probeCapabilities(
// Probing threw — treat WebNN as unavailable.
}
}
if (preference === "auto" || preference === "webgpu") {
if (preference === "auto" && /Firefox\//.test(navigator.userAgent)) {
// Firefox creates ort-web WebGPU sessions fine but runs them far slower
// than this machine's multithreaded WASM (field-verified 2026-07-17,
// D-026) — and a *successful* slow session is invisible to the
// failure-driven fallback chain. Never auto-pick WebGPU on Firefox; an
// explicit selection is honored (the retest hatch for future ort/Firefox).
console.info(
"[whisper] Firefox: skipping WebGPU for \"auto\" — onnxruntime-web's WebGPU backend " +
"runs much slower than multithreaded WASM on Firefox (D-026). " +
"Select WebGPU explicitly to retest.",
);
} else if (preference === "auto" || preference === "webgpu") {
try {
capabilities.webgpu = Boolean(await hardware.gpu?.requestAdapter());
const adapter = (await hardware.gpu?.requestAdapter()) ?? null;
if (adapter && isSoftwareAdapter(adapter)) {
// A fallback (software-rendered) adapter would "succeed" and then run
// far slower than multithreaded WASM — never attempt it (D-025).
console.info("[whisper] WebGPU adapter is software-rendered — skipping WebGPU.");
} else {
capabilities.webgpu = adapter !== null;
// fp16 weights need the "shader-f16" feature (D-025); Firefox ships
// WebGPU without it, so createPipeline gates on this.
capabilities.webgpuF16 = Boolean(adapter?.features?.has("shader-f16"));
}
} catch {
// Probing threw — treat WebGPU as unavailable.
}
@@ -167,17 +217,42 @@ function loadKey(message: LoadMessage): string {
async function createPipeline(
message: LoadMessage,
): Promise<AutomaticSpeechRecognitionPipeline> {
const capabilities = await probeCapabilities(message.device);
// WebGPU without "shader-f16" (Firefox) cannot run fp16 weights — skip the
// doomed attempt instead of failing into a degraded-thread fallback (D-025).
if (capabilities.webgpu && !capabilities.webgpuF16) {
const webgpuDtype =
message.dtype ?? resolveDefaultDtype(message.modelId, "webgpu");
if (dtypeRequiresF16(webgpuDtype)) {
capabilities.webgpu = false;
console.info(
"[whisper] WebGPU adapter lacks shader-f16 but the model dtype needs fp16 — skipping WebGPU.",
);
}
}
// Ordered devices to try (D-004/D-017): the first is the selected device;
// if pipeline creation throws there (driver quirks, unsupported ops,
// out-of-memory), the next one is tried automatically. For "auto" the chain
// is WebNN → WebGPU → WASM (filtered by the probe); WASM always terminates it.
const attempts: InferenceDevice[] = deviceAttempts(
message.device,
await probeCapabilities(message.device),
);
const attempts: InferenceDevice[] = deviceAttempts(message.device, capabilities);
let lastError: unknown;
let acceleratorAttempted = false;
for (const device of attempts) {
// A failed WebGPU/WebNN attempt has already initialized onnxruntime's
// WASM runtime single-threaded, and the thread pool is fixed at first
// init (D-023) — a WASM session created now would run on one thread for
// this worker's whole life (measured 5.5× slower). When multithreading is
// actually available, hand the fallback to a fresh worker instead (D-025).
if (device === "wasm" && acceleratorAttempted && desiredWasmThreads() > 1) {
post({
type: "load-restart",
modelId: message.modelId,
preference: message.device,
device: "wasm",
});
throw new RestartRequested();
}
configureWasmThreads(device);
const dtype = message.dtype ?? resolveDefaultDtype(message.modelId, device);
try {
@@ -194,10 +269,18 @@ async function createPipeline(
}),
progress_callback: forwardProgress,
});
post({ type: "load-progress", status: "ready", device });
// What ort actually ended up with (it clamps to 1 without isolation) —
// surfaced to the UI so degraded threading is visible in the field.
const threads = env.backends.onnx.wasm?.numThreads ?? 1;
console.info(`[whisper] pipeline ready: device=${device} wasmThreads=${threads}`);
post({ type: "load-progress", status: "ready", device, threads });
return asr;
} catch (error) {
lastError = error;
if (device !== "wasm") acceleratorAttempted = true;
console.warn(
`[whisper] pipeline creation failed on ${device}: ${errorMessage(error)}`,
);
}
}
throw lastError;
@@ -350,11 +433,15 @@ scope.addEventListener("message", (event) => {
switch (data.type) {
case "load":
load(data).catch((error: unknown) => {
// A restart-aborted load is not an error — the UI already got the
// load-restart message and is respawning this worker (D-025).
if (error instanceof RestartRequested) return;
post({ type: "error", stage: "load", message: errorMessage(error) });
});
break;
case "transcribe":
transcribe(data).catch((error: unknown) => {
if (error instanceof RestartRequested) return;
post({
type: "error",
stage: "transcribe",