nyaaorick's picture
feat: publish everything-webgpu package, engine source and documentation
1944112 verified
Raw
History Blame Contribute Delete
11.5 kB
/**
* "Will this model run here, and if not, why not" — answered before a byte is
* fetched.
*
* Every input is readable up front: WebGPU presence, whether an adapter can be
* had at all, `shader-f16`, five adapter limits, and the storage quota. So the
* expensive failure — download a gigabyte, then fail at pipeline creation — is
* avoidable, and this file exists to avoid it.
*
* It is also where this project's hard-won platform knowledge is written down
* as code rather than prose. Each rule below is a failure that actually
* happened here: the blocklisted GPU, Firefox's 9-storage-buffer cap that kills
* paged prefill, `q4f16_1` on a device without f16.
*
* What it deliberately does **not** claim: how much VRAM is free. Firefox
* implements neither `navigator.deviceMemory` nor `performance.memory`, and
* `storage.estimate()` measures disk quota, not memory. Nothing reports free
* memory to a web page. So VRAM headroom is reported as a warning with the
* numbers that *are* known, never as a confident blocker — the same reason the
* pool probes for a second engine rather than predicting one (AI.md, "The pool
* grows, it is not sized").
*/
/** WebLLM's ModelType enum; see model-store.js. */
const MODEL_TYPE_LLM = 0;
const MODEL_TYPE_VLM = 2;
/** Bindings `batch_prefill_paged_kv_kernel` needs; below this, no KV reuse. */
const PAGED_PREFILL_STORAGE_BUFFERS = 10;
/**
* @typedef {object} DeviceProbe
* @property {boolean} webgpu
* @property {string} [reason] why WebGPU is unusable, when it is
* @property {object} [adapter] vendor / architecture / device, where exposed
* @property {{shaderF16: boolean}} [features]
* @property {object} [limits]
* @property {boolean} [kvReuse] whether cross-turn KV reuse can be used here
* @property {{quota?: number, usage?: number, persisted?: boolean}} storage
* @property {number} [deviceMemoryGB] Chrome only; absent is not "small"
*/
/**
* Reads what this machine will admit to. Never throws: an unusable device is a
* result, not an error — the caller's job is to explain it, not to crash.
*
* @returns {Promise<DeviceProbe>}
*/
export async function probeDevice() {
const storage = await probeStorage();
const gpu = globalThis.navigator?.gpu;
if (!gpu) {
return {
webgpu: false,
reason:
"navigator.gpu is absent. On Firefox set dom.webgpu.enabled=true in about:config and restart; " +
"on any browser, a non-secure context (plain http) also hides it.",
storage,
};
}
// A `navigator.gpu` that is present but incomplete — a polyfill, a shim, a
// partially-enabled build — must be a result like any other, not a TypeError
// out of a function documented never to throw.
const adapter =
typeof gpu.requestAdapter === "function" ? await gpu.requestAdapter().catch(() => null) : null;
if (!adapter) {
return {
webgpu: false,
reason:
"WebGPU is present but no adapter was granted — usually a blocklisted GPU. On Firefox try " +
"gfx.webgpu.ignore-blocklist=true in about:config.",
storage,
};
}
const limits = {};
for (const key of [
"maxBufferSize",
"maxStorageBufferBindingSize",
"maxStorageBuffersPerShaderStage",
"maxComputeInvocationsPerWorkgroup",
"maxComputeWorkgroupStorageSize",
]) {
limits[key] = adapter.limits?.[key];
}
return {
webgpu: true,
adapter: await adapterInfo(adapter),
features: { shaderF16: Boolean(adapter.features?.has?.("shader-f16")) },
limits,
// Below 10 bindings the paged-prefill pipeline cannot be built, so every
// turn re-prefills the whole history. See engine-worker.js.
kvReuse: (limits.maxStorageBuffersPerShaderStage ?? 0) >= PAGED_PREFILL_STORAGE_BUFFERS,
storage,
...(globalThis.navigator?.deviceMemory ? { deviceMemoryGB: navigator.deviceMemory } : {}),
};
}
/** `adapter.info` is the current spec; `requestAdapterInfo()` was the old one. */
/**
* What the adapter will admit about itself, with blanks dropped.
*
* Firefox 154 exposes `adapter.info` but fills every field with `""`, so the
* naive shape is an object that *looks* populated and renders as "GPU: ". A
* caller cannot tell that from a real answer without checking each string, so
* empty fields are omitted and a browser that says nothing yields `{}` — the
* same thing the no-info path already returns.
*/
async function adapterInfo(adapter) {
const info = adapter.info ?? (await adapter.requestAdapterInfo?.().catch(() => null));
if (!info) return {};
const { vendor, architecture, device, description } = info;
return Object.fromEntries(
Object.entries({ vendor, architecture, device, description }).filter(([, v]) => v),
);
}
async function probeStorage() {
const s = globalThis.navigator?.storage;
if (!s) return {};
const [estimate, persisted] = await Promise.all([
s.estimate?.().catch(() => ({})) ?? {},
s.persisted?.().catch(() => undefined) ?? undefined,
]);
return { quota: estimate?.quota, usage: estimate?.usage, persisted };
}
/**
* Whether a model can run on a probed device.
*
* `blockers` mean it will not work; `warnings` mean it will work worse, or
* might not fit. The split matters: a caller should refuse to start on a
* blocker and merely inform on a warning, and conflating the two is how you end
* up either crashing or refusing to run something that would have been fine.
*
* @param {{model_id: string, vram_required_MB?: number, sizeBytes?: number}} model
* @param {DeviceProbe} probe
* @returns {{ok: boolean, blockers: Array<{code: string, message: string}>,
* warnings: Array<{code: string, message: string}>}}
*/
export function canRun(model, probe) {
const blockers = [];
const warnings = [];
const say = (list, code, message) => list.push({ code, message });
if (!probe?.webgpu) {
say(blockers, "NO_WEBGPU", probe?.reason ?? "WebGPU is unavailable.");
return { ok: false, blockers, warnings };
}
// The quantisation is encoded in the model id by MLC convention
// (`…-q4f16_1-MLC`). It is a naming convention, not a manifest field, so this
// is a heuristic — but a wrong guess only costs a spurious warning, while not
// checking costs a gigabyte downloaded before a pipeline fails.
if (/f16/.test(model?.model_id ?? "") && !probe.features?.shaderF16) {
say(
blockers,
"NO_SHADER_F16",
`"${model.model_id}" is an f16 build and this device has no \`shader-f16\` feature. ` +
"Choose a q4f32_1 or q0f32 variant — they are larger and slower, but they will run.",
);
}
if (!probe.kvReuse) {
say(
warnings,
"NO_KV_REUSE",
`This device allows ${probe.limits?.maxStorageBuffersPerShaderStage} storage buffers per shader ` +
`stage; paged prefill needs ${PAGED_PREFILL_STORAGE_BUFFERS}. Every turn re-prefills the whole ` +
"history, so long conversations get slow first tokens.",
);
}
const vramMB = model?.vram_required_MB;
if (vramMB && probe.deviceMemoryGB && vramMB > probe.deviceMemoryGB * 1024 * 0.5) {
say(
warnings,
"TIGHT_MEMORY",
`The model wants ~${Math.round(vramMB)} MB and this device reports ${probe.deviceMemoryGB} GB of ` +
"memory. It may load and then run against swap, or fail to load a second engine.",
);
}
const bytes = model?.sizeBytes ?? (vramMB ? vramMB * 1024 * 1024 : 0);
const free = probe.storage?.quota != null ? probe.storage.quota - (probe.storage.usage ?? 0) : null;
if (bytes && free != null && bytes > free) {
say(
blockers,
"INSUFFICIENT_STORAGE",
`The model needs ~${Math.round(bytes / 1e6)} MB cached and only ~${Math.round(free / 1e6)} MB of ` +
"quota is free.",
);
}
if (bytes && probe.storage?.persisted === false) {
say(
warnings,
"NOT_PERSISTED",
"Storage is not persisted, so the browser may evict the model under disk pressure. " +
"Call ensurePersistent() to ask for it.",
);
}
return { ok: blockers.length === 0, blockers, warnings };
}
/**
* Rank a model list by what this device can actually run.
*
* The prebuilt list spans 239 MB to 31 GB, so "which model should I use" is the
* first question a developer has and the one they have least basis to answer.
* Runnable models come first, then fewest warnings; unrunnable ones are kept at
* the end carrying their reason rather than silently dropped, because "why
* can't I use that one" is the next question.
*
* **`prefer` is a real choice, not a default worth hiding.** Decode here is
* memory-bandwidth-bound — time per token scales with weight bytes (AI.md,
* "Why not llama.cpp/Ollama-class"), so the largest model that fits is also the
* slowest thing that fits. `"quality"` picks the biggest, `"speed"` the
* smallest. Neither is right for everyone, which is why it is a parameter.
*
* Vision models are excluded from a text ranking rather than merely deprioritised:
* a VLM answers text prompts perfectly well, but at several times the download
* for no benefit, so recommending one to a caller who did not ask is bad advice.
*
* @param {Array<object>} models `model_list` entries or registry records
* @param {{probe: DeviceProbe, maxVramMB?: number, needsVision?: boolean,
* prefer?: "quality" | "speed"}} opts
*/
export function rankModels(models, { probe, maxVramMB, needsVision = false, prefer = "quality" } = {}) {
const wanted = needsVision ? MODEL_TYPE_VLM : MODEL_TYPE_LLM;
return models
.filter((m) => (m.model_type ?? MODEL_TYPE_LLM) === wanted)
.filter((m) => !maxVramMB || !m.vram_required_MB || m.vram_required_MB <= maxVramMB)
.map((m) => ({ model: m, ...canRun(m, probe) }))
.sort((a, b) => {
if (a.ok !== b.ok) return a.ok ? -1 : 1;
if (a.warnings.length !== b.warnings.length) return a.warnings.length - b.warnings.length;
const sizeA = a.model.vram_required_MB ?? 0;
const sizeB = b.model.vram_required_MB ?? 0;
return prefer === "speed" ? sizeA - sizeB : sizeB - sizeA;
});
}
/**
* Decode throughput is memory bandwidth divided by weight bytes.
*
* This project measured the whole chain: decode reaches ~16 GB/s of the M4's
* ~120 GB/s, and the 1.06 GB build runs 16.6–18.1 tok/s — which is that
* quotient. So a projection needs one number, the *achieved* bandwidth, and
* everything else follows from model size.
*
* The constant below is that machine's figure and is only a starting point. The
* moment this engine has decoded anything it knows the real number for the
* machine it is on, and `ScheduledEngine.estimateSpeed()` switches to it — so
* this is a cold-start default, not a claim about anyone's hardware.
*/
export const REFERENCE_DECODE_BYTES_PER_SECOND = 17e9;
export const REFERENCE_DEVICE = "M4 MacBook Air (16 GB), Firefox";
/**
* @param {number} modelBytes
* @param {number} [bytesPerSecond] this machine's measured rate, when known
* @returns {{tokensPerSecond: number, basis: "measured" | "extrapolated",
* modelBytes: number, bytesPerSecond: number, reference?: string}}
*/
export function projectSpeed(modelBytes, bytesPerSecond) {
const rate = bytesPerSecond || REFERENCE_DECODE_BYTES_PER_SECOND;
return {
tokensPerSecond: modelBytes > 0 ? rate / modelBytes : 0,
basis: bytesPerSecond ? "measured" : "extrapolated",
modelBytes,
bytesPerSecond: rate,
...(bytesPerSecond ? {} : { reference: REFERENCE_DEVICE }),
};
}