| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import { ERROR, EngineError } from "./errors.js"; |
|
|
| export const SEVERITY = { |
| |
| BLOCKED: "blocked", |
| |
| DEGRADED: "degraded", |
| |
| TUNE: "tune", |
| |
| INFO: "info", |
| |
| OK: "ok", |
| }; |
|
|
| |
| const RANK = [SEVERITY.BLOCKED, SEVERITY.DEGRADED, SEVERITY.TUNE, SEVERITY.INFO, SEVERITY.OK]; |
|
|
| |
| const PAGED_PREFILL_STORAGE_BUFFERS = 10; |
|
|
| const SCOPES = new Set(["full", "local", "device"]); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function environmentFacade(engine) { |
| const environment = (opts = {}) => report(engine, opts); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| environment.measure = async ({ tokens = 32 } = {}) => { |
| const modelId = engine.state.modelId; |
| if (!modelId || engine.resident.length === 0) { |
| throw new EngineError( |
| ERROR.NO_MODEL, |
| "environment.measure() generates in order to measure, so it needs a resident model. " + |
| "Call load() first, or use estimateSpeed(id) for a projection.", |
| ); |
| } |
| await engine.complete({ |
| messages: [{ role: "user", content: "Count from one to twenty." }], |
| max_tokens: tokens, |
| |
| priority: "background", |
| preemptible: true, |
| }); |
| return { ...(await engine.estimateSpeed(modelId)), tokens }; |
| }; |
|
|
| return environment; |
| } |
|
|
| async function report(engine, { scope = "full", ...rest } = {}) { |
| |
| |
| |
| const stray = Object.keys(rest); |
| if (stray.length) { |
| throw new EngineError( |
| ERROR.BAD_REQUEST, |
| `environment() reports; it does not change anything. ` + |
| `To set ${stray.map((k) => `\`${k}\``).join(", ")}, call configure({ ${stray.join(", ")} }).`, |
| { keys: stray }, |
| ); |
| } |
| if (!SCOPES.has(scope)) { |
| throw new EngineError( |
| ERROR.BAD_REQUEST, |
| `environment() scope must be ${[...SCOPES].map((s) => `"${s}"`).join(", ")}, not "${scope}".`, |
| { scope }, |
| ); |
| } |
|
|
| const device = await engine.probe(); |
| const lines = [...deviceLines(device)]; |
|
|
| |
| |
| |
| |
| const blocked = lines.some((l) => l.severity === SEVERITY.BLOCKED); |
|
|
| if (scope !== "device" && !blocked) { |
| const features = await engine.features(); |
| const settings = await engine.store.getSettings(); |
| lines.push(...runtimeLines(engine, device, features, settings)); |
| } |
|
|
| |
| |
| |
| let speed = null; |
| if (scope === "full" && !blocked && engine.state.modelId) { |
| speed = await engine.estimateSpeed().catch(() => null); |
| } |
|
|
| lines.sort((a, b) => RANK.indexOf(a.severity) - RANK.indexOf(b.severity)); |
| const worst = lines.reduce( |
| (acc, l) => (RANK.indexOf(l.severity) < RANK.indexOf(acc) ? l.severity : acc), |
| SEVERITY.OK, |
| ); |
|
|
| return { |
| scope, |
| ok: worst !== SEVERITY.BLOCKED, |
| severity: worst, |
| device, |
| ...(speed ? { speed } : {}), |
| lines, |
| }; |
| } |
|
|
| const line = (id, severity, affects, cause, fix = null) => ({ |
| id, |
| severity, |
| affects, |
| cause, |
| fix, |
| operable: fix !== null, |
| }); |
|
|
| function* deviceLines(device) { |
| if (!device.webgpu) { |
| |
| |
| yield { |
| ...line("webgpu", SEVERITY.BLOCKED, "everything — no model can load", device.reason ?? "navigator.gpu is absent"), |
| fix: device.reason ?? null, |
| operable: false, |
| }; |
| return; |
| } |
| yield line("webgpu", SEVERITY.OK, null, "navigator.gpu is present and an adapter was granted"); |
|
|
| const buffers = device.limits?.maxStorageBuffersPerShaderStage; |
| if (device.kvReuse === false) { |
| yield line( |
| "kvReuse", |
| SEVERITY.DEGRADED, |
| "every turn after the first re-prefills the whole history, so a long conversation waits seconds for its first token", |
| `paged prefill binds ${PAGED_PREFILL_STORAGE_BUFFERS} storage buffers per stage; this device allows ${buffers ?? "fewer"}`, |
| |
| null, |
| ); |
| } else if (device.kvReuse) { |
| yield line("kvReuse", SEVERITY.OK, null, `storage buffers per stage: ${buffers}`); |
| } |
|
|
| yield device.features?.shaderF16 |
| ? line("shaderF16", SEVERITY.OK, null, "shader-f16 is supported, so q4f16 models run at full speed") |
| : line( |
| "shaderF16", |
| SEVERITY.DEGRADED, |
| "q4f16 models fall back to f32 maths, roughly halving decode", |
| "the adapter does not expose shader-f16", |
| null, |
| ); |
|
|
| const { quota, usage, persisted } = device.storage ?? {}; |
| if (persisted === false) { |
| yield line( |
| "persist", |
| SEVERITY.TUNE, |
| "the browser may evict a multi-GB model under storage pressure, forcing a re-download", |
| "storage is not marked persistent", |
| "await ensurePersistent() — from everything-webgpu/adapters/idb. One-way, and may prompt.", |
| ); |
| } else if (persisted) { |
| yield line("persist", SEVERITY.OK, null, "storage is persistent; models will not be evicted"); |
| } |
|
|
| if (quota && usage !== undefined) { |
| const freeGB = (quota - usage) / 1e9; |
| yield freeGB < 2 |
| ? line( |
| "quota", |
| SEVERITY.DEGRADED, |
| "a model may fail to cache, or evict one already there", |
| `${freeGB.toFixed(1)} GB free of ${(quota / 1e9).toFixed(1)} GB`, |
| "Free space, or remove(id) a model you no longer need.", |
| ) |
| : line("quota", SEVERITY.INFO, null, `${freeGB.toFixed(1)} GB free of ${(quota / 1e9).toFixed(1)} GB`); |
| } |
| } |
|
|
| function* runtimeLines(engine, device, features, settings) { |
| |
| |
| const off = engine.state.decode?.multiStepOff; |
| if (off?.length) { |
| yield line( |
| "multiStepDecoding", |
| SEVERITY.DEGRADED, |
| "decode fell back to one GPU sync per token — roughly half throughput", |
| `the live pipeline is missing ${off.length} tvmjs internal(s): ${off.slice(0, 3).join("; ")}`, |
| "This is what a WebLLM upgrade looks like. Run `npm test` (webllm-contract) to see whether the names are gone from the bundle too.", |
| ); |
| } else if (features.multiStepDecoding) { |
| yield line("multiStepDecoding", SEVERITY.OK, null, `K=${features.decodeSteps} forward steps per GPU sync`); |
| } |
|
|
| yield features.decodeSteps === 1 |
| ? line( |
| "decodeSteps", |
| SEVERITY.TUNE, |
| "decode pays one GPU sync per token, which is the ~10 tok/s ceiling", |
| "decodeSteps is 1, so multi-step decoding is off", |
| "configure({ decodeSteps: 15 })", |
| ) |
| : line( |
| "decodeSteps", |
| SEVERITY.INFO, |
| null, |
| `K=${features.decodeSteps}; the best value falls as the model grows, since it is ms/step that fills the 100 ms tick`, |
| "configure({ decodeSteps: n })", |
| ); |
|
|
| yield line( |
| "engineCount", |
| SEVERITY.INFO, |
| null, |
| `pool cap ${features.maxEngines}, ${features.engines} built; it grows only when a second task waits`, |
| "configure({ engineCount: n }) — persisted, and applies to pools built after it", |
| ); |
|
|
| |
| const batching = features.computePassBatching; |
| if (batching !== null) { |
| yield batching > 1.5 |
| ? line("computePassBatching", SEVERITY.OK, null, `${batching.toFixed(1)} kernel launches per flush`) |
| : line( |
| "computePassBatching", |
| SEVERITY.DEGRADED, |
| "one compute pass per kernel launch, which measured ~2.5x slower", |
| `${batching.toFixed(1)} launches per flush — the build-time patch is not in effect`, |
| |
| null, |
| ); |
| } |
|
|
| if (settings.engineCount && features.maxEngines && settings.engineCount !== features.maxEngines) { |
| yield line( |
| "engineCountPending", |
| SEVERITY.INFO, |
| null, |
| `engineCount is ${settings.engineCount} but the live pool was built with ${features.maxEngines}`, |
| "Reload the model to apply it.", |
| ); |
| } |
| } |
|
|