Optimize max peak memory usage and add warnings on mobile for higher models
This commit is contained in:
1 parent
f6019b3a42
commit
a0ef5548af
10 files changed
+428
-7
No files matched your search
+65
-6
@@ -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) {
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user