Optimize max peak memory usage and add warnings on mobile for higher models

This commit is contained in:
Lucas Winther committed 2026-07-28 07:23:37 +02:00
1 parent f6019b3a42
commit a0ef5548af
10 files changed
+428 -7

No files matched your search

+65 -6
View File
@@ -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<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
// 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) {
+81
View File
@@ -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;
}
+19
View File
@@ -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)
// ---------------------------------------------------------------------------