Optimize max peak memory usage and add warnings on mobile for higher models
This commit is contained in:
@@ -24,6 +24,9 @@
|
|||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"ffmpeg-static",
|
"ffmpeg-static",
|
||||||
],
|
],
|
||||||
|
"patchedDependencies": {
|
||||||
|
"@huggingface/[email protected]": "patches/@huggingface%[email protected]",
|
||||||
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@derhuerst/http-basic": ["@derhuerst/[email protected]", "", { "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=="],
|
"@derhuerst/http-basic": ["@derhuerst/[email protected]", "", { "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=="],
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
targets the one engine with field evidence.
|
||||||
**Revisit:** on any onnxruntime-web bump — retest explicit WebGPU on Firefox
|
**Revisit:** on any onnxruntime-web bump — retest explicit WebGPU on Firefox
|
||||||
(file `tests/live.md` note) and drop the check when it reaches parity.
|
(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%[email protected]`,
|
||||||
|
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.
|
||||||
|
|||||||
@@ -255,6 +255,15 @@ Notes (M5):
|
|||||||
WebGPU EP on Firefox completes but runs far slower than WASM, so
|
WebGPU EP on Firefox completes but runs far slower than WASM, so
|
||||||
"auto" skips WebGPU on Firefox (D-026, explicit selection still
|
"auto" skips WebGPU on Firefox (D-026, explicit selection still
|
||||||
honored — retest on ort bumps).
|
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)
|
- [ ] Basic a11y pass (keyboard, focus, aria on progress/live transcript)
|
||||||
- [ ] Production build + `bun start` deployment check; README for deployers
|
- [ ] Production build + `bun start` deployment check; README for deployers
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -26,5 +26,8 @@
|
|||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"ffmpeg-static"
|
"ffmpeg-static"
|
||||||
]
|
],
|
||||||
|
"patchedDependencies": {
|
||||||
|
"@huggingface/[email protected]": "patches/@huggingface%[email protected]"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
+65
-6
@@ -32,8 +32,12 @@ import {
|
|||||||
DEFAULT_MODEL_ID,
|
DEFAULT_MODEL_ID,
|
||||||
DEFAULT_TASK,
|
DEFAULT_TASK,
|
||||||
MODELS,
|
MODELS,
|
||||||
|
estimatePeakLoadBytes,
|
||||||
|
formatModelSize,
|
||||||
getModel,
|
getModel,
|
||||||
|
resolveDefaultDtype,
|
||||||
} from "./lib/models";
|
} from "./lib/models";
|
||||||
|
import { shouldWarnBeforeLoad, type MobileMemoryHint } from "./lib/mobileMemoryGuard";
|
||||||
import {
|
import {
|
||||||
applyLoadProgress,
|
applyLoadProgress,
|
||||||
initialLoadProgress,
|
initialLoadProgress,
|
||||||
@@ -84,6 +88,12 @@ export function App() {
|
|||||||
// entirely. Session-only on purpose: a browser update may fix the backend.
|
// entirely. Session-only on purpose: a browser update may fix the backend.
|
||||||
const deviceOverrides = useRef(new Map<string, InferenceDevice>());
|
const deviceOverrides = useRef(new Map<string, InferenceDevice>());
|
||||||
|
|
||||||
|
// 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<string>());
|
||||||
|
|
||||||
// Resolves when the current browser-engine load reports ready (rejects on
|
// Resolves when the current browser-engine load reports ready (rejects on
|
||||||
// load failure/cancel). Transcribe posts await it, so no request is ever
|
// 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
|
// 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;
|
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 {
|
function newLoadGate(): void {
|
||||||
let resolve!: () => void;
|
let resolve!: () => void;
|
||||||
let reject!: (error: Error) => void;
|
let reject!: (error: Error) => void;
|
||||||
@@ -406,6 +456,7 @@ export function App() {
|
|||||||
/** Explicit model download (D-020) — pre-caches weights for offline use. */
|
/** Explicit model download (D-020) — pre-caches weights for offline use. */
|
||||||
function handleDownloadModel() {
|
function handleDownloadModel() {
|
||||||
if (busy || liveStatus !== "idle" || loadStarted) return;
|
if (busy || liveStatus !== "idle" || loadStarted) return;
|
||||||
|
if (!confirmRiskyMobileLoad(modelId, devicePreference)) return;
|
||||||
startLoad(modelId, devicePreference);
|
startLoad(modelId, devicePreference);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,13 +508,17 @@ export function App() {
|
|||||||
|
|
||||||
async function handleFile(file: File) {
|
async function handleFile(file: File) {
|
||||||
if (busy || liveStatus !== "idle") return;
|
if (busy || liveStatus !== "idle") return;
|
||||||
const run = ++fileRun.current;
|
|
||||||
// The toggle's choice only takes effect with a session and an available
|
// The toggle's choice only takes effect with a session and an available
|
||||||
// server engine (D-010); everything else runs on-device as always.
|
// server engine (D-010); everything else runs on-device as always.
|
||||||
const useServer =
|
const useServer =
|
||||||
engineChoice === "server" &&
|
engineChoice === "server" &&
|
||||||
auth.user !== null &&
|
auth.user !== null &&
|
||||||
serverStatus?.available === true;
|
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);
|
setBusy(true);
|
||||||
setRunEngine(useServer ? "server" : "browser");
|
setRunEngine(useServer ? "server" : "browser");
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -603,6 +658,15 @@ export function App() {
|
|||||||
|
|
||||||
async function startLive() {
|
async function startLive() {
|
||||||
if (busy || liveStatus !== "idle") return;
|
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);
|
setError(null);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setPartialText("");
|
setPartialText("");
|
||||||
@@ -612,11 +676,6 @@ export function App() {
|
|||||||
setLivePending(0);
|
setLivePending(0);
|
||||||
setLiveStatus("starting");
|
setLiveStatus("starting");
|
||||||
|
|
||||||
const useServer =
|
|
||||||
engineChoice === "server" &&
|
|
||||||
auth.user !== null &&
|
|
||||||
serverStatus?.available === true;
|
|
||||||
|
|
||||||
const options = (getModel(modelId)?.multilingual ?? false) ? { language, task } : {};
|
const options = (getModel(modelId)?.multilingual ?? false) ? { language, task } : {};
|
||||||
|
|
||||||
if (useServer) {
|
if (useServer) {
|
||||||
|
|||||||
@@ -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<MobileMemoryHint, "userAgent" | "maxTouchPoints">,
|
||||||
|
): 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;
|
||||||
|
}
|
||||||
@@ -93,6 +93,25 @@ export function formatModelSize(bytes: number): string {
|
|||||||
return `${Math.round(bytes / 1e6)} MB`;
|
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)
|
// dtype defaults (community-known-good; see file header)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
WHISPER_LANGUAGES,
|
WHISPER_LANGUAGES,
|
||||||
estimateDefaultDownloadBytes,
|
estimateDefaultDownloadBytes,
|
||||||
estimateDownloadBytes,
|
estimateDownloadBytes,
|
||||||
|
estimatePeakLoadBytes,
|
||||||
formatModelSize,
|
formatModelSize,
|
||||||
getFasterWhisperModel,
|
getFasterWhisperModel,
|
||||||
getModel,
|
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", () => {
|
describe("WHISPER_LANGUAGES", () => {
|
||||||
test("codes are unique, lowercase, and include common languages", () => {
|
test("codes are unique, lowercase, and include common languages", () => {
|
||||||
const codes = WHISPER_LANGUAGES.map((entry) => entry.code);
|
const codes = WHISPER_LANGUAGES.map((entry) => entry.code);
|
||||||
|
|||||||
Reference in New Issue
Block a user