Prefer WASM on firefox cause WebGPU is incredibly slow there.
This commit is contained in:
@@ -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",
|
||||
|
||||
+60
-3
@@ -106,7 +106,18 @@ export type LoadProgressMessage =
|
||||
progress: number;
|
||||
}
|
||||
| { type: "load-progress"; status: "done"; file: string }
|
||||
| { type: "load-progress"; status: "ready"; device: InferenceDevice };
|
||||
| {
|
||||
type: "load-progress";
|
||||
status: "ready";
|
||||
device: InferenceDevice;
|
||||
/**
|
||||
* WASM thread count onnxruntime actually ended up with (D-025) — ort
|
||||
* clamps to 1 when cross-origin isolation is missing, so this is field
|
||||
* observability for "is multithreading really on?". Accelerator devices
|
||||
* report 1 (their WASM side must be single-threaded, D-023).
|
||||
*/
|
||||
threads?: number;
|
||||
};
|
||||
|
||||
/** Incremental transcript fragment — append to the text streamed so far. */
|
||||
export interface PartialMessage {
|
||||
@@ -155,12 +166,32 @@ export interface ErrorMessage {
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The worker cannot finish this load usefully in-place and asks the UI to
|
||||
* respawn it (D-025). Posted when an accelerator attempt already initialized
|
||||
* onnxruntime's WASM runtime — whose thread pool is fixed at first init —
|
||||
* single-threaded, so the WASM fallback in *this* worker would run on one
|
||||
* thread for the worker's whole life. The UI terminates the worker, spawns a
|
||||
* fresh one, and re-issues the load with the explicit `device`, remembering
|
||||
* `modelId`+`preference` so later loads skip the doomed attempt entirely.
|
||||
*/
|
||||
export interface LoadRestartMessage {
|
||||
type: "load-restart";
|
||||
/** Echo of the aborted load's model id. */
|
||||
modelId: string;
|
||||
/** Echo of the aborted load's device preference. */
|
||||
preference: DevicePreference;
|
||||
/** Explicit device the fresh worker should load with. */
|
||||
device: InferenceDevice;
|
||||
}
|
||||
|
||||
export type WorkerToUiMessage =
|
||||
| LoadProgressMessage
|
||||
| PartialMessage
|
||||
| TranscribeProgressMessage
|
||||
| ResultMessage
|
||||
| ErrorMessage;
|
||||
| ErrorMessage
|
||||
| LoadRestartMessage;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Device selection policy (D-004, D-017) — pure so it is unit-testable; the
|
||||
@@ -173,6 +204,23 @@ export interface DeviceCapabilities {
|
||||
webnn: boolean;
|
||||
/** `navigator.gpu.requestAdapter()` returned an adapter (WebGPU). */
|
||||
webgpu: boolean;
|
||||
/**
|
||||
* The WebGPU adapter supports the "shader-f16" feature. Firefox ships
|
||||
* WebGPU without it, so fp16 weights cannot run there (D-025); the worker
|
||||
* skips the WebGPU attempt when the resolved dtype needs fp16 and this is
|
||||
* false. Optional so `deviceAttempts` callers that don't probe it (tests)
|
||||
* stay valid.
|
||||
*/
|
||||
webgpuF16?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a dtype spec involves fp16 weights anywhere — such a model cannot
|
||||
* run on a WebGPU adapter without the "shader-f16" feature (D-025).
|
||||
*/
|
||||
export function dtypeRequiresF16(dtype: DtypeSpec): boolean {
|
||||
if (typeof dtype === "string") return dtype === "fp16";
|
||||
return dtype.encoder_model === "fp16" || dtype.decoder_model_merged === "fp16";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -284,7 +332,10 @@ export function isWorkerToUiMessage(value: unknown): value is WorkerToUiMessage
|
||||
typeof value.progress === "number"
|
||||
);
|
||||
case "ready":
|
||||
return isInferenceDevice(value.device);
|
||||
return (
|
||||
isInferenceDevice(value.device) &&
|
||||
(value.threads === undefined || typeof value.threads === "number")
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -308,6 +359,12 @@ export function isWorkerToUiMessage(value: unknown): value is WorkerToUiMessage
|
||||
(value.stage === "load" || value.stage === "transcribe") &&
|
||||
isOptionalString(value.requestId)
|
||||
);
|
||||
case "load-restart":
|
||||
return (
|
||||
typeof value.modelId === "string" &&
|
||||
isDevicePreference(value.preference) &&
|
||||
isInferenceDevice(value.device)
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user