File size: 11,539 Bytes
1944112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
/**
 * "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 }),
  };
}