File size: 11,436 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
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
/**
 * `environment()` β€” one call that says what this machine will do, and why.
 *
 * It absorbs the *read* half of `probe()`, `features()` and `estimateSpeed()`.
 * It is **read-only**. Writes go through `configure()`, which already existed
 * and stays the one way to change a setting.
 *
 * That split was a deliberate correction to the original plan, which had one
 * function do both and told them apart by argument shape. Implicit dispatch is
 * the opposite of foolproof: `environment({ scope: "device", decodeSteps: 8 })`
 * has no intuitive answer, and the reason it has none is that one function was
 * being asked to do two jobs. So reading and writing are different verbs, and
 * the report's `fix` field names the write to make instead of performing it.
 *
 * Every line carries the same five fields, because a caller should be able to
 * render the whole report without special-casing any of it:
 *
 *   severity  blocked | degraded | tune | info | ok
 *   affects   what the reader loses β€” in their terms, not the engine's
 *   cause     the measured fact behind the verdict
 *   fix       the exact call or instruction, or `null` when nothing can be done
 *   operable  whether this is reachable from JS at all
 *
 * `fix` is `null` for anything the caller genuinely cannot change β€” hardware,
 * build-time flags, browser settings that JS cannot reach. Reporting a
 * consequence with no remedy is still worth doing: "your second turn is slow
 * because this device caps storage buffers at 9" is the difference between a
 * bug report and an informed decision.
 *
 * **`canRun(modelId)` stays where it is.** It is per-*model*; this is
 * per-*device*, and model ranking belongs with model discovery. The two answer
 * different questions and merging them would make both worse.
 */
import { ERROR, EngineError } from "./errors.js";

export const SEVERITY = {
  /** Nothing will run until this is dealt with. */
  BLOCKED: "blocked",
  /** It runs, measurably worse. */
  DEGRADED: "degraded",
  /** It runs well; this is a dial worth turning. */
  TUNE: "tune",
  /** Worth knowing, nothing to do. */
  INFO: "info",
  /** Checked, and fine. */
  OK: "ok",
};

/** Worst-first, so `ok` can be computed and lines sorted by urgency. */
const RANK = [SEVERITY.BLOCKED, SEVERITY.DEGRADED, SEVERITY.TUNE, SEVERITY.INFO, SEVERITY.OK];

/** Paged prefill's binding count; below this, cross-turn KV reuse cannot build. */
const PAGED_PREFILL_STORAGE_BUFFERS = 10;

const SCOPES = new Set(["full", "local", "device"]);

/**
 * Builds the callable `engine.environment` β€” a function with `.measure()` on it.
 *
 * A function rather than an object because the common case is asking for the
 * whole report, and `environment()` should be the short spelling of that.
 *
 * @param {import("./engine.js").ScheduledEngine} engine
 */
export function environmentFacade(engine) {
  const environment = (opts = {}) => report(engine, opts);

  /**
   * One calibration generation, then the measured rate.
   *
   * Returns tok/s for the **current model** β€” the only one it can measure,
   * since measuring means generating. The device-level bytes/sec figure it
   * teaches the engine is what projects *other* models, and that projection is
   * `estimateSpeed(id)`'s job, not this one's.
   */
  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,
      // Calibration must not jump a queue a real caller is waiting in.
      priority: "background",
      preemptible: true,
    });
    return { ...(await engine.estimateSpeed(modelId)), tokens };
  };

  return environment;
}

async function report(engine, { scope = "full", ...rest } = {}) {
  // The one thing an implicit read/write API could not do: notice that a caller
  // meant to write. `configure()` is named in the error because that is the
  // call they wanted.
  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)];

  // A blocked device short-circuits everything below it. "K=15 forward steps
  // per GPU sync" is true and completely useless next to "no model can load" β€”
  // and burying the one line that matters under four that do not is exactly the
  // failure this report exists to prevent.
  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));
  }

  // `full` is the only scope that may touch the network: `estimateSpeed` reads
  // the model list, which pulls the ~6 MB WebLLM bundle when prebuilt models
  // are on. `local` exists precisely so a caller can ask cheaply and often.
  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) {
    // `reason` already carries the exact per-browser instruction, and it is not
    // reachable from JS β€” so it is the fix text, not an operable one.
    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"}`,
      // Genuinely not operable: it is a driver/browser limit, not a setting.
      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) {
  // The 2d guard's report. Nothing else surfaces it, and its whole point is
  // that the failure is otherwise silent.
  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",
  );

  // Observed, not asserted: a build with NO_PASS_MERGE=1 reports ~1.
  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`,
          // Build-time, not runtime: nothing a caller holding the engine can do.
          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.",
    );
  }
}