Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions website/playground/webgpu/src/config/models.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions website/playground/webgpu/src/ui/demo-chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions website/playground/webgpu/src/ui/demo-effects.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions website/playground/webgpu/src/ui/demo-segment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 } });
Expand Down
2 changes: 2 additions & 0 deletions website/playground/webgpu/src/ui/demo-stt.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions website/playground/webgpu/src/ui/demo-vision.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions website/playground/webgpu/src/ui/demo-vlm.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
73 changes: 73 additions & 0 deletions website/playground/webgpu/src/ui/memory-guard.js
Original file line number Diff line number Diff line change
@@ -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();
});
}
8 changes: 8 additions & 0 deletions website/playground/webgpu/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
36 changes: 36 additions & 0 deletions website/playground/webgpu/tests/memory-guard.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading