mlx-bun 0.0.9 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,11 @@ export interface PromptCacheEntry {
17
17
  /** Namespace key — adapter spec for LoRA requests ("" = base model).
18
18
  * KV computed under one adapter must never seed another's prefill. */
19
19
  ns: string;
20
+ /** Release hook for entries restored from the SSD cold tier: unmaps the
21
+ * backing COW file mapping. MUST run only after `caches` are disposed
22
+ * (they may alias file pages); every dispose path here honors that, and
23
+ * callers who take() an entry carry the thunk with it. */
24
+ retain?: () => void;
20
25
  }
21
26
 
22
27
  function cacheBytes(caches: Cache[]): number {
@@ -26,7 +31,7 @@ function cacheBytes(caches: Cache[]): number {
26
31
  }
27
32
 
28
33
  /** Length of the longest common prefix of two token sequences. */
29
- function commonPrefixLength(a: number[], b: number[]): number {
34
+ export function commonPrefixLength(a: number[], b: number[]): number {
30
35
  const n = Math.min(a.length, b.length);
31
36
  let i = 0;
32
37
  while (i < n && a[i] === b[i]) i++;
@@ -39,9 +44,22 @@ export class PromptCache {
39
44
  #clock = 0;
40
45
  hits = 0;
41
46
  misses = 0;
47
+ /** Cold-tier spill hook: called with an entry ABOUT to be evicted (still
48
+ * alive — the hook may serialize its caches); the cache disposes it after.
49
+ * Failures are the hook's problem; eviction proceeds regardless. */
50
+ readonly #spill: ((entry: PromptCacheEntry) => void) | null;
42
51
 
43
- constructor(maxBytes: number) {
52
+ constructor(maxBytes: number, spill: ((entry: PromptCacheEntry) => void) | null = null) {
44
53
  this.maxBytes = maxBytes;
54
+ this.#spill = spill;
55
+ }
56
+
57
+ #disposeEntry(entry: PromptCacheEntry, spillFirst: boolean): void {
58
+ if (spillFirst && this.#spill) {
59
+ try { this.#spill(entry); } catch {}
60
+ }
61
+ for (const c of entry.caches) c.dispose();
62
+ entry.retain?.();
45
63
  }
46
64
 
47
65
  get totalBytes(): number {
@@ -88,26 +106,54 @@ export class PromptCache {
88
106
  return entry;
89
107
  }
90
108
 
91
- /** Insert (or reinsert) an entry; evicts LRU entries over the byte cap.
92
- * If the entry itself exceeds the cap it is disposed, not stored. */
93
- put(tokens: number[], caches: Cache[], ns = ""): void {
109
+ /** Longest usable prefix length available for `prompt` WITHOUT taking
110
+ * (the SSD tier compares this against its own best before restoring). */
111
+ peekPrefixLen(prompt: number[], ns = ""): number {
112
+ let best = 0;
113
+ for (const { entry: e } of this.#entries) {
114
+ if (e.ns !== ns) continue;
115
+ const p = Math.min(commonPrefixLength(e.tokens, prompt), prompt.length - 1);
116
+ if (p <= best) continue;
117
+ const trimNeeded = e.tokens.length - p; // same usability rule as take()
118
+ if (trimNeeded > 0 && !e.caches.every((c) => c.isTrimmable())) continue;
119
+ best = p;
120
+ }
121
+ return best;
122
+ }
123
+
124
+ /** Read-only exact-token lookup (the write-behind snapshot path: the
125
+ * entry stays owned by the cache; the caller only reads array state,
126
+ * under the gateway lock so no generation is mutating it). */
127
+ findExact(tokens: number[], ns = ""): PromptCacheEntry | null {
128
+ for (const { entry: e } of this.#entries) {
129
+ if (e.ns !== ns || e.tokens.length !== tokens.length) continue;
130
+ if (commonPrefixLength(e.tokens, tokens) === tokens.length) return e;
131
+ }
132
+ return null;
133
+ }
134
+
135
+ /** Insert (or reinsert) an entry; evicts LRU entries over the byte cap
136
+ * (spilling each to the cold tier first, when one is attached). If the
137
+ * entry itself exceeds the cap it is spilled + disposed, not stored. */
138
+ put(tokens: number[], caches: Cache[], ns = "", retain?: () => void): void {
94
139
  const bytes = cacheBytes(caches);
140
+ const entry: PromptCacheEntry = { tokens, caches, ns, retain };
95
141
  if (bytes > this.maxBytes) {
96
- for (const c of caches) c.dispose();
142
+ this.#disposeEntry(entry, true);
97
143
  return;
98
144
  }
99
- this.#entries.push({ entry: { tokens, caches, ns }, bytes, lastUsed: ++this.#clock });
145
+ this.#entries.push({ entry, bytes, lastUsed: ++this.#clock });
100
146
  while (this.totalBytes > this.maxBytes && this.#entries.length > 1) {
101
147
  let lruIdx = 0;
102
148
  for (let i = 1; i < this.#entries.length; i++)
103
149
  if (this.#entries[i]!.lastUsed < this.#entries[lruIdx]!.lastUsed) lruIdx = i;
104
150
  const [evicted] = this.#entries.splice(lruIdx, 1);
105
- for (const c of evicted!.entry.caches) c.dispose();
151
+ this.#disposeEntry(evicted!.entry, true);
106
152
  }
107
153
  }
108
154
 
109
155
  clear(): void {
110
- for (const e of this.#entries) for (const c of e.entry.caches) c.dispose();
156
+ for (const e of this.#entries) this.#disposeEntry(e.entry, false);
111
157
  this.#entries = [];
112
158
  }
113
159
  }
package/src/registry.ts CHANGED
@@ -465,7 +465,10 @@ function visionConfigTypeOf(config: Record<string, any>): string | null {
465
465
  return typeof vt === "string" && vt.endsWith("_vision") ? vt : null;
466
466
  }
467
467
 
468
- async function scanSnapshot(dir: string, repoId: string): Promise<ModelRecord | null> {
468
+ /** Build a ModelRecord straight from a snapshot directory (also the scan()
469
+ * workhorse). Exported for explicit-path loads (`serve --model <dir>`),
470
+ * which must not depend on the registry DB having scanned that dir. */
471
+ export async function scanSnapshot(dir: string, repoId: string): Promise<ModelRecord | null> {
469
472
  const configPath = join(dir, "config.json");
470
473
  if (!existsSync(configPath)) return null;
471
474
 
@@ -21,9 +21,12 @@
21
21
  // mask). Full layers share one leftPad/offset (all rows advance together); the
22
22
  // rotating caches self-track per-row leftPad/offset as the ring wraps. The
23
23
  // per-row absolute position stays consistent across both (full: offset-leftPad;
24
- // rot: offsetArr) — see docs/design/parallel-slots.md. Models whose caches
25
- // don't support the dynamic-B ops (e.g. Qwen3.5's SSMCache) never reach this
26
- // scheduler — GenerationGateway.willBatch gates on cache capability.
24
+ // rot: offsetArr) — see docs/design/parallel-slots.md. Hybrid gated-DeltaNet
25
+ // models (Qwen3.5) add "ssm" layers: SSMCache state is plain [B,...] with no
26
+ // temporal axis and no padding (rows solo-prefill unpadded, decode feeds one
27
+ // real token per row), so merge/filter are B-axis concat/take and the cache
28
+ // passes through the step unwrapped (no mask, no per-row RoPE — full layers
29
+ // carry positions). Gate: GenerationGateway.willBatch on cache capability.
27
30
  //
28
31
  // Engine mechanics (the serial decode loop's hygiene, transplanted —
29
32
  // batching-v2-plan step 3):
@@ -57,12 +60,23 @@ import { clearCache } from "../mlx/ffi";
57
60
  import { KVCache, RotatingKVCache, type Cache } from "../model/gemma4-base";
58
61
  import { BatchedDecodeMaskCache, mergeKVRows, filterKVRows } from "../model/batched-mask";
59
62
  import { BatchedRotatingCache } from "../model/batched-rotating";
63
+ import { SSMCache } from "../model/qwen3-delta";
60
64
  import type { RuntimeModel } from "../model/factory";
61
65
 
62
66
  /** Decode-pipeline kill switch (read once at load, like the serial loop's
63
67
  * MLX_BUN_COMPILED_DECODE): 1 ⇒ read each step's tokens synchronously. */
64
68
  const NO_PIPELINE = process.env.MLX_BUN_BATCH_NO_PIPELINE === "1";
65
69
 
70
+ // NOTE — oMLX-style "adaptive burst decode" (several steps per event-loop
71
+ // yield, omlx/engine_core.py _step_burst) was ported here 2026-07-02 and
72
+ // REFUTED by measurement: on this runtime it REGRESSED cpm5 B=4 aggregate
73
+ // 345→289 tok/s, batch-lane B=1 149→121, and TTFT ~+100 ms (first-token SSE
74
+ // flush waits out the burst budget). Their win exists because each Python
75
+ // step hand-off ping-pongs the GIL with asyncio/uvicorn (~1 ms/token); Bun's
76
+ // setImmediate hop costs microseconds, so bursting here only delays socket
77
+ // flushes. Don't re-add without new evidence — the per-yield step below is
78
+ // the measured optimum (docs/design/batching-perf-path.md P4 notes).
79
+
66
80
  /** A token sampler for one row: (logits [1,V], step) → token array [1] on
67
81
  * device. Greedy is `(l) => ops.argmaxAxis(l, -1)`; richer closures fold in
68
82
  * temperature / top-p / logits processors + history (built per request from
@@ -127,7 +141,7 @@ export interface BatchSchedulerOptions {
127
141
  prefillChunkSize?: number;
128
142
  }
129
143
 
130
- type LayerInner = KVCache | BatchedRotatingCache;
144
+ type LayerInner = KVCache | BatchedRotatingCache | SSMCache;
131
145
  type Row1 = { keys: MlxArray; values: MlxArray };
132
146
 
133
147
  export class BatchScheduler {
@@ -146,7 +160,7 @@ export class BatchScheduler {
146
160
  readonly #lock: ExclusiveLock | undefined;
147
161
  readonly #admissionHeld: (() => boolean) | undefined;
148
162
  readonly #prefillChunkSize: number;
149
- readonly #kinds: ("full" | "rot")[]; // per-layer attention type
163
+ readonly #kinds: ("full" | "rot" | "ssm")[]; // per-layer attention type
150
164
  readonly #rotMaxSize: number[]; // per-layer sliding window (rot layers only)
151
165
 
152
166
  constructor(private readonly model: RuntimeModel, opts: BatchSchedulerOptions) {
@@ -155,7 +169,9 @@ export class BatchScheduler {
155
169
  this.#admissionHeld = opts.admissionHeld;
156
170
  this.#prefillChunkSize = Math.max(1, Math.floor(opts.prefillChunkSize ?? 2048));
157
171
  const proto = model.makeCache(); // fresh caches hold no buffers
158
- this.#kinds = proto.map((c) => (c instanceof RotatingKVCache ? "rot" : "full"));
172
+ this.#kinds = proto.map((c) =>
173
+ c instanceof RotatingKVCache ? "rot" : c instanceof SSMCache ? "ssm" : "full",
174
+ );
159
175
  this.#rotMaxSize = proto.map((c) => (c instanceof RotatingKVCache ? c.maxSize : 0));
160
176
  for (const c of proto) c.dispose();
161
177
  }
@@ -314,6 +330,16 @@ export class BatchScheduler {
314
330
  const newInners: LayerInner[] = [];
315
331
  let newFullPad = this.#fullLeftPad;
316
332
  for (let layer = 0; layer < this.#kinds.length; layer++) {
333
+ if (this.#kinds[layer] === "ssm") {
334
+ // No temporal axis, no left-pad: B-axis concat of the state slots.
335
+ // mergeRows steals the solo arrays when the batch starts cold, so the
336
+ // unconditional p.solo dispose below stays safe either way.
337
+ newInners.push(SSMCache.mergeRows(
338
+ (prev?.[layer] as SSMCache | undefined) ?? null,
339
+ p.solo[layer] as SSMCache,
340
+ ));
341
+ continue;
342
+ }
317
343
  const soloC = p.solo[layer] as KVCache | RotatingKVCache;
318
344
  const [sk, sv] = soloC.temporalView();
319
345
  const newRow: Row1 = { keys: sk, values: sv };
@@ -391,10 +417,13 @@ export class BatchScheduler {
391
417
  let nextToks: MlxArray | null = null;
392
418
  if (anyLive) {
393
419
  // Per-layer forward cache: rot layers use the persistent
394
- // BatchedRotatingCache directly; full layers get a fresh
395
- // BatchedDecodeMaskCache wrapper.
420
+ // BatchedRotatingCache directly; ssm layers are already [B,...] state
421
+ // with no padding (no mask, no per-row RoPE — pass through); full
422
+ // layers get a fresh BatchedDecodeMaskCache wrapper.
396
423
  const fwd: Cache[] = inners.map((c) =>
397
- c instanceof BatchedRotatingCache ? c : new BatchedDecodeMaskCache(c, B, this.#fullLeftPad, null),
424
+ c instanceof BatchedRotatingCache || c instanceof SSMCache
425
+ ? c
426
+ : new BatchedDecodeMaskCache(c, B, this.#fullLeftPad, null),
398
427
  );
399
428
  try {
400
429
  const ids = this.#pendingToks
@@ -524,6 +553,9 @@ export class BatchScheduler {
524
553
  if (inner instanceof BatchedRotatingCache) {
525
554
  inner.filter(keep); // in-place (mutates + drops rows + reduces padding)
526
555
  out.push(inner);
556
+ } else if (inner instanceof SSMCache) {
557
+ inner.filter(keep); // in-place B-axis take on both state slots
558
+ out.push(inner);
527
559
  } else {
528
560
  const [k0, v0] = inner.temporalView();
529
561
  const f = filterKVRows(k0, v0, keep);
@@ -35,11 +35,13 @@
35
35
  // in v1 (cachedTokens = 0).
36
36
 
37
37
  import { MlxArray } from "../mlx/array";
38
+ import * as ops from "../mlx/ops";
38
39
  import type { RuntimeModel } from "../model/factory";
39
40
  import { DiffusionGemmaModel } from "../model/diffusion-gemma";
40
41
  import { KVCache, RotatingKVCache } from "../model/gemma4-base";
42
+ import { SSMCache } from "../model/qwen3-delta";
41
43
  import type { GenerateOptions, GenerateStats, TokenLogprobs } from "../generate";
42
- import { makeSampler, toLogprobs } from "../sampler";
44
+ import { makeSampler, makeLogitsProcessors, toLogprobs } from "../sampler";
43
45
  import { BatchScheduler } from "./batch-scheduler";
44
46
 
45
47
  /** Async mutex: acquire() resolves to a release fn; releases run FIFO. */
@@ -126,13 +128,18 @@ export class GenerationGateway {
126
128
 
127
129
  /** Cache-capability gate (mirrors mlx-lm server.py's all-caches-have-merge
128
130
  * check): the scheduler's dynamic-B ops (temporalView/merge/filter) exist
129
- * only on KVCache and RotatingKVCache. A hybrid model (Qwen3.5's SSMCache)
130
- * routes serial until the batched-SSM port (batching-v2-plan item h). */
131
+ * on KVCache, RotatingKVCache, and SSMCache (hybrid gated-DeltaNet models
132
+ * — Qwen3.5 — batch via SSMCache.mergeRows/filter; MLX_BUN_BATCH_SSM=0 is
133
+ * the kill switch back to serial routing). */
131
134
  #modelCachesBatchable(): boolean {
132
135
  if (this.#cacheBatchable === null) {
136
+ const ssmOk = process.env.MLX_BUN_BATCH_SSM !== "0";
133
137
  const proto = this.model.makeCache(); // fresh caches hold no buffers
134
138
  this.#cacheBatchable = proto.every(
135
- (c) => c instanceof KVCache || c instanceof RotatingKVCache,
139
+ (c) =>
140
+ c instanceof KVCache ||
141
+ c instanceof RotatingKVCache ||
142
+ (ssmOk && c instanceof SSMCache),
136
143
  );
137
144
  for (const c of proto) c.dispose();
138
145
  }
@@ -146,11 +153,15 @@ export class GenerationGateway {
146
153
  if (this.model instanceof DiffusionGemmaModel) return false;
147
154
  if (!this.batchingEnabled) return false;
148
155
  if (!this.#modelCachesBatchable()) return false;
156
+ // repetitionPenalty / logits extras (min_p, XTC, logit_bias,
157
+ // presence/frequency penalties) batch: the per-row sampler folds in the
158
+ // same makeSampler/makeLogitsProcessors closures the serial lane runs,
159
+ // over a per-row device-side token history. (Load-bearing beyond opt-in
160
+ // knobs: some models — Qwen3.5 — ship a default repetition penalty in
161
+ // generation_config.json, which used to route EVERY request serial.)
149
162
  return (
150
163
  !shape.hasVision &&
151
164
  !shape.hasAdapters &&
152
- !shape.hasRepetitionPenalty &&
153
- !shape.hasLogitsExtras &&
154
165
  !shape.wantsLogprobs &&
155
166
  !shape.userSeed &&
156
167
  !shape.kvQuant
@@ -187,27 +198,75 @@ export class GenerationGateway {
187
198
  vision: Vision | undefined,
188
199
  shape: RequestShape,
189
200
  ): Promise<GenerateStats> {
190
- if (!this.willBatch(shape))
191
- return this.runExclusive(() => this.serialRun(promptIds, options, onToken, vision));
201
+ if (!this.willBatch(shape)) {
202
+ // Serial decode is an unbroken microtask chain (FFI + generator
203
+ // resumes): without a periodic macrotask hop the event loop starves for
204
+ // the WHOLE generation — /stats, /health, and new-connection accepts
205
+ // stall until the request finishes (measured 2.5 s on a 512-token cpm5
206
+ // run). Rate-limited to ≥25 ms — the SSE flush hop's budget, ~0.1% of
207
+ // decode — and applied HERE so every serial caller (streaming or not,
208
+ // all four API surfaces) is covered. The batch lane needs nothing: its
209
+ // drive loop yields per step (measured /stats ~8 ms mid-generation).
210
+ let lastHop = performance.now();
211
+ const hop = (): Promise<void> => new Promise<void>((r) => setImmediate(r));
212
+ const hoppingOnToken: OnToken = (token, lp) => {
213
+ const r = onToken(token, lp);
214
+ if (r === false) return false;
215
+ if (performance.now() - lastHop < 25) return r;
216
+ lastHop = performance.now();
217
+ if (r instanceof Promise) return r.then((v) => (v === false ? false : hop().then(() => v)));
218
+ return hop().then(() => r);
219
+ };
220
+ return this.runExclusive(() => this.serialRun(promptIds, options, hoppingOnToken, vision));
221
+ }
192
222
 
193
- // Per-row sampler, mirroring generate()'s sampleStep (no logits processors:
194
- // repetition penalty was excluded by willBatch). Greedy (temperature 0) is
195
- // argmax; temp>0 uses this request's own seed → independent per-row RNG.
223
+ // Per-row sampler, mirroring generate()'s sampleStep: logits processors
224
+ // (logit_bias / repetition / presence / frequency penalties — the same
225
+ // device-side closures the serial lane runs) fold in over a per-row token
226
+ // history seeded with the prompt, then the sampler. Greedy (temperature 0)
227
+ // is argmax; temp>0 uses this request's own seed → independent per-row RNG.
196
228
  const sampler = makeSampler(options);
229
+ const processors = makeLogitsProcessors(options);
230
+ // Device-side history (prompt + generated), maintained only when a
231
+ // processor needs it — generate()'s pushHistory, one per row. The sample
232
+ // closure is called once per sampled token in order, so extending it
233
+ // inside the closure keeps history exact.
234
+ let history: MlxArray | null = null;
235
+ if (processors.length > 0) history = ops.fromInt32(promptIds, [promptIds.length]);
197
236
  const sample = (logits1V: MlxArray, step: number): MlxArray => {
198
- const lp = toLogprobs(logits1V);
237
+ let cur = logits1V; // caller-owned; only intermediates are disposed here
238
+ for (const p of processors) {
239
+ const next = p(history, cur);
240
+ if (cur !== logits1V) cur.dispose();
241
+ cur = next;
242
+ }
243
+ const lp = toLogprobs(cur);
244
+ if (cur !== logits1V) cur.dispose();
199
245
  const tok = sampler(lp, step);
200
246
  lp.dispose();
247
+ if (history) {
248
+ const t1 = ops.reshape(tok, [1]);
249
+ const prev = history;
250
+ history = ops.concatAxis([prev, t1], 0);
251
+ prev.dispose();
252
+ t1.dispose();
253
+ }
201
254
  return tok;
202
255
  };
203
256
 
204
- const st = await this.#ensureScheduler().submit({
205
- promptIds,
206
- maxTokens: options.maxTokens ?? 512,
207
- eosTokenIds: options.eosTokenIds ?? this.model.config.eosTokenIds,
208
- sample,
209
- onToken,
210
- });
257
+ let st;
258
+ try {
259
+ st = await this.#ensureScheduler().submit({
260
+ promptIds,
261
+ maxTokens: options.maxTokens ?? 512,
262
+ eosTokenIds: options.eosTokenIds ?? this.model.config.eosTokenIds,
263
+ sample,
264
+ onToken,
265
+ });
266
+ } finally {
267
+ history?.dispose();
268
+ history = null;
269
+ }
211
270
 
212
271
  return {
213
272
  promptTokens: st.promptTokens,
package/src/server.ts CHANGED
@@ -41,6 +41,8 @@ import {
41
41
  TOOL_CALL_END, TOOL_CALL_START,
42
42
  } from "./tool-call";
43
43
  import { PromptCache } from "./prompt-cache";
44
+ import { SsdCacheStore } from "./ssd-cache";
45
+ import { configFingerprint } from "./model/fingerprint";
44
46
  import {
45
47
  anthropicToChatBody, chatJsonToAnthropic, translateOpenAiSse,
46
48
  type AnthropicRequest,
@@ -123,6 +125,16 @@ export interface ServerOptions {
123
125
  * request's explicit `adapter` (including "none") always wins. Hot-swap
124
126
  * via /v1/adapters is unchanged. */
125
127
  defaultAdapter?: string;
128
+ /** SSD cold tier for the prompt/KV cache (`--ssd-cache <dir>`): prefix
129
+ * KV survives RAM eviction AND server restarts — the coding-agent
130
+ * long-context TTFT win (docs/design/ssd-kv-cold-tier.md). Off unless
131
+ * a directory is given. Serial lane only (where the prompt cache lives). */
132
+ ssdCacheDir?: string;
133
+ /** Byte cap for the SSD tier (default 32 GiB). */
134
+ ssdCacheMaxBytes?: number;
135
+ /** Verify every tensor hash on restore (`--ssd-cache-verify`) — reads all
136
+ * bytes eagerly, defeating lazy fault-in; integrity paranoia only. */
137
+ ssdCacheVerify?: boolean;
126
138
  }
127
139
 
128
140
  export interface ServerContext {
@@ -976,7 +988,63 @@ export function createServer(
976
988
  // lane (see GenerationGateway.willBatch).
977
989
  const batch = Math.max(1, Math.floor(serverOptions.batch ?? 1));
978
990
 
979
- const promptCache = new PromptCache(serverOptions.promptCacheBytes ?? 2e9);
991
+ // KV-quant scheme, resolved once: kv_config.json by default (optiq
992
+ // serve's headline behavior), overridable to uniform bits or off.
993
+ // KV quant scheme. The serial default is the model's mixed-precision config
994
+ // (optiq parity). But `--batch N` is a bf16 continuous-batching MODE (= mlx-lm
995
+ // B=N parity), so when KV quant is left UNSET under `--batch N` it defaults to
996
+ // bf16 — the batch path engages out of the box. An EXPLICIT --kv-quant
997
+ // (config / bits) is still honored, but those requests then route to the
998
+ // serial path (batched quantized KV is the L2 follow-up); warned below.
999
+ const configScheme = ctx.kvConfig?.length ? { kvConfig: ctx.kvConfig } : {};
1000
+ const kvScheme: Pick<GenerateOptions, "kvBits" | "kvConfig" | "quantizedKvStart"> =
1001
+ serverOptions.kvQuant === "off" ? {}
1002
+ : serverOptions.kvQuant === "config" ? configScheme
1003
+ : typeof serverOptions.kvQuant === "number"
1004
+ ? { kvBits: serverOptions.kvQuant, quantizedKvStart: 0 }
1005
+ : batch > 1 ? {} // unset + batching → bf16 mode (Option B)
1006
+ : configScheme; // unset + serial → optiq-parity mixed-precision default
1007
+ if (batch > 1 && (kvScheme.kvConfig?.length || kvScheme.kvBits))
1008
+ console.warn(
1009
+ `[batch] --batch ${batch} with explicit --kv-quant: batched serving (v1) is ` +
1010
+ `bf16-only, so kv-quant requests route to the serial path (no batching for them). ` +
1011
+ `Omit --kv-quant to batch in bf16. (docs/design/parallel-slots.md)`,
1012
+ );
1013
+
1014
+ // SSD cold tier (docs/design/ssd-kv-cold-tier.md): prefix KV survives RAM
1015
+ // eviction and restarts. Compatibility key = configFingerprint (graph
1016
+ // shape) + the EFFECTIVE kv scheme (flags pick bf16 vs config vs uniform
1017
+ // on the same model — restored caches must match what serving produces) +
1018
+ // tokenizer hash (ids must keep meaning the same text).
1019
+ let ssdStore: SsdCacheStore | null = null;
1020
+ if (serverOptions.ssdCacheDir) {
1021
+ if ((serverOptions.promptCacheBytes ?? 2e9) <= 0)
1022
+ throw new Error("--ssd-cache requires the RAM prompt cache (--prompt-cache 0 disables it)");
1023
+ const schemeKey = kvScheme.kvBits
1024
+ ? `kv${kvScheme.kvBits}`
1025
+ : kvScheme.kvConfig?.length ? "config" : "bf16";
1026
+ const tokJson = readFileSync(`${ctx.model.config.modelDir}/tokenizer.json`);
1027
+ ssdStore = new SsdCacheStore({
1028
+ dir: serverOptions.ssdCacheDir,
1029
+ maxBytes: serverOptions.ssdCacheMaxBytes ?? 32 * 2 ** 30,
1030
+ configFingerprint: `${configFingerprint(ctx.model.config)}-${schemeKey}`,
1031
+ tokenizerHash: Bun.hash(tokJson).toString(16),
1032
+ modelId: ctx.modelId,
1033
+ verify: serverOptions.ssdCacheVerify,
1034
+ });
1035
+ const recovered = ssdStore.scan();
1036
+ console.log(
1037
+ `[ssd-cache] ${serverOptions.ssdCacheDir} — ${recovered} entr${recovered === 1 ? "y" : "ies"} recovered, ` +
1038
+ `${(ssdStore.totalBytes / 2 ** 30).toFixed(2)} GiB of ${(ssdStore.maxBytes / 2 ** 30).toFixed(0)} GiB cap`,
1039
+ );
1040
+ }
1041
+
1042
+ const promptCache = new PromptCache(
1043
+ serverOptions.promptCacheBytes ?? 2e9,
1044
+ // RAM eviction spills to the cold tier (whole-prefix entries, no work
1045
+ // on the token loop — the spill runs at eviction time only).
1046
+ ssdStore ? (entry) => { ssdStore!.store(entry.tokens, entry.caches, entry.ns); } : null,
1047
+ );
980
1048
  // Responses-API store for previous_response_id resumption (Phase 11):
981
1049
  // TTL + byte-capped LRU, port of optiq/response_store.py. Pairs with
982
1050
  // the prompt cache: a resumed conversation re-renders the same prefix,
@@ -1001,7 +1069,31 @@ export function createServer(
1001
1069
  // Cache entries are adapter-specific: KV computed under one adapter
1002
1070
  // must never seed another's (or the base's) prefill.
1003
1071
  const cacheNs = options.adapters?.join("+") ?? "";
1004
- const entry = vision ? null : promptCache.take(promptIds, cacheNs);
1072
+ let entry = vision ? null : promptCache.take(promptIds, cacheNs);
1073
+ // Cold tier: restore from SSD when it has a STRICTLY longer usable
1074
+ // prefix than RAM offered (zero-copy COW mmap; pages fault in lazily
1075
+ // during the continuation prefill). The restored entry carries an
1076
+ // unmap thunk that PromptCache runs after its caches are disposed.
1077
+ if (!vision && ssdStore) {
1078
+ const ssdHit = ssdStore.find(promptIds, cacheNs);
1079
+ if (ssdHit && ssdHit.prefixLen > (entry?.tokens.length ?? 0)) {
1080
+ const loaded = ssdStore.restore(ssdHit.entry, ctx.model);
1081
+ if (loaded) {
1082
+ const trimNeeded = loaded.tokens.length - ssdHit.prefixLen;
1083
+ if (trimNeeded > 0 && !loaded.caches.every((c) => c.isTrimmable())) {
1084
+ // Divergent tail on an untrimmable kind (ring post-wrap, SSM):
1085
+ // this file can't seed the prompt — fall back to the RAM hit.
1086
+ for (const c of loaded.caches) c.dispose();
1087
+ loaded.mmap.unmap();
1088
+ } else {
1089
+ if (trimNeeded > 0) for (const c of loaded.caches) c.trim(trimNeeded);
1090
+ if (entry) promptCache.put(entry.tokens, entry.caches, cacheNs, entry.retain); // return the RAM hit untouched
1091
+ entry = { tokens: loaded.tokens.slice(0, ssdHit.prefixLen), caches: loaded.caches,
1092
+ ns: cacheNs, retain: () => loaded.mmap.unmap() };
1093
+ }
1094
+ }
1095
+ }
1096
+ }
1005
1097
  const caches = entry?.caches ?? ctx.model.makeCache();
1006
1098
  try {
1007
1099
  const gen = generate(ctx.model, promptIds, {
@@ -1016,11 +1108,13 @@ export function createServer(
1016
1108
  if (vision) {
1017
1109
  for (const c of caches) c.dispose();
1018
1110
  } else {
1019
- promptCache.put(s.cacheTokens, caches, cacheNs);
1111
+ promptCache.put(s.cacheTokens, caches, cacheNs, entry?.retain);
1112
+ scheduleSsdSnapshot(s.cacheTokens, cacheNs);
1020
1113
  }
1021
1114
  return s;
1022
1115
  } catch (e) {
1023
1116
  for (const c of caches) c.dispose();
1117
+ entry?.retain?.();
1024
1118
  throw e;
1025
1119
  } finally {
1026
1120
  vision?.embeddings.dispose();
@@ -1029,6 +1123,29 @@ export function createServer(
1029
1123
  }
1030
1124
  };
1031
1125
 
1126
+ // Write-behind persistence (restart survival — the oMLX boundary-snapshot
1127
+ // idea at whole-entry granularity, spill-on-evict alone can't survive a
1128
+ // clean exit): after a request completes, snapshot its still-RAM-resident
1129
+ // entry to SSD off the request path. Debounced + coalesced per namespace —
1130
+ // an agent hammering one conversation persists the settled state once
1131
+ // things go quiet, not every turn. Runs under the gateway lock (byte
1132
+ // extraction must not race a generation mutating the entry); if the entry
1133
+ // was evicted (already spilled) or extended (a newer schedule pending)
1134
+ // meanwhile, findExact misses and nothing is written.
1135
+ const ssdPending = new Map<string, ReturnType<typeof setTimeout>>();
1136
+ const scheduleSsdSnapshot = (tokens: number[], ns: string): void => {
1137
+ if (!ssdStore || tokens.length === 0) return;
1138
+ const prev = ssdPending.get(ns);
1139
+ if (prev) clearTimeout(prev);
1140
+ ssdPending.set(ns, setTimeout(() => {
1141
+ ssdPending.delete(ns);
1142
+ void gateway.runExclusive(async () => {
1143
+ const e = promptCache.findExact(tokens, ns);
1144
+ if (e) ssdStore!.store(e.tokens, e.caches, ns);
1145
+ }).catch(() => { /* cold tier is best-effort */ });
1146
+ }, 1000));
1147
+ };
1148
+
1032
1149
  // The lane picker: routes each request to the serial path (runGeneration,
1033
1150
  // above) or the continuous-batching scheduler, keeping the two off the GPU
1034
1151
  // (and shared loraState) at the same time. See src/serve/generation-gateway.ts.
@@ -1075,28 +1192,8 @@ export function createServer(
1075
1192
  return jobStore;
1076
1193
  };
1077
1194
 
1078
- // KV-quant scheme, resolved once: kv_config.json by default (optiq
1079
- // serve's headline behavior), overridable to uniform bits or off.
1080
- // KV quant scheme. The serial default is the model's mixed-precision config
1081
- // (optiq parity). But `--batch N` is a bf16 continuous-batching MODE (= mlx-lm
1082
- // B=N parity), so when KV quant is left UNSET under `--batch N` it defaults to
1083
- // bf16 — the batch path engages out of the box. An EXPLICIT --kv-quant
1084
- // (config / bits) is still honored, but those requests then route to the
1085
- // serial path (batched quantized KV is the L2 follow-up); warned below.
1086
- const configScheme = ctx.kvConfig?.length ? { kvConfig: ctx.kvConfig } : {};
1087
- const kvScheme: Pick<GenerateOptions, "kvBits" | "kvConfig" | "quantizedKvStart"> =
1088
- serverOptions.kvQuant === "off" ? {}
1089
- : serverOptions.kvQuant === "config" ? configScheme
1090
- : typeof serverOptions.kvQuant === "number"
1091
- ? { kvBits: serverOptions.kvQuant, quantizedKvStart: 0 }
1092
- : batch > 1 ? {} // unset + batching → bf16 mode (Option B)
1093
- : configScheme; // unset + serial → optiq-parity mixed-precision default
1094
- if (batch > 1 && (kvScheme.kvConfig?.length || kvScheme.kvBits))
1095
- console.warn(
1096
- `[batch] --batch ${batch} with explicit --kv-quant: batched serving (v1) is ` +
1097
- `bf16-only, so kv-quant requests route to the serial path (no batching for them). ` +
1098
- `Omit --kv-quant to batch in bf16. (docs/design/parallel-slots.md)`,
1099
- );
1195
+ // (kvScheme is resolved earlier, above the prompt-cache construction —
1196
+ // the SSD tier's compatibility fingerprint folds the effective scheme in.)
1100
1197
 
1101
1198
  // XTC never removes EOS or the newline token: mlx_lm.server passes
1102
1199
  // [tokenizer.eos_token_id, tokenizer.encode("\n")] as xtc_special_tokens.
@@ -1442,6 +1539,17 @@ export function createServer(
1442
1539
  hits: promptCache.hits,
1443
1540
  misses: promptCache.misses,
1444
1541
  },
1542
+ ...(ssdStore ? {
1543
+ ssd_cache: {
1544
+ dir: serverOptions.ssdCacheDir,
1545
+ entries: ssdStore.entries,
1546
+ bytes: ssdStore.totalBytes,
1547
+ max_bytes: ssdStore.maxBytes,
1548
+ restores: ssdStore.stats.restores,
1549
+ spills: ssdStore.stats.spills,
1550
+ restore_ms_last: Math.round(ssdStore.stats.restoreMsLast),
1551
+ },
1552
+ } : {}),
1445
1553
  response_store: {
1446
1554
  entries: responseStore.size,
1447
1555
  bytes: responseStore.totalBytes,
@@ -1867,6 +1975,8 @@ export function createServer(
1867
1975
  kvQuant: !!(options.kvConfig?.length || options.kvBits),
1868
1976
  };
1869
1977
  const batched = gateway.willBatch(shape);
1978
+ if (process.env.MLX_BUN_LANE_DEBUG === "1")
1979
+ console.error(`[lane] batched=${batched} shape=${JSON.stringify(shape)} t=${Date.now() % 100000}`);
1870
1980
 
1871
1981
  if (body.stream) {
1872
1982
  const stream = new ReadableStream<Uint8Array>({