From 790acf171a2a8b5ca39a9019c06a5d3ea3b5ff18 Mon Sep 17 00:00:00 2001 From: JahShoeAh Date: Thu, 9 Jul 2026 16:14:01 -0400 Subject: [PATCH] webGPU to WASM workaround --- src/client.ts | 9 +++++++-- src/hardware.ts | 15 +++++++++++++++ src/multimodal.ts | 11 ++++++++++- test/client.test.ts | 15 ++++++++++++++- 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/client.ts b/src/client.ts index 1f73c80..d2f836e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -39,7 +39,7 @@ import type { ModelSourceMode } from "./model-source.js"; import { applyContextOverride, buildWebLLMAppConfig, TRANSFORMERS_CACHE_KEY } from "./model-source.js"; import { verifyTransformersProxy, verifyWebLLMProxy } from "./proxy-verify.js"; import type { HardwareSnapshot } from "./hardware.js"; -import { collectHardwareSnapshot } from "./hardware.js"; +import { collectHardwareSnapshot, isChromiumBased } from "./hardware.js"; import type { CacheCleanupResult, CachedModelStatus, IndividualModelCleanupResult, ModelCacheTarget, StorageEstimateSnapshot } from "./model-cache.js"; import { deleteOneModelArtifactsFromBrowserStorage, @@ -261,11 +261,16 @@ export class BrowserAI extends TypedEmitter { try { this.#hardware = await collectHardwareSnapshot(); let device: MultimodalDevice = "webgpu"; + const wasmCapable = !!preset.mmRuntime && WASM_CAPABLE_RUNTIMES.has(preset.mmRuntime); if (!this.#hardware.webgpuSupported) { // Only the runtimes whose loaders honor a wasm device (ASR pipelines, TTS) can fall back; // VLM/Gemma/audio-LLM recipes hardcode WebGPU, and text engines require it outright. - if (preset.mmRuntime && WASM_CAPABLE_RUNTIMES.has(preset.mmRuntime)) device = "wasm"; + if (wasmCapable) device = "wasm"; else throw new WebGPUUnavailableError(this.#hardware.webgpuReason); + } else if (wasmCapable && !isChromiumBased()) { + // Firefox and Safari now pass the WebGPU probe, but onnxruntime-web's WebGPU backend only + // reliably runs on Chromium (shader miscompiles / quantized-session failures elsewhere). + device = "wasm"; } // Free any loaded models occupying the slots this one needs, waiting out their in-flight runs. diff --git a/src/hardware.ts b/src/hardware.ts index 5aa49c8..6f4a84f 100644 --- a/src/hardware.ts +++ b/src/hardware.ts @@ -19,6 +19,21 @@ export type HardwareSnapshot = { platform?: string; }; +/** + * Whether this is a Chromium-based browser (Chrome, Edge, Opera, Brave, …), per the UA-Client-Hints + * brand list. onnxruntime-web's WebGPU backend is developed and tested against Chromium; Firefox's + * and Safari's newer WebGPU implementations still fail on some of its generated WGSL and its + * quantized-weight session transforms (errors that only surface at load/inference time), so + * wasm-capable runtimes prefer wasm off-Chromium even when the WebGPU probe passes. + * navigator.userAgentData is itself Chromium-only, and every Chromium new enough for WebGPU (113+) + * ships it, so the brand list is a reliable signal. + */ +export function isChromiumBased(): boolean { + if (typeof navigator === "undefined") return false; + const nav = navigator as Navigator & { userAgentData?: { brands?: Array<{ brand: string }> } }; + return nav.userAgentData?.brands?.some((entry) => entry.brand === "Chromium") ?? false; +} + const LIMIT_NAMES = [ "maxBufferSize", "maxStorageBufferBindingSize", diff --git a/src/multimodal.ts b/src/multimodal.ts index b3b0961..52c3a4e 100644 --- a/src/multimodal.ts +++ b/src/multimodal.ts @@ -214,7 +214,16 @@ export async function loadMultimodal( (device === "webgpu" ? { encoder_model: "fp32", decoder_model_merged: "q4" } : { encoder_model: "fp32", decoder_model_merged: "q8" }); - const transcriber = await transformers.pipeline("automatic-speech-recognition", preset.id, { device, dtype, progress_callback }); + const transcriber = await transformers.pipeline("automatic-speech-recognition", preset.id, { + device, + dtype, + progress_callback, + // onnxruntime-web 1.26-dev's extended graph optimizations fail at session creation for the + // quantized ASR decoders on the wasm EP (qdq_actions.cc: "Missing required scale … + // TransposeDQWeightsForMatMulNBits"). Capping at "basic" skips the broken QDQ rewrites — + // verified to load and transcribe. The WebGPU path keeps the default level. + ...(device === "wasm" ? { session_options: { graphOptimizationLevel: "basic" as const } } : {}), + }); return { kind: "stt", transcriber }; } case "tts-pipeline": { diff --git a/test/client.test.ts b/test/client.test.ts index 0b403bc..a1e2183 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -4,9 +4,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; /* Module mocks: fake WebGPU probe + fake engines */ /* ------------------------------------------------------------------ */ -const hardwareState = { webgpuSupported: true }; +const hardwareState = { webgpuSupported: true, chromiumBased: true }; vi.mock("../src/hardware.js", () => ({ + isChromiumBased: () => hardwareState.chromiumBased, collectHardwareSnapshot: async () => ({ webgpuSupported: hardwareState.webgpuSupported, webgpuReason: hardwareState.webgpuSupported ? "ok" : "no adapter", @@ -99,6 +100,7 @@ import { UnknownModelError, WebGPUUnavailableError } from "../src/errors.js"; beforeEach(() => { hardwareState.webgpuSupported = true; + hardwareState.chromiumBased = true; created.length = 0; transformersCalls.length = 0; activeGenerations = 0; @@ -168,6 +170,17 @@ describe("BrowserAI slot manager", () => { expect(call?.options.device).toBe("wasm"); }); + it("prefers wasm for ASR/TTS presets off-Chromium even when the WebGPU probe passes", async () => { + hardwareState.chromiumBased = false; + const ai = new BrowserAI(); + await ai.load("onnx-community/whisper-base"); + const call = transformersCalls.find((entry) => entry.task === "automatic-speech-recognition"); + expect(call?.options.device).toBe("wasm"); + // The wasm EP must cap graph optimization: ORT 1.26-dev's extended QDQ rewrites break the + // quantized ASR decoders at session creation ("Missing required scale" / MatMulNBits). + expect(call?.options.session_options).toEqual({ graphOptimizationLevel: "basic" }); + }); + it("does NOT offer a wasm fallback for VLM/Gemma/audio-LLM presets (their recipes hardcode WebGPU)", async () => { hardwareState.webgpuSupported = false; const ai = new BrowserAI();