From 07b439a9e50a2811f8926ea24ae7188fa0d0d0f3 Mon Sep 17 00:00:00 2001 From: Siddartha Pothapragada Date: Tue, 20 Oct 2026 13:00:38 -0700 Subject: [PATCH] Playground: ask before loading models too big for phones and tablets Summary: On iPhones, loading a chat model in the ExecuTorch WebGPU Playground closes the Safari tab during download or GPU setup. iOS kills the tab for memory, so the page gets no error it could show. The chat models need far more memory than a phone gives one tab: the downloaded bytes, the runtime's copy of the program, the GPU weights, and the fp32 KV cache each program allocates for its full 8,960-token context (Qwen 3 0.6B: 2.06 GB; Gemma 3 1B: 0.48 GB). Before a demo starts loading, `memory-guard.js` estimates the peak: twice the download, plus the KV cache for chat models. On a phone or tablet (iPadOS included, which reports a Mac user agent), or on a device reporting 4 GB of memory or less, it shows a dialog when the estimate is over 400 MB. The dialog states the estimate, says phones often close the tab, and offers Cancel (focused) or Load anyway, once per model per page load. Today that covers the chat models (Qwen ~2.9 GB, Gemma ~1.8 GB) and Whisper (~0.5 GB); the vision demos stay under the limit and load as before. Desktops are unchanged. This is a guardrail, not a fix. Re-exporting the chat models with a shorter context would cut the KV cache itself. Differential Revision: D126470374 --- .../playground/webgpu/src/config/models.js | 6 ++ website/playground/webgpu/src/ui/demo-chat.js | 2 + .../playground/webgpu/src/ui/demo-effects.js | 2 + .../playground/webgpu/src/ui/demo-segment.js | 2 + website/playground/webgpu/src/ui/demo-stt.js | 2 + .../playground/webgpu/src/ui/demo-vision.js | 2 + website/playground/webgpu/src/ui/demo-vlm.js | 2 + .../playground/webgpu/src/ui/memory-guard.js | 73 +++++++++++++++++++ website/playground/webgpu/styles/app.css | 8 ++ .../webgpu/tests/memory-guard.test.mjs | 36 +++++++++ 10 files changed, 135 insertions(+) create mode 100644 website/playground/webgpu/src/ui/memory-guard.js create mode 100644 website/playground/webgpu/tests/memory-guard.test.mjs diff --git a/website/playground/webgpu/src/config/models.js b/website/playground/webgpu/src/config/models.js index 5bb93ddf311..4b37df63a81 100644 --- a/website/playground/webgpu/src/config/models.js +++ b/website/playground/webgpu/src/config/models.js @@ -23,6 +23,8 @@ export const MODELS = [ tagline: 'Larger · Built with Llama', approxBytes: 744e6, approxBytesIsEstimate: false, + // fp32 KV cache the program allocates for its full context. + kvCacheBytes: 587202560, arch: { layers: 16, dModel: 2048, ffn: 8192, heads: 32, kvHeads: 8, vocab: 128256 }, assets: { "baseUrl": "https://huggingface.co/executorch-community/Llama-3.2-1B-Instruct-ExecuTorch-WebGPU/resolve/f2b116fc0b746ec8ba8adb93258b9e8b439cefa7/", // @lint-ignore (base URL, files resolve under it) @@ -74,6 +76,8 @@ export const MODELS = [ tagline: 'Fastest · 100+ languages', approxBytes: 416e6, approxBytesIsEstimate: false, + // fp32 KV cache the program allocates for its full context. + kvCacheBytes: 2055208960, arch: { layers: 28, dModel: 1024, ffn: 3072, heads: 16, kvHeads: 8, vocab: 151936 }, assets: { "baseUrl": "https://huggingface.co/executorch-community/Qwen3-0.6B-ExecuTorch-WebGPU/resolve/65fa8ec0b8996b4a0157cdb89ea766b000b1134a/", // @lint-ignore (base URL, files resolve under it) @@ -124,6 +128,8 @@ export const MODELS = [ tagline: 'Larger · by Google DeepMind', approxBytes: 675e6, approxBytesIsEstimate: false, + // fp32 KV cache the program allocates for its full context. + kvCacheBytes: 477102080, arch: { layers: 26, dModel: 1152, ffn: 6912, heads: 4, kvHeads: 1, vocab: 262144 }, assets: { "baseUrl": "https://huggingface.co/executorch-community/Gemma-3-1B-IT-ExecuTorch-WebGPU/resolve/91864e518e371b57a6433c180490ce5ab31639ec/", // @lint-ignore (base URL, files resolve under it) diff --git a/website/playground/webgpu/src/ui/demo-chat.js b/website/playground/webgpu/src/ui/demo-chat.js index 8f14e44e8fb..ea3a174e193 100644 --- a/website/playground/webgpu/src/ui/demo-chat.js +++ b/website/playground/webgpu/src/ui/demo-chat.js @@ -14,6 +14,7 @@ import { icon } from './icons.js'; import { onDeviceNote } from './on-device.js'; import { metricTile } from './metric.js'; import { attachVoiceInput } from './voice-input.js'; +import { confirmModelMemory } from './memory-guard.js'; const SUGGESTED_PROMPTS = [ 'Explain WebGPU to a 10-year-old', @@ -168,6 +169,7 @@ export function mountChat(root, demo) { async function load() { if (gpuState.get().status !== 'ready' || state.phase === 'loading' || !state.source || state.source.kind === 'unavailable') return; const model = state.model; + if (!(await confirmModelMemory(model)) || model !== state.model) return; const controller = new AbortController(); Object.assign(state, { phase: 'loading', loadError: null, loadAbort: controller, load: { stage: 'runtime', fractions: {}, bytes: null } }); renderAll(); diff --git a/website/playground/webgpu/src/ui/demo-effects.js b/website/playground/webgpu/src/ui/demo-effects.js index 7893bbc7a1d..4d8f1ec42d8 100644 --- a/website/playground/webgpu/src/ui/demo-effects.js +++ b/website/playground/webgpu/src/ui/demo-effects.js @@ -14,6 +14,7 @@ import { appIcon } from './app-icons.js'; import { hfBadge } from './hf-badge.js'; import { icon } from './icons.js'; import { onDeviceNote } from './on-device.js'; +import { confirmModelMemory } from './memory-guard.js'; const NORM = { mean: [0.5, 0.5, 0.5], std: [0.5, 0.5, 0.5] }; const LOAD_STAGES = [ @@ -112,6 +113,7 @@ export function mountEffects(root, demo) { async function load() { if (gpuState.get().status !== 'ready' || state.phase === 'loading') return; + if (!(await confirmModelMemory(model))) return; const controller = new AbortController(); Object.assign(state, { phase: 'loading', loadError: null, loadAbort: controller, load: { stage: 'runtime', fractions: {}, detail: '' } }); renderAll(); diff --git a/website/playground/webgpu/src/ui/demo-segment.js b/website/playground/webgpu/src/ui/demo-segment.js index a63bd0bea5e..441d8823e56 100644 --- a/website/playground/webgpu/src/ui/demo-segment.js +++ b/website/playground/webgpu/src/ui/demo-segment.js @@ -11,6 +11,7 @@ import { appIcon } from './app-icons.js'; import { hfBadge } from './hf-badge.js'; import { icon } from './icons.js'; import { onDeviceNote } from './on-device.js'; +import { confirmModelMemory } from './memory-guard.js'; const COLORS = ['#3b82f6', '#f97316', '#10b981', '#e11d48', '#8b5cf6', '#eab308', '#06b6d4', '#ec4899']; const MAX_WORKING_SIDE = 2048; @@ -125,6 +126,7 @@ export function mountSegment(root, demo) { async function load() { if (state.phase === 'loading' || gpuState.get().status !== 'ready') return; + if (!(await confirmModelMemory(model))) return; const controller = new AbortController(); loadAbort = controller; Object.assign(state, { phase: 'loading', loadError: null, load: { stage: 'runtime', done: new Set(), loaded: 0, total: 0, fromCache: false } }); diff --git a/website/playground/webgpu/src/ui/demo-stt.js b/website/playground/webgpu/src/ui/demo-stt.js index 6241b9aa547..9780e44e075 100644 --- a/website/playground/webgpu/src/ui/demo-stt.js +++ b/website/playground/webgpu/src/ui/demo-stt.js @@ -15,6 +15,7 @@ import { appIcon } from './app-icons.js'; import { hfBadge } from './hf-badge.js'; import { icon } from './icons.js'; import { onDeviceNote } from './on-device.js'; +import { confirmModelMemory } from './memory-guard.js'; const MAX_REC_SECONDS = 300; const MIN_CLIP_SECONDS = 0.4; @@ -221,6 +222,7 @@ export function mountStt(root, demo) { async function load() { if (state.phase === 'loading') return; + if (!(await confirmModelMemory(model))) return; loadAbort = new AbortController(); Object.assign(state, { phase: 'loading', loadError: null, load: { stage: 'runtime', fractions: {}, loaded: 0, total: 0, detail: '' } }); renderStage(); diff --git a/website/playground/webgpu/src/ui/demo-vision.js b/website/playground/webgpu/src/ui/demo-vision.js index faee2e7b9b8..dfdef0c493b 100644 --- a/website/playground/webgpu/src/ui/demo-vision.js +++ b/website/playground/webgpu/src/ui/demo-vision.js @@ -16,6 +16,7 @@ import { appIcon } from './app-icons.js'; import { hfBadge } from './hf-badge.js'; import { icon } from './icons.js'; import { onDeviceNote } from './on-device.js'; +import { confirmModelMemory } from './memory-guard.js'; const TASKS = { depth: createDepthTask, detect: createDetectTask, bgremove: createMattingTask, superres: createSuperresTask }; const COPY = { @@ -137,6 +138,7 @@ export function mountVision(root, demo) { async function load() { if (gpuState.get().status !== 'ready' || state.phase === 'loading') return; + if (!(await confirmModelMemory(model))) return; const controller = new AbortController(); Object.assign(state, { phase: 'loading', loadError: null, loadAbort: controller, load: { stage: 'runtime', fractions: {}, detail: '' } }); renderAll(); diff --git a/website/playground/webgpu/src/ui/demo-vlm.js b/website/playground/webgpu/src/ui/demo-vlm.js index 2560488fa0a..107bd8f7829 100644 --- a/website/playground/webgpu/src/ui/demo-vlm.js +++ b/website/playground/webgpu/src/ui/demo-vlm.js @@ -11,6 +11,7 @@ import { appIcon } from './app-icons.js'; import { hfBadge } from './hf-badge.js'; import { icon } from './icons.js'; import { onDeviceNote } from './on-device.js'; +import { confirmModelMemory } from './memory-guard.js'; const QUICK = ['Describe this image', 'What text can you read?', 'How many people are there?', 'What’s unusual here?']; const MAX_NEW_TOKENS = 192; @@ -213,6 +214,7 @@ export function mountVlm(root, demo) { async function load() { if (state.phase === 'loading' || !state.source || state.source.kind === 'unavailable') return; + if (!(await confirmModelMemory(model))) return; const controller = new AbortController(); Object.assign(state, { phase: 'loading', loadError: null, loadAbort: controller, load: { stage: 'runtime', fractions: {}, detail: '' } }); loaderEls = null; diff --git a/website/playground/webgpu/src/ui/memory-guard.js b/website/playground/webgpu/src/ui/memory-guard.js new file mode 100644 index 00000000000..0a8dd71c9b2 --- /dev/null +++ b/website/playground/webgpu/src/ui/memory-guard.js @@ -0,0 +1,73 @@ +// Asks before loading a model that likely needs more memory than a phone or +// tablet gives one browser tab. iOS Safari closes the tab outright (no error the +// page can catch), so the check has to happen before the download starts. +import { h } from '../lib/dom.js'; +import { formatBytes } from '../lib/format.js'; +import { icon } from './icons.js'; + +// Above this estimate, phones and tablets are warned. The chat models +// (estimated 1.8-2.9 GB) close the tab on iPhones; the vision models stay +// under 0.3 GB. +export const MOBILE_PEAK_LIMIT = 400e6; + +const accepted = new Set(); + +export function isMobileDevice(nav = globalThis.navigator) { + if (!nav) return false; + const ua = nav.userAgent ?? ''; + if (/iPhone|iPad|iPod|Android/i.test(ua)) return true; + // iPadOS Safari reports a desktop Mac user agent. + return /Macintosh/.test(ua) && (nav.maxTouchPoints ?? 0) > 1; +} + +// Rough peak while loading: the downloaded bytes, the runtime's copy of the +// program, and GPU buffers for the weights plus any preallocated KV cache. +export function estimatePeakBytes(model) { + return 2 * (model.approxBytes ?? 0) + (model.kvCacheBytes ?? 0); +} + +export function needsMemoryWarning(model, nav = globalThis.navigator) { + const lowMemory = typeof nav?.deviceMemory === 'number' && nav.deviceMemory <= 4; + return (isMobileDevice(nav) || lowMemory) && estimatePeakBytes(model) > MOBILE_PEAK_LIMIT; +} + +// Resolves true to go ahead. Asks once per model per page load. +export function confirmModelMemory(model) { + if (accepted.has(model.id) || !needsMemoryWarning(model) || typeof HTMLDialogElement === 'undefined') { + return Promise.resolve(true); + } + return new Promise((resolve) => { + const finish = (ok) => { + if (ok) accepted.add(model.id); + dialog.close(); + dialog.remove(); + resolve(ok); + }; + const cancel = h('button', { class: 'btn btn--ghost', type: 'button', onclick: () => finish(false) }, 'Cancel'); + const dialog = h( + 'dialog', + { class: 'memory-guard', 'aria-labelledby': 'memory-guard-title' }, + h('div', { class: 'memory-guard__icon', 'aria-hidden': 'true' }, icon('alert', 22)), + h('h2', { class: 'memory-guard__title', id: 'memory-guard-title' }, 'This model may be too big for this device'), + h( + 'p', + { class: 'memory-guard__body' }, + `${model.name} needs about ${formatBytes(estimatePeakBytes(model))} of memory while it loads. `, + 'Phones and tablets often close the tab when a page uses that much. It works best on a laptop or desktop.', + ), + h( + 'div', + { class: 'memory-guard__actions' }, + cancel, + h('button', { class: 'btn btn--primary', type: 'button', onclick: () => finish(true) }, 'Load anyway'), + ), + ); + dialog.addEventListener('cancel', (e) => { + e.preventDefault(); + finish(false); + }); + document.body.append(dialog); + dialog.showModal(); + cancel.focus(); + }); +} diff --git a/website/playground/webgpu/styles/app.css b/website/playground/webgpu/styles/app.css index 3d6c8749caa..bff5b78dc53 100644 --- a/website/playground/webgpu/styles/app.css +++ b/website/playground/webgpu/styles/app.css @@ -1389,3 +1389,11 @@ p { margin: 0; } .model-row--stored { border-color: color-mix(in srgb, var(--live) 45%, var(--border)); box-shadow: inset 4px 0 0 var(--live), var(--shadow-sm); } .model-row--stored .model-row__name { font-weight: 700; } .model-row:not(.model-row--stored) .model-row__name { color: var(--text-2); font-weight: 600; } + +/* Memory warning before loading a large model on a phone or tablet */ +.memory-guard { max-width: min(420px, calc(100vw - 32px)); padding: 24px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); box-shadow: var(--shadow-md); } +.memory-guard::backdrop { background: rgba(15, 23, 42, 0.45); } +.memory-guard__icon { display: inline-flex; color: var(--accent); margin-bottom: 8px; } +.memory-guard__title { margin: 0 0 8px; font-size: 18px; font-weight: 700; } +.memory-guard__body { margin: 0 0 20px; line-height: 1.5; color: var(--text-2); } +.memory-guard__actions { display: flex; justify-content: flex-end; gap: 10px; flex-wrap: wrap; } diff --git a/website/playground/webgpu/tests/memory-guard.test.mjs b/website/playground/webgpu/tests/memory-guard.test.mjs new file mode 100644 index 00000000000..57665f02213 --- /dev/null +++ b/website/playground/webgpu/tests/memory-guard.test.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { getModel } from '../src/config/models.js'; +import { estimatePeakBytes, isMobileDevice, needsMemoryWarning } from '../src/ui/memory-guard.js'; + +const IPHONE = { userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 26_0 like Mac OS X) AppleWebKit/605.1.15 Version/26.0 Mobile/15E148 Safari/604.1', maxTouchPoints: 5 }; +const IPAD = { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/26.0 Safari/605.1.15', maxTouchPoints: 5 }; +const MAC = { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/26.0 Safari/605.1.15', maxTouchPoints: 0 }; +const ANDROID = { userAgent: 'Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 Chrome/141.0 Mobile Safari/537.36', deviceMemory: 8 }; +const LAPTOP_4GB = { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/141.0 Safari/537.36', deviceMemory: 4 }; +const LAPTOP_8GB = { ...LAPTOP_4GB, deviceMemory: 8 }; + +test('phones and tablets are detected, including iPadOS with a desktop user agent', () => { + assert.equal(isMobileDevice(IPHONE), true); + assert.equal(isMobileDevice(IPAD), true); + assert.equal(isMobileDevice(ANDROID), true); + assert.equal(isMobileDevice(MAC), false); + assert.equal(isMobileDevice(LAPTOP_8GB), false); +}); + +test('chat models count their KV cache in the estimate', () => { + const qwen = getModel('qwen3-0.6b'); + assert.equal(estimatePeakBytes(qwen), 2 * qwen.approxBytes + 28 * 2 * 8 * 128 * 8960 * 4); +}); + +test('chat and speech models warn on phones; small vision models do not; desktops never', () => { + for (const id of ['qwen3-0.6b', 'gemma-3-1b-it', 'whisper-base']) { + assert.equal(needsMemoryWarning(getModel(id), IPHONE), true, id); + assert.equal(needsMemoryWarning(getModel(id), MAC), false, id); + assert.equal(needsMemoryWarning(getModel(id), LAPTOP_8GB), false, id); + } + for (const id of ['depth-anything-v2-small', 'modnet', 'real-esrgan', 'dfine-small', 'sam2-hiera-tiny']) { + assert.equal(needsMemoryWarning(getModel(id), IPHONE), false, id); + } + assert.equal(needsMemoryWarning(getModel('qwen3-0.6b'), LAPTOP_4GB), true); +});