| /** | |
| * Engine pool + priority scheduler. | |
| * | |
| * Why this exists: the GPU gives us ~10 completion ticks per second, shared by | |
| * every caller (AI.md, "The 10 tok/s ceiling"). Two consequences drive the | |
| * whole design: | |
| * | |
| * - A single generation can never beat ~10 tok/s. When decode is *sync-bound* | |
| * - waiting on the ~100 ms tick rather than on the GPU - N concurrent ones | |
| * each still get ~10 tok/s, because they all wake on the same tick. That | |
| * held for the 0.8B (1.3-2.0x on two engines) and stops holding once the | |
| * model is big enough to keep the GPU busy: measured 1.06x on the 2B, where | |
| * two streams simply run at half speed each. Fan-out is worth having, but | |
| * the second engine earns its memory through isolation, not throughput. | |
| * - Every engine in the pool holds its own copy of the weights, so the pool is | |
| * small and its slots are the scarce resource worth scheduling. | |
| * | |
| * The scheduler is therefore four mechanisms and no more: | |
| * 1. Priority bands, FIFO within a band. | |
| * 2. Session supersession - a new job with the same `session` cancels the | |
| * previous one. This is the ghost-text primitive: every keystroke replaces | |
| * the last request instead of queueing behind it. | |
| * 3. Opt-in preemption - an `interactive` job with no free slot may interrupt | |
| * a running job that declared `preemptible`. The victim resolves with what | |
| * it produced so far, so it is never requeued and can never starve. | |
| * 4. Demand-driven growth - the pool starts at one engine and earns another | |
| * only when a *different* task is waiting on a busy pool. See `#grow`. | |
| * | |
| * Jobs carry a `task`: the unit that owns an engine. Every item of one batch | |
| * shares it, so "translate this page" is one task however many requests it is. | |
| * Slots are handed out per job, but the last free slot is reserved per task - | |
| * otherwise one batch fills the pool and ghost-text starves behind it. | |
| * | |
| * `createEngine` is injected so the scheduler can be tested without a GPU. | |
| */ | |
| import { JOB_KIND, PRIORITY, PRIORITY_ORDER } from "./constants.js"; | |
| let nextJobId = 0; | |
| export class EnginePool { | |
| #maxSize; | |
| #createEngine; | |
| #onStateChange; | |
| #slots = []; | |
| #queues = new Map(PRIORITY_ORDER.map((p) => [p, []])); | |
| #bySession = new Map(); | |
| #loading = null; | |
| #growing = null; | |
| /** Set to the failure reason once a grow attempt fails; growth then stops. */ | |
| #growthBlocked = null; | |
| /** | |
| * Bumped by `unload()`. An engine that finishes loading against a stale | |
| * generation is torn down rather than installed — see `load()`. | |
| */ | |
| #generation = 0; | |
| constructor({ size = 2, createEngine, onStateChange = () => {} }) { | |
| this.#maxSize = Math.max(1, Math.min(4, size)); | |
| this.#createEngine = createEngine; | |
| this.#onStateChange = onStateChange; | |
| } | |
| /** Engines that exist right now, which is not the same as the cap. */ | |
| get size() { | |
| return this.#slots.length; | |
| } | |
| get maxSize() { | |
| return this.#maxSize; | |
| } | |
| get loaded() { | |
| return this.#slots.length > 0; | |
| } | |
| /** | |
| * Brings up the first engine, and only the first. | |
| * | |
| * The pool used to build every engine here. It no longer does, because an | |
| * engine is a full copy of the weights - measured ~1.6 GB steady state for | |
| * the 0.8B, ~2.4 GB for a 2B - and staging a second one costs that much host | |
| * memory before the GPU ever sees it. On a 16 GB machine that is the | |
| * difference between working and swapping, and it was being paid up front | |
| * whether or not two tasks ever ran at once. `#grow` earns the rest. | |
| */ | |
| async load(onProgress = () => {}) { | |
| if (this.#loading) return this.#loading; | |
| const generation = this.#generation; | |
| this.#loading = (async () => { | |
| const engine = await this.#createEngine(0, (report) => | |
| onProgress({ ...report, engine: 1, engines: 1 }), | |
| ); | |
| // `unload()` may have run while this was still building — a cancelled | |
| // load, or a model switch. Installing it now would resurrect an engine | |
| // nobody holds a reference to, leaking its worker and a full copy of the | |
| // weights. `#grow()` has always guarded this; `load()` did not. | |
| if (generation !== this.#generation) { | |
| await engine.unload?.().catch(() => {}); | |
| return 0; | |
| } | |
| this.#slots = [{ engine, job: null }]; | |
| return this.#slots.length; | |
| })().finally(() => { | |
| this.#loading = null; | |
| }); | |
| return this.#loading; | |
| } | |
| async unload() { | |
| this.#generation += 1; | |
| for (const job of this.#allJobs()) this.#finish(job, { cancelled: true }); | |
| for (const p of this.#queues.values()) p.length = 0; | |
| this.#bySession.clear(); | |
| // A smaller model may well fit where this one did not. | |
| this.#growthBlocked = null; | |
| const slots = this.#slots; | |
| this.#slots = []; | |
| await Promise.all(slots.map((s) => s.engine.unload?.().catch(() => {}))); | |
| } | |
| /** | |
| * @param {object} spec | |
| * @param {object} spec.params passed straight to `engine.chat.completions.create` | |
| * @param {string} [spec.task] the unit that owns an engine; a whole batch shares one | |
| * @param {string} [spec.session] later jobs with this session supersede earlier ones | |
| * @param {string} [spec.priority] one of PRIORITY | |
| * @param {boolean} [spec.preemptible] may be interrupted by an interactive job | |
| * @param {(chunk: object) => void} [spec.onChunk] WebLLM's chunk, verbatim | |
| * @returns {Promise<{text: string, usage?: object, cancelled?: boolean, preempted?: boolean}>} | |
| */ | |
| submit(spec) { | |
| const priority = PRIORITY_ORDER.includes(spec.priority) ? spec.priority : PRIORITY.NORMAL; | |
| const id = spec.id ?? `job-${++nextJobId}`; | |
| const job = { | |
| id, | |
| // Unlabelled work is its own task, so two bare `chat` calls still compete | |
| // for separate engines the way two different callers would. | |
| task: spec.task ?? spec.session ?? id, | |
| session: spec.session, | |
| priority, | |
| preemptible: Boolean(spec.preemptible), | |
| // What the slot's engine should be asked to do. Everything else about a | |
| // job — priority, supersession, preemption, one-task-one-engine — is the | |
| // same either way, which is why this is a field and not a second pool. | |
| kind: spec.kind === JOB_KIND.EMBEDDING ? JOB_KIND.EMBEDDING : JOB_KIND.CHAT, | |
| params: spec.params, | |
| onChunk: spec.onChunk ?? (() => {}), | |
| text: "", | |
| slot: null, | |
| done: false, | |
| }; | |
| job.promise = new Promise((resolve) => (job.resolve = resolve)); | |
| if (job.session) { | |
| const previous = this.#bySession.get(job.session); | |
| // Superseded, not queued behind: the keystroke that produced the old | |
| // request is already stale. | |
| if (previous) this.cancel(previous.id); | |
| this.#bySession.set(job.session, job); | |
| } | |
| this.#queues.get(priority).push(job); | |
| this.#pump(); | |
| this.#emit(); | |
| return job.promise; | |
| } | |
| /** Cancels by job id or by session key. Returns how many jobs it stopped. */ | |
| cancel(idOrSession) { | |
| let stopped = 0; | |
| for (const job of this.#allJobs()) { | |
| if (job.id !== idOrSession && job.session !== idOrSession) continue; | |
| stopped += 1; | |
| if (job.slot === null) { | |
| this.#dequeue(job); | |
| this.#finish(job, { cancelled: true }); | |
| } else { | |
| job.cancelling = true; | |
| this.#slots[job.slot].engine.interruptGenerate(); | |
| } | |
| } | |
| if (stopped) this.#emit(); | |
| return stopped; | |
| } | |
| /** | |
| * Pushes a runtime setting to every engine that accepts one. | |
| * | |
| * Separate from `load()` because the settings this carries — `decodeSteps` so | |
| * far — are per-generation knobs, not per-model ones: retuning them must not | |
| * cost a reload of the weights. | |
| */ | |
| configure(patch) { | |
| let applied = 0; | |
| for (const slot of this.#slots) { | |
| if (typeof slot.engine.configure !== "function") continue; | |
| slot.engine.configure(patch); | |
| applied += 1; | |
| } | |
| return applied; | |
| } | |
| status() { | |
| return { | |
| size: this.#slots.length, | |
| maxSize: this.#maxSize, | |
| growing: this.#growing !== null, | |
| // Non-null once a second engine failed to come up; the UI can say why the | |
| // pool is smaller than the cap instead of looking stuck. | |
| growthBlocked: this.#growthBlocked, | |
| busy: this.#slots.filter((s) => s.job !== null).length, | |
| queued: PRIORITY_ORDER.reduce((n, p) => n + this.#queues.get(p).length, 0), | |
| queuedByPriority: Object.fromEntries( | |
| PRIORITY_ORDER.map((p) => [p, this.#queues.get(p).length]), | |
| ), | |
| }; | |
| } | |
| // ------------------------------------------------------------ internals --- | |
| /** | |
| * Adds one engine, but only when a second one would actually buy something. | |
| * | |
| * There is no budget to check against: Firefox implements neither | |
| * `navigator.deviceMemory` nor `performance.memory`, and `storage.estimate()` | |
| * measures disk quota, not RAM. Nothing reports free memory to an extension. | |
| * | |
| * So the pool does not predict, it probes - and it only probes when the | |
| * answer matters. A failed load is taken as the answer and is not retried: | |
| * the retry would cost another full staging pass to learn the same thing. | |
| */ | |
| #grow() { | |
| if (this.#growing || this.#growthBlocked || this.#loading) return; | |
| if (this.#slots.length >= this.#maxSize) return; | |
| if (!this.#crossTaskDemand()) return; | |
| const index = this.#slots.length; | |
| this.#growing = (async () => { | |
| try { | |
| const engine = await this.#createEngine(index, () => {}); | |
| // `unload()` may have emptied the pool while this was still loading; | |
| // pushing then would resurrect a slot for a model nobody asked for. | |
| if (this.#slots.length === index) this.#slots.push({ engine, job: null }); | |
| else await engine.unload?.().catch(() => {}); | |
| } catch (err) { | |
| // Almost always memory. Staying at the current size is the right | |
| // outcome, not an error owed to whichever job happened to trigger it. | |
| this.#growthBlocked = err?.message ?? String(err); | |
| } | |
| })().finally(() => { | |
| this.#growing = null; | |
| this.#pump(); | |
| this.#emit(); | |
| }); | |
| } | |
| /** | |
| * True when a queued job belongs to a task that is not already running. | |
| * | |
| * This is the entire growth policy. A second engine exists so that a | |
| * translation and a ghost-text completion can run at once - not to make one | |
| * batch finish sooner. Four queued items of the same batch keep the pool at | |
| * one engine; one queued completion alongside them grows it. | |
| */ | |
| #crossTaskDemand() { | |
| const running = this.#runningTasks(); | |
| if (running.size === 0) return false; | |
| for (const job of this.#queued()) if (!running.has(job.task)) return true; | |
| return false; | |
| } | |
| #runningTasks() { | |
| const tasks = new Set(); | |
| for (const slot of this.#slots) if (slot.job) tasks.add(slot.job.task); | |
| return tasks; | |
| } | |
| *#queued() { | |
| for (const p of PRIORITY_ORDER) yield* this.#queues.get(p); | |
| } | |
| *#allJobs() { | |
| for (const slot of this.#slots) if (slot.job) yield slot.job; | |
| yield* this.#queued(); | |
| } | |
| #dequeue(job) { | |
| const queue = this.#queues.get(job.priority); | |
| const i = queue.indexOf(job); | |
| if (i >= 0) queue.splice(i, 1); | |
| } | |
| #nextJob() { | |
| for (const p of PRIORITY_ORDER) { | |
| const queue = this.#queues.get(p); | |
| if (queue.length) return queue[0]; | |
| } | |
| return null; | |
| } | |
| #pump() { | |
| for (;;) { | |
| const slot = this.#slots.findIndex((s) => s.job === null); | |
| const job = slot >= 0 ? this.#pick() : this.#nextJob(); | |
| if (!job) break; | |
| if (slot >= 0) { | |
| this.#dequeue(job); | |
| this.#start(slot, job); | |
| continue; | |
| } | |
| // No slot. Only an interactive job is allowed to take one by force, and | |
| // only from a job that opted in. | |
| if (job.priority !== PRIORITY.INTERACTIVE) break; | |
| const victim = this.#slots.find((s) => s.job?.preemptible && !s.job.cancelling); | |
| if (!victim) break; | |
| victim.job.preempting = true; | |
| victim.job.cancelling = true; | |
| victim.engine.interruptGenerate(); | |
| break; // the freed slot re-enters #pump when the victim settles | |
| } | |
| this.#grow(); | |
| } | |
| /** | |
| * Which queued job takes a free slot: the first, by priority, whose task is | |
| * not already running. | |
| * | |
| * **One task holds at most one engine.** Letting a batch spread over the pool | |
| * used to be the point - it was worth 1.3-2.0x on the 0.8B, where decode was | |
| * sync-bound and a second stream filled idle GPU. On the 2B it is worth | |
| * 1.06x: the GPU is busy, so two streams of the same work just run at half | |
| * speed each (measured; `engine scaling:` in the e2e). That buys nothing, and | |
| * it costs the thing a second engine is actually for - a page translation | |
| * would sit on both engines and ghost-text would wait behind it. | |
| * | |
| * So the rule is flat, and two runnable tasks are therefore always running at | |
| * once whenever two engines exist. An engine may idle while one task has work | |
| * queued; that is the ~6% being deliberately given up. | |
| * | |
| * `interactive` is the one exception: that band exists for work a human is | |
| * waiting on keystroke-by-keystroke, and it takes a free engine regardless. | |
| */ | |
| #pick() { | |
| const head = this.#nextJob(); | |
| if (!head || head.priority === PRIORITY.INTERACTIVE) return head; | |
| const running = this.#runningTasks(); | |
| for (const job of this.#queued()) if (!running.has(job.task)) return job; | |
| return null; | |
| } | |
| #start(slotIndex, job) { | |
| const slot = this.#slots[slotIndex]; | |
| slot.job = job; | |
| job.slot = slotIndex; | |
| job.startedAt = performance.now(); | |
| job.engineIndex = slotIndex; | |
| this.#emit(); | |
| (async () => { | |
| try { | |
| if (job.kind === JOB_KIND.EMBEDDING) { | |
| // One shot, no stream: an embedding is a single forward pass and | |
| // WebLLM returns the whole OpenAI-shaped response at once. There is | |
| // no decode loop here, so none of the streaming bookkeeping applies — | |
| // but the queueing, priority and preemption above all still did. | |
| const res = await slot.engine.embeddings.create({ ...job.params }); | |
| job.usage = res.usage; | |
| job.embeddings = res.data; | |
| this.#finish(job, { cancelled: Boolean(job.cancelling && !job.preempting) }); | |
| return; | |
| } | |
| const stream = await slot.engine.chat.completions.create({ | |
| ...job.params, | |
| stream: true, | |
| stream_options: { include_usage: true }, | |
| }); | |
| for await (const chunk of stream) { | |
| const choice = chunk.choices?.[0]; | |
| job.text += choice?.delta?.content ?? ""; | |
| // WebLLM's own "stop" | "length" | "abort" | "tool_calls". Kept | |
| // because a caller cannot otherwise tell a natural stop from a | |
| // `max_tokens` truncation. | |
| if (choice?.finish_reason) job.finishReason = choice.finish_reason; | |
| // Assigned, not merged: WebLLM parses the whole output message at the | |
| // end and emits tool calls complete in one terminal chunk. It never | |
| // streams the OpenAI-style fragments, so there is nothing to | |
| // accumulate and a merge step would be machinery for a wire shape | |
| // that is never produced. | |
| if (choice?.delta?.tool_calls) job.toolCalls = choice.delta.tool_calls; | |
| if (chunk.usage) job.usage = chunk.usage; | |
| // The chunk goes on verbatim. It is already a compliant OpenAI | |
| // envelope carrying id / created / model / logprobs / | |
| // system_fingerprint; rebuilding one downstream only loses fields. | |
| job.onChunk(chunk); | |
| } | |
| this.#finish(job, { | |
| cancelled: Boolean(job.cancelling && !job.preempting), | |
| preempted: Boolean(job.preempting), | |
| }); | |
| } catch (err) { | |
| this.#finish(job, { error: String(err?.message ?? err) }); | |
| } finally { | |
| slot.job = null; | |
| job.slot = null; | |
| this.#pump(); | |
| this.#emit(); | |
| } | |
| })(); | |
| } | |
| #finish(job, extra) { | |
| if (job.done) return; | |
| job.done = true; | |
| if (this.#bySession.get(job.session) === job) this.#bySession.delete(job.session); | |
| job.resolve({ | |
| id: job.id, | |
| text: job.text, | |
| usage: job.usage, | |
| ...(job.embeddings ? { embeddings: job.embeddings } : {}), | |
| ...(job.toolCalls ? { toolCalls: job.toolCalls } : {}), | |
| // Interrupted work reports "abort" whatever the stream last said. | |
| finishReason: extra.cancelled || extra.preempted ? "abort" : job.finishReason, | |
| engineIndex: job.engineIndex, | |
| startedAt: job.startedAt, | |
| finishedAt: performance.now(), | |
| ...extra, | |
| }); | |
| } | |
| #emit() { | |
| this.#onStateChange(this.status()); | |
| } | |
| } | |