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); +});