From a0ef5548af625675b5320f06b457dad0d7a89cb3 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 28 Jul 2026 07:23:37 +0200 Subject: [PATCH] Optimize max peak memory usage and add warnings on mobile for higher models --- bun.lock | 3 + docs/Decisions.md | 78 +++++++++++++++++ docs/WorkBreakdown.md | 9 ++ package.json | 5 +- .../@huggingface%2Ftransformers@4.2.0.patch | 49 +++++++++++ src/App.tsx | 71 +++++++++++++-- src/lib/mobileMemoryGuard.ts | 81 +++++++++++++++++ src/lib/models.ts | 19 ++++ tests/mobileMemoryGuard.test.ts | 87 +++++++++++++++++++ tests/models.test.ts | 33 +++++++ 10 files changed, 428 insertions(+), 7 deletions(-) create mode 100644 patches/@huggingface%2Ftransformers@4.2.0.patch create mode 100644 src/lib/mobileMemoryGuard.ts create mode 100644 tests/mobileMemoryGuard.test.ts diff --git a/bun.lock b/bun.lock index 80bff18..5a06c05 100644 --- a/bun.lock +++ b/bun.lock @@ -24,6 +24,9 @@ "trustedDependencies": [ "ffmpeg-static", ], + "patchedDependencies": { + "@huggingface/transformers@4.2.0": "patches/@huggingface%2Ftransformers@4.2.0.patch", + }, "packages": { "@derhuerst/http-basic": ["@derhuerst/http-basic@8.2.4", "", { "dependencies": { "caseless": "^0.12.0", "concat-stream": "^2.0.0", "http-response-object": "^3.0.1", "parse-cache-control": "^1.0.1" } }, "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw=="], diff --git a/docs/Decisions.md b/docs/Decisions.md index 69b6897..a78c6e1 100644 --- a/docs/Decisions.md +++ b/docs/Decisions.md @@ -539,3 +539,81 @@ more than it saves. The check is one line, logged, scoped to `"auto"`, and targets the one engine with field evidence. **Revisit:** on any onnxruntime-web bump — retest explicit WebGPU on Firefox (file `tests/live.md` note) and drop the check when it reaches parity. + +## D-027 — Mobile OOM crash on Medium+ loads: sequential session loading (patched) + a warn/confirm gate +**Status:** Accepted (2026-07-28) +Reported symptom: downloading Whisper Medium (English) on a phone crashed the +whole browser — on Android, badly enough to briefly kick the OS back to the +home screen (consistent with the low-memory killer reaping background +processes, including the launcher, once the tab's memory spike triggered +system-wide pressure). +**Cause.** Whisper Medium's smallest available dtype on any device is q4 +(D-022) — still ~460 MB combined encoder+decoder weights. Two independent, +compounding sources of transient memory at load time: +1. `@huggingface/transformers` buffers each ONNX weight file fully into one + JS-heap `Uint8Array` (`readResponse`/`loadResourceFile`, + `src/utils/hub.js`/`hub/utils.js`) before it is cached or handed to + onnxruntime — unavoidable either way (with or without a + `progress_callback`, and regardless of whether a URL string or a buffer + is passed to `InferenceSession.create`): onnxruntime-web's WASM backend + has no streaming ONNX parser, so the whole file must exist as one + contiguous buffer to construct a session. +2. `constructSessions` (`src/models/session.js`) loaded the encoder and + decoder sessions **concurrently** via `Promise.all`, so both full-size + JS buffers were resident at once — needlessly roughly doubling the + already-large per-file peak (on top of onnxruntime-web's own + WASM-heap `_malloc` + `HEAPU8.set` copy inside `copyFromExternalBuffer`, + made once per file at session-creation time, which is itself already + serialized across sessions in web environments). +Net: peak transient memory for Medium approached ~2-3x the ~460 MB combined +weight size — comfortably inside WASM32's 4 GiB address space, but well +past the much tighter per-tab memory budgets mobile Safari/Chrome enforce, +which respond to going over budget with an outright, uncatchable tab kill +(no JS `error` event fires — `worker.onerror` in `App.tsx` never sees it). +There was no mobile detection anywhere in the app: Medium downloaded/loaded +identically on a phone and a workstation. +**Fix (two parts):** +1. **Sequential session loading (real fix, no UX cost).** `constructSessions` + is patched (`bun patch`, `patches/@huggingface%2Ftransformers@4.2.0.patch`, + tracked via `patchedDependencies` in `package.json` so it survives + `bun install`) to load sub-model sessions one at a time instead of via + `Promise.all`. This lets the previous file's JS buffer become garbage + before the next file is fetched, roughly halving the load-time peak for + any multi-file model (Whisper's encoder+decoder) — a strict improvement + with no downside, so it applies unconditionally, not just on mobile. + Investigated and rejected as *not* actionable: skipping the + `progress_callback`, `env.customCache`, and passing a URL string instead + of a buffer to onnxruntime-web — all three still end up materializing the + whole file, per the analysis above. Fixing that fully would require + forking onnxruntime-web itself (out of scope). +2. **Warn/confirm gate on mobile (`src/lib/mobileMemoryGuard.ts`, + `estimatePeakLoadBytes` in `src/lib/models.ts`).** Even halved, Medium's + peak (~555 MB estimated: `2 × max(encoderBytes, decoderBytes)`, reflecting + the now-sequential loading) can still exceed a constrained phone's tab + budget. `App.tsx`'s `confirmRiskyMobileLoad` gates all three ways a + browser-engine load can start — the explicit Download button, and the + lazy auto-load on file-drop / live-start (D-020) — behind + `shouldWarnBeforeLoad`: mobile UA detection (Android/iPhone/iPod, plus + iPadOS's desktop-masquerading Safari UA via touch-point count, same + UA-sniffing rationale as D-026 — no memory-pressure API exists to probe + instead) combined with `navigator.deviceMemory` when Chromium reports it, + falling back to a flat ~400 MB threshold where it doesn't (all of + iOS/Safari). A native `window.confirm()` states the estimated memory need + and the crash risk in plain language; declining aborts the load with no + state changes, accepting remembers the modelId+device combination for the + rest of the session so the same load doesn't ask twice. Desktop/laptop + browsers are never gated — no crash evidence exists there. +**Alternatives rejected:** hard-blocking Medium+ on mobile with no override +(some phones have enough RAM to be fine; the confirm lets the user judge +their own device); forking onnxruntime-web for a streaming session-creation +path (large undertaking for a runtime we don't own upstream). +**Consequence:** every model load is now faster-to-safe (sequential +sessions cost a little wall-clock time — files download one after another +instead of in parallel — in exchange for materially lower peak memory). +Mobile users choosing Medium/Large now see one extra confirmation step; +Tiny/Base/Small are unaffected (their estimated peaks stay under the +warning thresholds). +**Revisit:** if field reports show the sequential-load patch alone resolves +mobile crashes in practice, consider loosening the confirm thresholds; if +onnxruntime-web ever gains a streaming/incremental session-creation API, +drop the patch and re-measure before touching the thresholds again. diff --git a/docs/WorkBreakdown.md b/docs/WorkBreakdown.md index d3eff25..3ee6045 100644 --- a/docs/WorkBreakdown.md +++ b/docs/WorkBreakdown.md @@ -255,6 +255,15 @@ Notes (M5): WebGPU EP on Firefox completes but runs far slower than WASM, so "auto" skips WebGPU on Firefox (D-026, explicit selection still honored — retest on ort bumps). +- [x] Mobile OOM crash on Medium+ model loads (D-027) — patched + `@huggingface/transformers` (`bun patch`) to load the encoder/decoder + sessions sequentially instead of concurrently, roughly halving + load-time peak memory for every model; added a mobile UA + + `deviceMemory` warn/confirm gate (`src/lib/mobileMemoryGuard.ts`) + before any browser-engine load whose estimated peak + (`estimatePeakLoadBytes`) is risky on a phone/tablet, covering the + explicit Download button and the lazy auto-load on file-drop/live-start + alike. - [ ] Basic a11y pass (keyboard, focus, aria on progress/live transcript) - [ ] Production build + `bun start` deployment check; README for deployers diff --git a/package.json b/package.json index e08bfae..e56927f 100644 --- a/package.json +++ b/package.json @@ -26,5 +26,8 @@ }, "trustedDependencies": [ "ffmpeg-static" - ] + ], + "patchedDependencies": { + "@huggingface/transformers@4.2.0": "patches/@huggingface%2Ftransformers@4.2.0.patch" + } } diff --git a/patches/@huggingface%2Ftransformers@4.2.0.patch b/patches/@huggingface%2Ftransformers@4.2.0.patch new file mode 100644 index 0000000..f7d8a20 --- /dev/null +++ b/patches/@huggingface%2Ftransformers@4.2.0.patch @@ -0,0 +1,49 @@ +diff --git a/dist/transformers.web.js b/dist/transformers.web.js +index bb7b32717a42475a5f66411a33d5e5964dfda3e2..799cb402e98d8d0d58bf59629c4c2992407477e0 100644 +--- a/dist/transformers.web.js ++++ b/dist/transformers.web.js +@@ -18558,22 +18558,28 @@ async function getSession(pretrained_model_name_or_path, fileName, options, cach + return { buffer_or_path, session_options, session_config }; + } + async function constructSessions(pretrained_model_name_or_path, names, options, cache_sessions = void 0) { +- return Object.fromEntries( +- await Promise.all( +- Object.keys(names).map(async (name) => { +- const cache_config = cache_sessions?.[name] ?? false; +- const { buffer_or_path, session_options, session_config } = await getSession( +- pretrained_model_name_or_path, +- names[name], +- options, +- cache_config, +- name +- ); +- const session = await createInferenceSession(buffer_or_path, session_options, session_config); +- return [name, session]; +- }) +- ) +- ); ++ // Patched (au_whisper_web): load sub-model sessions one at a time instead of ++ // Promise.all-ing them. Each entry briefly holds a full-size decoded model ++ // buffer in JS-heap memory (on top of onnxruntime-web's own WASM-heap copy ++ // during session creation); loading Whisper's encoder and decoder ++ // concurrently keeps both buffers alive at once and roughly doubles the ++ // load-time memory peak, which is enough to OOM-crash mobile browsers on ++ // larger models (e.g. medium, ~460 MB combined). Sequential loading lets the ++ // previous entry's buffer become garbage before the next one is fetched. ++ const entries = []; ++ for (const name of Object.keys(names)) { ++ const cache_config = cache_sessions?.[name] ?? false; ++ const { buffer_or_path, session_options, session_config } = await getSession( ++ pretrained_model_name_or_path, ++ names[name], ++ options, ++ cache_config, ++ name ++ ); ++ const session = await createInferenceSession(buffer_or_path, session_options, session_config); ++ entries.push([name, session]); ++ } ++ return Object.fromEntries(entries); + } + function replaceTensors(obj) { + for (let prop in obj) { diff --git a/src/App.tsx b/src/App.tsx index 0ba9ef6..6170aee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,8 +32,12 @@ import { DEFAULT_MODEL_ID, DEFAULT_TASK, MODELS, + estimatePeakLoadBytes, + formatModelSize, getModel, + resolveDefaultDtype, } from "./lib/models"; +import { shouldWarnBeforeLoad, type MobileMemoryHint } from "./lib/mobileMemoryGuard"; import { applyLoadProgress, initialLoadProgress, @@ -84,6 +88,12 @@ export function App() { // entirely. Session-only on purpose: a browser update may fix the backend. const deviceOverrides = useRef(new Map()); + // Mobile memory-crash warnings the user already accepted this session + // (D-027), keyed "modelId::device" — once confirmed, repeat loads of the + // same combination (e.g. the lazy auto-load after an explicit Download) + // don't ask again. + const mobileWarningAcknowledged = useRef(new Set()); + // Resolves when the current browser-engine load reports ready (rejects on // load failure/cancel). Transcribe posts await it, so no request is ever // queued inside a worker that a load-restart (D-025) is about to terminate @@ -203,6 +213,46 @@ export function App() { return deviceOverrides.current.get(overrideKey(forModelId, preference)) ?? preference; } + /** + * Confirm before a browser-engine load that risks crashing the tab on + * mobile (D-027). Returns true if the load should proceed — either it + * isn't risky, or the user already accepted the risk (this load, or an + * identical one earlier this session). A declined confirm returns false + * with no side effects, so callers can bail out before touching any state. + */ + function confirmRiskyMobileLoad(forModelId: string, preference: DevicePreference): boolean { + const targetModel = getModel(forModelId); + if (!targetModel) return true; + // Mirrors ModelPicker's size-estimate fallback: until the worker reports + // an active device, assume WASM unless the user asked for an accelerator. + const device: InferenceDevice = + activeDevice ?? + (preference === "webgpu" ? "webgpu" : preference === "webnn" ? "webnn" : "wasm"); + const key = overrideKey(forModelId, device); + if (mobileWarningAcknowledged.current.has(key)) return true; + + const peakBytes = estimatePeakLoadBytes(targetModel, resolveDefaultDtype(forModelId, device)); + const hint: MobileMemoryHint = { + userAgent: typeof navigator !== "undefined" ? navigator.userAgent : undefined, + maxTouchPoints: typeof navigator !== "undefined" ? navigator.maxTouchPoints : undefined, + deviceMemoryGB: + typeof navigator !== "undefined" + ? (navigator as Navigator & { deviceMemory?: number }).deviceMemory + : undefined, + }; + if (!shouldWarnBeforeLoad(hint, peakBytes)) return true; + + const proceed = window.confirm( + `${targetModel.displayName} needs roughly ${formatModelSize(peakBytes)} of memory at ` + + "once to load — more than many phones and tablets allow a browser tab to use " + + "before force-closing it. This can crash your browser (and on some phones, the " + + "whole screen briefly goes back to the home screen). A smaller model (Tiny, Base " + + "or Small) is much safer on mobile.\n\nContinue anyway?", + ); + if (proceed) mobileWarningAcknowledged.current.add(key); + return proceed; + } + function newLoadGate(): void { let resolve!: () => void; let reject!: (error: Error) => void; @@ -406,6 +456,7 @@ export function App() { /** Explicit model download (D-020) — pre-caches weights for offline use. */ function handleDownloadModel() { if (busy || liveStatus !== "idle" || loadStarted) return; + if (!confirmRiskyMobileLoad(modelId, devicePreference)) return; startLoad(modelId, devicePreference); } @@ -457,13 +508,17 @@ export function App() { async function handleFile(file: File) { if (busy || liveStatus !== "idle") return; - const run = ++fileRun.current; // The toggle's choice only takes effect with a session and an available // server engine (D-010); everything else runs on-device as always. const useServer = engineChoice === "server" && auth.user !== null && serverStatus?.available === true; + // Browser-engine runs may need to load the model right away (D-020); bail + // out before any state changes if the user declines a mobile crash risk + // warning (D-027). + if (!useServer && !confirmRiskyMobileLoad(modelId, devicePreference)) return; + const run = ++fileRun.current; setBusy(true); setRunEngine(useServer ? "server" : "browser"); setError(null); @@ -603,6 +658,15 @@ export function App() { async function startLive() { if (busy || liveStatus !== "idle") return; + const useServer = + engineChoice === "server" && + auth.user !== null && + serverStatus?.available === true; + // Browser-engine live sessions may need to load the model right away + // (D-020); bail out before any state changes if the user declines a + // mobile crash risk warning (D-027). + if (!useServer && !confirmRiskyMobileLoad(modelId, devicePreference)) return; + setError(null); setResult(null); setPartialText(""); @@ -612,11 +676,6 @@ export function App() { setLivePending(0); setLiveStatus("starting"); - const useServer = - engineChoice === "server" && - auth.user !== null && - serverStatus?.available === true; - const options = (getModel(modelId)?.multilingual ?? false) ? { language, task } : {}; if (useServer) { diff --git a/src/lib/mobileMemoryGuard.ts b/src/lib/mobileMemoryGuard.ts new file mode 100644 index 0000000..b8b2549 --- /dev/null +++ b/src/lib/mobileMemoryGuard.ts @@ -0,0 +1,81 @@ +/** + * Warn before a browser-engine model load that risks crashing the tab on + * mobile (Decisions.md D-027). + * + * Root cause: transformers.js buffers each ONNX weight file fully into a + * JS-heap `Uint8Array`, then onnxruntime-web mallocs a second full-size copy + * inside WASM linear memory to parse it at session-creation time — a + * per-file ~2x peak (`models.ts`'s `estimatePeakLoadBytes`; the + * sequential-session-load patch in `patches/` at least keeps the encoder and + * decoder from both being resident at once). Desktop browsers shrug this + * off; mobile Safari/Chrome enforce much tighter per-tab memory budgets and + * respond to going over them with an outright, uncatchable tab kill — + * observed as Whisper Medium (~460 MB download, ~555 MB estimated peak) + * crashing the browser entirely, cascading on Android into the OS's + * low-memory killer reaping other processes (including the launcher). + * + * Pure and UA-driven rather than feature-detected, same rationale as + * D-026's Firefox check: there is no memory-pressure API to query in + * advance, so a benchmark probe would cost more than it saves. + */ + +export interface MobileMemoryHint { + /** `navigator.userAgent`, or undefined outside a browser (tests, SSR). */ + userAgent: string | undefined; + /** + * `navigator.maxTouchPoints`. iPadOS 13+ Safari reports a desktop-Safari + * "Macintosh" user agent, so touch support is the only signal left to + * tell an iPad apart from a real Mac. + */ + maxTouchPoints: number | undefined; + /** + * `navigator.deviceMemory` (GiB, quantized: 0.25 .. 8) — Chromium only, + * and capped at 8 regardless of actual RAM. Undefined on Safari/Firefox, + * which means undefined on every iOS browser (all WebKit under the hood). + */ + deviceMemoryGB: number | undefined; +} + +const MOBILE_UA_RE = /Android|iPhone|iPod|Mobile/i; + +/** + * True for phones/tablets: Android/iPhone/iPod/generic-Mobile UAs, plus a + * touch-capable "Macintosh" UA (iPadOS masquerading as desktop Safari). + */ +export function isLikelyMobile( + hint: Pick, +): boolean { + if (!hint.userAgent) return false; + if (MOBILE_UA_RE.test(hint.userAgent)) return true; + return /Macintosh/.test(hint.userAgent) && (hint.maxTouchPoints ?? 0) > 1; +} + +/** + * Fraction of reported device RAM a single tab can safely peak at before + * mobile OSes intervene. Conservative on purpose — `deviceMemory` already + * caps at 8 regardless of actual RAM, and the tab competes with the rest of + * the OS, the browser process itself, and other open tabs. + */ +const SAFE_TAB_FRACTION_OF_DEVICE_MEMORY = 0.5; + +/** + * Fallback risk threshold when `deviceMemory` isn't reported (notably all of + * iOS/iPadOS, and Firefox). Set between Small's (~330-350 MB) and Medium's + * (~555 MB) estimated peaks, so it flags Medium/Large without flagging the + * models this codebase has no crash evidence for (D-022's precedent: scope + * fixes to where the evidence is, not every model preemptively). + */ +const MOBILE_FALLBACK_RISK_THRESHOLD_BYTES = 400_000_000; + +/** + * True when a browser-engine load of `peakBytes` is likely to crash this + * device's tab. Desktop/laptop browsers are out of scope — the crash reports + * this guards against are mobile-only. + */ +export function shouldWarnBeforeLoad(hint: MobileMemoryHint, peakBytes: number): boolean { + if (!isLikelyMobile(hint)) return false; + if (hint.deviceMemoryGB !== undefined) { + return peakBytes > hint.deviceMemoryGB * 1e9 * SAFE_TAB_FRACTION_OF_DEVICE_MEMORY; + } + return peakBytes > MOBILE_FALLBACK_RISK_THRESHOLD_BYTES; +} diff --git a/src/lib/models.ts b/src/lib/models.ts index 5848458..ad82c0c 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -93,6 +93,25 @@ export function formatModelSize(bytes: number): string { return `${Math.round(bytes / 1e6)} MB`; } +/** + * Approximate peak transient memory a browser-engine load needs (D-027): for + * whichever of the encoder/decoder weight files is larger, transformers.js + * buffers the full download into one JS-heap `Uint8Array`, then + * onnxruntime-web mallocs a second full-size copy inside WASM linear memory + * to parse it at session-creation time — a per-file ~2x peak. The + * sequential-session-load patch (`patches/`) keeps the encoder and decoder + * from both being resident at once, so the two files' peaks don't add + * together; only the larger one sets the peak. Approximate — real browser/ + * runtime overhead (page baseline, Cache Storage, other tabs) adds more. + */ +export function estimatePeakLoadBytes(model: WhisperModel, dtype: DtypeSpec): number { + const encDtype = typeof dtype === "string" ? dtype : dtype.encoder_model; + const decDtype = typeof dtype === "string" ? dtype : dtype.decoder_model_merged; + const encoderBytes = model.encoderParamsM * 1e6 * BYTES_PER_PARAM[encDtype]; + const decoderBytes = model.decoderParamsM * 1e6 * BYTES_PER_PARAM[decDtype]; + return Math.round(2 * Math.max(encoderBytes, decoderBytes)); +} + // --------------------------------------------------------------------------- // dtype defaults (community-known-good; see file header) // --------------------------------------------------------------------------- diff --git a/tests/mobileMemoryGuard.test.ts b/tests/mobileMemoryGuard.test.ts new file mode 100644 index 0000000..4815ed9 --- /dev/null +++ b/tests/mobileMemoryGuard.test.ts @@ -0,0 +1,87 @@ +import { describe, test, expect } from "bun:test"; +import { isLikelyMobile, shouldWarnBeforeLoad } from "../src/lib/mobileMemoryGuard"; + +// Pure UA/heuristic logic (D-027) — no browser, no navigator. + +const ANDROID_UA = + "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36"; +const IPHONE_UA = + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"; +const IPAD_DESKTOP_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"; +const DESKTOP_MAC_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"; +const DESKTOP_WINDOWS_UA = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"; + +describe("isLikelyMobile", () => { + test("detects Android and iPhone user agents", () => { + expect(isLikelyMobile({ userAgent: ANDROID_UA, maxTouchPoints: 5 })).toBe(true); + expect(isLikelyMobile({ userAgent: IPHONE_UA, maxTouchPoints: 5 })).toBe(true); + }); + + test("detects iPadOS's desktop-Safari UA via touch points", () => { + expect(isLikelyMobile({ userAgent: IPAD_DESKTOP_UA, maxTouchPoints: 5 })).toBe(true); + // Same UA, no touch support reported — treat as a real desktop Mac. + expect(isLikelyMobile({ userAgent: IPAD_DESKTOP_UA, maxTouchPoints: 0 })).toBe(false); + expect(isLikelyMobile({ userAgent: IPAD_DESKTOP_UA, maxTouchPoints: undefined })).toBe( + false, + ); + }); + + test("does not flag real desktop browsers", () => { + expect(isLikelyMobile({ userAgent: DESKTOP_MAC_UA, maxTouchPoints: 0 })).toBe(false); + expect(isLikelyMobile({ userAgent: DESKTOP_WINDOWS_UA, maxTouchPoints: 0 })).toBe(false); + }); + + test("undefined userAgent (SSR/tests) is never mobile", () => { + expect(isLikelyMobile({ userAgent: undefined, maxTouchPoints: 5 })).toBe(false); + }); +}); + +describe("shouldWarnBeforeLoad", () => { + const MEDIUM_PEAK_BYTES = 555_000_000; // ~medium q4's estimated peak + + test("never warns on desktop regardless of size or reported memory", () => { + expect( + shouldWarnBeforeLoad( + { userAgent: DESKTOP_WINDOWS_UA, maxTouchPoints: 0, deviceMemoryGB: 0.5 }, + MEDIUM_PEAK_BYTES, + ), + ).toBe(false); + }); + + test("warns on mobile with low reported deviceMemory", () => { + // Safe budget = 1 GB * 0.5 = 500 MB, below the ~555 MB medium peak. + expect( + shouldWarnBeforeLoad( + { userAgent: ANDROID_UA, maxTouchPoints: 5, deviceMemoryGB: 1 }, + MEDIUM_PEAK_BYTES, + ), + ).toBe(true); + }); + + test("does not warn on mobile with ample reported deviceMemory", () => { + expect( + shouldWarnBeforeLoad( + { userAgent: ANDROID_UA, maxTouchPoints: 5, deviceMemoryGB: 8 }, + MEDIUM_PEAK_BYTES, + ), + ).toBe(false); + }); + + test("falls back to a flat threshold when deviceMemory is unreported (iOS)", () => { + expect( + shouldWarnBeforeLoad( + { userAgent: IPHONE_UA, maxTouchPoints: 5, deviceMemoryGB: undefined }, + MEDIUM_PEAK_BYTES, + ), + ).toBe(true); + expect( + shouldWarnBeforeLoad( + { userAgent: IPHONE_UA, maxTouchPoints: 5, deviceMemoryGB: undefined }, + 50_000_000, // tiny model — well under the fallback threshold + ), + ).toBe(false); + }); +}); diff --git a/tests/models.test.ts b/tests/models.test.ts index 9be0f19..1ef20c3 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -5,6 +5,7 @@ import { WHISPER_LANGUAGES, estimateDefaultDownloadBytes, estimateDownloadBytes, + estimatePeakLoadBytes, formatModelSize, getFasterWhisperModel, getModel, @@ -163,6 +164,38 @@ describe("size estimates", () => { }); }); +describe("estimatePeakLoadBytes (D-027)", () => { + const medium = getModel("onnx-community/whisper-medium-ONNX")!; + const turbo = getModel("onnx-community/whisper-large-v3-turbo")!; + + test("is ~2x the larger of the two weight files, not their sum", () => { + const dtype = resolveDefaultDtype(medium.id, "wasm"); // q4 + const peak = estimatePeakLoadBytes(medium, dtype); + const download = estimateDownloadBytes(medium, dtype); + // The combined download sums both files; the peak only doubles the + // larger one — smaller than 2x the sum, but bigger than the sum alone + // once the sequential-load patch keeps only one file resident at a time. + expect(peak).toBeLessThan(2 * download); + expect(peak).toBeGreaterThan(download); + }); + + test("mixed per-part dtypes (fp16 encoder + q4 decoder) peak on the larger encoder", () => { + const dtype = resolveDefaultDtype(turbo.id, "webgpu"); // fp16 encoder, q4 decoder + const peak = estimatePeakLoadBytes(turbo, dtype); + // large-v3-turbo's encoder (635M) dwarfs its decoder (174M) even before + // fp16 vs q4 is factored in, so the encoder alone should set the peak. + const encoderOnlyPeak = 2 * turbo.encoderParamsM * 1e6 * 2; // fp16 = 2 bytes/param + expect(peak).toBeCloseTo(encoderOnlyPeak, -5); + }); + + test("scales up with a larger model at the same dtype", () => { + const tiny = getModel("onnx-community/whisper-tiny.en")!; + expect(estimatePeakLoadBytes(medium, "q4")).toBeGreaterThan( + estimatePeakLoadBytes(tiny, "q4"), + ); + }); +}); + describe("WHISPER_LANGUAGES", () => { test("codes are unique, lowercase, and include common languages", () => { const codes = WHISPER_LANGUAGES.map((entry) => entry.code);