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.
- package/docs/design/batching-perf-path.md +232 -0
- package/docs/design/omlx-adoption-map.md +90 -0
- package/docs/design/segmented-backward-training.md +15 -0
- package/docs/design/ssd-kv-cold-tier.md +158 -0
- package/docs/investigations/decode-roofline-lookagain.md +92 -6
- package/docs/investigations/kernel-perf-review-2026-07.md +105 -26
- package/docs/planning/release-notes-v0.0.10.md +57 -0
- package/docs/reference/server-config.md +3 -0
- package/docs/reference/training.md +6 -1
- package/package.json +1 -1
- package/src/cli.ts +49 -5
- package/src/generate.ts +8 -0
- package/src/kv-store.ts +240 -98
- package/src/mlx/array.ts +21 -0
- package/src/mlx/ops.ts +26 -0
- package/src/model/gemma4-base.ts +34 -1
- package/src/model/qwen3-delta.ts +48 -1
- package/src/model/qwen3_5.ts +11 -2
- package/src/prompt-cache.ts +55 -9
- package/src/registry.ts +4 -1
- package/src/serve/batch-scheduler.ts +41 -9
- package/src/serve/generation-gateway.ts +78 -19
- package/src/server.ts +135 -25
- package/src/ssd-cache.ts +213 -0
- package/src/train/flash-cce.ts +27 -24
- package/src/train/loss.ts +53 -11
- package/src/train/prefix-shared.ts +30 -7
- package/src/train/segmented.ts +150 -46
- package/src/train/trainer.ts +21 -11
package/src/prompt-cache.ts
CHANGED
|
@@ -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
|
-
/**
|
|
92
|
-
*
|
|
93
|
-
|
|
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
|
-
|
|
142
|
+
this.#disposeEntry(entry, true);
|
|
97
143
|
return;
|
|
98
144
|
}
|
|
99
|
-
this.#entries.push({ entry
|
|
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
|
-
|
|
151
|
+
this.#disposeEntry(evicted!.entry, true);
|
|
106
152
|
}
|
|
107
153
|
}
|
|
108
154
|
|
|
109
155
|
clear(): void {
|
|
110
|
-
for (const e of this.#entries)
|
|
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
|
-
|
|
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.
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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) =>
|
|
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;
|
|
395
|
-
//
|
|
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
|
|
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
|
-
*
|
|
130
|
-
*
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
194
|
-
// repetition
|
|
195
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1079
|
-
//
|
|
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>({
|