mlx-bun 0.0.8 → 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.
Files changed (144) hide show
  1. package/README.md +112 -20
  2. package/docs/design/batching-perf-path.md +232 -0
  3. package/docs/design/batching-v2-plan.md +515 -0
  4. package/docs/design/bucketing-stage.md +196 -0
  5. package/docs/design/compat-cli-surface-design.md +11 -11
  6. package/docs/design/docs-reorg-plan.md +1 -1
  7. package/docs/design/dreaming-nightly-pipeline.md +291 -0
  8. package/docs/design/dspark-speculative-decoding.md +106 -0
  9. package/docs/design/generic-model-support.md +381 -0
  10. package/docs/design/memory-inference-path.md +422 -0
  11. package/docs/design/memory-synthesis.md +287 -0
  12. package/docs/design/minicpm5-decode-megakernel.md +484 -0
  13. package/docs/design/mlx-lm-tool-parity-plan.md +535 -0
  14. package/docs/design/omlx-adoption-map.md +90 -0
  15. package/docs/design/orpo-dynamic-lambda.md +41 -0
  16. package/docs/design/orpo-training.md +35 -5
  17. package/docs/design/parallel-slots.md +12 -1
  18. package/docs/design/parity-tier-dag.md +13 -5
  19. package/docs/design/segmented-backward-training.md +15 -0
  20. package/docs/design/spec-decode-larger-targets.md +229 -0
  21. package/docs/design/ssd-kv-cold-tier.md +158 -0
  22. package/docs/design/tauri-desktop-app.md +2 -2
  23. package/docs/design/the-dreaming-handoff.md +141 -0
  24. package/docs/design/the-dreaming-master-plan.md +446 -0
  25. package/docs/design/write-pipeline-entity.md +468 -0
  26. package/docs/investigations/curve-bisector-routing.md +1 -1
  27. package/docs/investigations/decode-roofline-lookagain.md +348 -0
  28. package/docs/investigations/dspark-handoff.md +58 -0
  29. package/docs/investigations/kernel-perf-review-2026-07.md +171 -0
  30. package/docs/planning/PRODUCT_ROADMAP.md +2 -2
  31. package/docs/planning/curve-sampler-research-plan.md +331 -0
  32. package/docs/planning/memory-docs-and-dag-plan.md +185 -0
  33. package/docs/planning/pi-integration-review.md +178 -0
  34. package/docs/planning/release-notes-v0.0.10.md +57 -0
  35. package/docs/planning/release-notes-v0.0.9.md +122 -0
  36. package/docs/planning/web-ui-pass-plan.md +213 -0
  37. package/docs/planning/website-readme-pass-plan.md +224 -0
  38. package/docs/reference/cli.md +255 -0
  39. package/docs/reference/embedding.md +7 -2
  40. package/docs/reference/library-api.md +22 -3
  41. package/docs/reference/memory.md +5 -5
  42. package/docs/reference/models.md +137 -0
  43. package/docs/reference/orpo-quickstart.md +6 -0
  44. package/docs/reference/server-api.md +187 -9
  45. package/docs/reference/server-config.md +43 -9
  46. package/docs/reference/training.md +31 -12
  47. package/package.json +1 -1
  48. package/src/anthropic.ts +22 -0
  49. package/src/assets/curve-designer.html +1 -1
  50. package/src/chat-template.ts +25 -7
  51. package/src/cli.ts +1077 -90
  52. package/src/config.ts +4 -1
  53. package/src/download.ts +99 -8
  54. package/src/embed.ts +77 -0
  55. package/src/eval/perplexity.ts +164 -0
  56. package/src/eval/runner.ts +15 -6
  57. package/src/eval/tasks/bfcl.ts +1 -1
  58. package/src/generate.ts +149 -18
  59. package/src/harness-pi.ts +43 -17
  60. package/src/index.ts +2 -0
  61. package/src/kv-store.ts +240 -98
  62. package/src/lora.ts +2 -2
  63. package/src/memory/article.ts +558 -0
  64. package/src/memory/chunk-validation.ts +150 -0
  65. package/src/memory/chunk.ts +305 -12
  66. package/src/memory/cluster.ts +344 -17
  67. package/src/memory/coverage.ts +134 -0
  68. package/src/memory/crosslink.ts +0 -0
  69. package/src/memory/db.ts +184 -45
  70. package/src/memory/entity.ts +203 -0
  71. package/src/memory/gate.ts +187 -0
  72. package/src/memory/history.ts +152 -0
  73. package/src/memory/ingest.ts +324 -11
  74. package/src/memory/model.ts +321 -0
  75. package/src/memory/normalize.ts +671 -0
  76. package/src/memory/parse.ts +22 -0
  77. package/src/memory/pipeline.ts +242 -30
  78. package/src/memory/prompts.ts +42 -0
  79. package/src/memory/query.ts +334 -0
  80. package/src/memory/reconcile.ts +734 -0
  81. package/src/memory/redirect.ts +108 -0
  82. package/src/memory/reindex.ts +325 -0
  83. package/src/memory/resolve.ts +307 -0
  84. package/src/memory/route.ts +272 -0
  85. package/src/memory/schedule.ts +6 -4
  86. package/src/memory/stages.ts +774 -0
  87. package/src/memory/synthesize.ts +1629 -20
  88. package/src/memory/tools.ts +446 -96
  89. package/src/memory/vault.ts +462 -35
  90. package/src/memory/wikify.ts +1057 -16
  91. package/src/mlx/array.ts +21 -0
  92. package/src/mlx/ffi.ts +3 -0
  93. package/src/mlx/ops.ts +72 -0
  94. package/src/model/compiled-decode.ts +43 -8
  95. package/src/model/factory.ts +29 -8
  96. package/src/model/flash-attention.ts +4 -2
  97. package/src/model/fused-decode-kernel.ts +14 -7
  98. package/src/model/fused-mlp-kernel.ts +169 -0
  99. package/src/model/fused-swiglu-kernel.ts +240 -0
  100. package/src/model/gemma4-base.ts +96 -7
  101. package/src/model/gemma4.ts +33 -2
  102. package/src/model/minicpm5.ts +106 -8
  103. package/src/model/qwen3-delta.ts +48 -1
  104. package/src/model/qwen3.ts +276 -0
  105. package/src/model/qwen3_5.ts +11 -2
  106. package/src/model/steel-linear-kernel.ts +96 -0
  107. package/src/model/support.ts +38 -8
  108. package/src/model/universal/archs.ts +332 -0
  109. package/src/model/universal/dense.ts +576 -0
  110. package/src/model/universal/modules.ts +163 -0
  111. package/src/model/universal/rope.ts +366 -0
  112. package/src/pi-provider.ts +1 -1
  113. package/src/pi-terminal.ts +29 -8
  114. package/src/pi-web.ts +19 -2
  115. package/src/prompt-cache.ts +55 -9
  116. package/src/quantize/config-writer.ts +1 -1
  117. package/src/registry.ts +219 -8
  118. package/src/responses.ts +23 -0
  119. package/src/sampler.ts +220 -9
  120. package/src/serve/batch-scheduler.ts +307 -83
  121. package/src/serve/generation-gateway.ts +156 -29
  122. package/src/server.ts +746 -73
  123. package/src/spec/drafter.ts +81 -49
  124. package/src/spec/dspark/data-dflash.ts +156 -0
  125. package/src/spec/dspark/data.ts +201 -0
  126. package/src/spec/dspark/generate-dflash.ts +193 -0
  127. package/src/spec/dspark/generate.ts +290 -0
  128. package/src/spec/dspark/loss.ts +213 -0
  129. package/src/spec/dspark/module-dflash.ts +361 -0
  130. package/src/spec/dspark/module.ts +540 -0
  131. package/src/spec/dspark/sample.ts +112 -0
  132. package/src/spec/generate.ts +40 -19
  133. package/src/ssd-cache.ts +213 -0
  134. package/src/status-page.html +1 -1
  135. package/src/tokenizer.ts +7 -0
  136. package/src/train/flash-cce.ts +81 -23
  137. package/src/train/fuse.ts +243 -0
  138. package/src/train/index.ts +3 -0
  139. package/src/train/job.ts +8 -0
  140. package/src/train/loss.ts +341 -40
  141. package/src/train/prefix-shared.ts +62 -11
  142. package/src/train/segmented.ts +215 -55
  143. package/src/train/trainer.ts +112 -38
  144. package/src/web/skills/memory/SKILL.md +40 -17
@@ -2,7 +2,7 @@
2
2
  // loop). Owns ONE running batch and drives it forward one decode step at a
3
3
  // time, admitting waiting requests and evicting finished ones between steps —
4
4
  // iteration-level (continuous) scheduling, not static batching. See
5
- // docs/design/parallel-slots.md.
5
+ // docs/design/parallel-slots.md and docs/design/batching-v2-plan.md.
6
6
  //
7
7
  // The numerically-hard parts are verified primitives:
8
8
  // - the batched FORWARD (per-row RoPE/mask) is bit-parity with mlx-lm B=N
@@ -21,12 +21,38 @@
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.
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.
30
+ //
31
+ // Engine mechanics (the serial decode loop's hygiene, transplanted —
32
+ // batching-v2-plan step 3):
33
+ // - PIPELINED decode: each step builds the NEXT step's graph from the
34
+ // still-unread sampled-token array (asyncEval), then reads the PREVIOUS
35
+ // step's tokens while the new step computes — mlx-lm GenerationBatch._step.
36
+ // The pipeline is flushed (read + emit) before a join merges, so admission
37
+ // never has to reconcile an in-flight token array with a new row.
38
+ // - clearCache every 256 steps (serial's cadence; mlx-lm batched uses 512),
39
+ // not every step — per-step clears trashed the buffer pool each token.
40
+ // - CHUNKED, INTERLEAVED admission: a joiner prefills prefillChunkSize
41
+ // tokens per loop iteration with one batch decode step run in between, so
42
+ // running rows stall at most one chunk per joiner (mlx-lm interleaves the
43
+ // same way), and the prefill transient stays bounded.
44
+ // - Failure containment: one row's onToken throwing evicts THAT row (its
45
+ // promise rejects); siblings keep decoding (mlx-lm `remove` semantics). A
46
+ // forward/sampling error still drops the whole batch (can't be attributed
47
+ // to a row).
25
48
  //
26
49
  // Bun-async, NO threads: a single detached driver loop owns the GPU for batched
27
50
  // mode (an ExclusiveLock keeps the serial fallback off the GPU concurrently).
51
+ // When `admissionHeld` reports a waiting serial-lane request, the loop stops
52
+ // admitting, finishes the running rows, and releases the lock so the serial
53
+ // request runs (mlx-lm's drain_batch) — resumed via kick().
28
54
  // Joins re-merge the whole batch; the keep-the-running-batch `extend`
29
- // optimization is a later refinement.
55
+ // optimization is a later refinement (batching-v2-plan item a).
30
56
 
31
57
  import { MlxArray } from "../mlx/array";
32
58
  import * as ops from "../mlx/ops";
@@ -34,12 +60,28 @@ import { clearCache } from "../mlx/ffi";
34
60
  import { KVCache, RotatingKVCache, type Cache } from "../model/gemma4-base";
35
61
  import { BatchedDecodeMaskCache, mergeKVRows, filterKVRows } from "../model/batched-mask";
36
62
  import { BatchedRotatingCache } from "../model/batched-rotating";
63
+ import { SSMCache } from "../model/qwen3-delta";
37
64
  import type { RuntimeModel } from "../model/factory";
38
65
 
66
+ /** Decode-pipeline kill switch (read once at load, like the serial loop's
67
+ * MLX_BUN_COMPILED_DECODE): 1 ⇒ read each step's tokens synchronously. */
68
+ const NO_PIPELINE = process.env.MLX_BUN_BATCH_NO_PIPELINE === "1";
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
+
39
80
  /** A token sampler for one row: (logits [1,V], step) → token array [1] on
40
81
  * device. Greedy is `(l) => ops.argmaxAxis(l, -1)`; richer closures fold in
41
82
  * temperature / top-p / logits processors + history (built per request from
42
- * its sampler options, exactly like generate()'s sampleStep). */
83
+ * its sampler options, exactly like generate()'s sampleStep). Called once per
84
+ * step 0..maxTokens-1 for this row (never beyond maxTokens). */
43
85
  export type RowSampler = (logits1V: MlxArray, step: number) => MlxArray;
44
86
 
45
87
  export interface BatchRequest {
@@ -49,8 +91,9 @@ export interface BatchRequest {
49
91
  sample: RowSampler;
50
92
  /** Called per emitted (non-EOS) token, in order. Returning `false` halts this
51
93
  * row (a decoded-text stop sequence fired) — matches generate()'s onToken
52
- * contract. EOS terminates the row WITHOUT an onToken call. May be async;
53
- * keep it cheap — it runs inline in the step loop. */
94
+ * contract. EOS terminates the row WITHOUT an onToken call. Throwing evicts
95
+ * THIS row only (its submit promise rejects; siblings continue). May be
96
+ * async; keep it cheap — it runs inline in the step loop. */
54
97
  onToken: (token: number) => void | boolean | Promise<void | boolean>;
55
98
  }
56
99
 
@@ -67,11 +110,20 @@ interface Row {
67
110
  req: BatchRequest;
68
111
  resolve: (s: BatchStats) => void;
69
112
  reject: (e: unknown) => void;
70
- current: number; // last emitted token, fed at the next step
113
+ current: number; // last emitted token, fed at the next step (pipeline cold)
71
114
  generated: number; // tokens emitted so far (incl. a terminating EOS)
115
+ sampled: number; // sample() calls so far (leads `generated` by 1 in-pipeline)
72
116
  promptTokens: number;
73
117
  }
74
118
 
119
+ /** A joiner mid-prefill: `pos` prompt tokens are already in `solo`. Advanced
120
+ * one chunk per loop iteration, interleaved with batch decode steps. */
121
+ interface PrefillState {
122
+ row: Row;
123
+ solo: Cache[];
124
+ pos: number;
125
+ }
126
+
75
127
  /** Held while the batch is active; the serial fallback acquires the same lock. */
76
128
  export interface ExclusiveLock {
77
129
  acquire(): Promise<() => void>;
@@ -81,9 +133,15 @@ export interface BatchSchedulerOptions {
81
133
  /** Max rows in the running batch (mlx-lm `--decode-concurrency`). */
82
134
  maxBatch: number;
83
135
  lock?: ExclusiveLock;
136
+ /** Drain signal: while true, no NEW rows are admitted (running rows finish,
137
+ * the lock is released) so a waiting serial-lane request can run — mlx-lm's
138
+ * drain_batch. Pair with kick() when it flips back to false. */
139
+ admissionHeld?: () => boolean;
140
+ /** Joiner prefill chunk length (default 2048, the serial loop's constant). */
141
+ prefillChunkSize?: number;
84
142
  }
85
143
 
86
- type LayerInner = KVCache | BatchedRotatingCache;
144
+ type LayerInner = KVCache | BatchedRotatingCache | SSMCache;
87
145
  type Row1 = { keys: MlxArray; values: MlxArray };
88
146
 
89
147
  export class BatchScheduler {
@@ -91,19 +149,31 @@ export class BatchScheduler {
91
149
  #inners: LayerInner[] | null = null; // per-layer batched KV; null when empty
92
150
  #fullLeftPad: number[] = []; // per-row padding for FULL layers (rot self-tracks)
93
151
  #pending: Row[] = [];
152
+ #prefill: PrefillState | null = null; // the (single) joiner mid-prefill
153
+ /** Sampled-but-unread token array [B], aligned with #running — the decode
154
+ * pipeline register. Filtered/disposed alongside the batched KV. */
155
+ #pendingToks: MlxArray | null = null;
156
+ #steps = 0; // decode-step counter (clearCache cadence)
94
157
  #looping = false;
95
158
  #wake: (() => void) | null = null;
96
159
  readonly #maxBatch: number;
97
160
  readonly #lock: ExclusiveLock | undefined;
98
- readonly #kinds: ("full" | "rot")[]; // per-layer attention type
161
+ readonly #admissionHeld: (() => boolean) | undefined;
162
+ readonly #prefillChunkSize: number;
163
+ readonly #kinds: ("full" | "rot" | "ssm")[]; // per-layer attention type
99
164
  readonly #rotMaxSize: number[]; // per-layer sliding window (rot layers only)
100
165
 
101
166
  constructor(private readonly model: RuntimeModel, opts: BatchSchedulerOptions) {
102
167
  this.#maxBatch = Math.max(1, Math.floor(opts.maxBatch));
103
168
  this.#lock = opts.lock;
169
+ this.#admissionHeld = opts.admissionHeld;
170
+ this.#prefillChunkSize = Math.max(1, Math.floor(opts.prefillChunkSize ?? 2048));
104
171
  const proto = model.makeCache(); // fresh caches hold no buffers
105
- 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
+ );
106
175
  this.#rotMaxSize = proto.map((c) => (c instanceof RotatingKVCache ? c.maxSize : 0));
176
+ for (const c of proto) c.dispose();
107
177
  }
108
178
 
109
179
  get activeRows(): number {
@@ -115,12 +185,17 @@ export class BatchScheduler {
115
185
  return new Promise<BatchStats>((resolve, reject) => {
116
186
  this.#pending.push({
117
187
  req, resolve, reject,
118
- current: 0, generated: 0, promptTokens: req.promptIds.length,
188
+ current: 0, generated: 0, sampled: 0, promptTokens: req.promptIds.length,
119
189
  });
120
190
  this.#ensureLoop();
121
191
  });
122
192
  }
123
193
 
194
+ /** Wake the driver loop (e.g. after admissionHeld flips back to false). */
195
+ kick(): void {
196
+ this.#ensureLoop();
197
+ }
198
+
124
199
  #ensureLoop(): void {
125
200
  if (this.#wake) { this.#wake(); return; }
126
201
  if (this.#looping) return;
@@ -132,29 +207,58 @@ export class BatchScheduler {
132
207
  let release: (() => void) | null = null;
133
208
  try {
134
209
  while (true) {
135
- if (!release && this.#lock && (this.#pending.length > 0 || this.#running.length > 0))
136
- release = await this.#lock.acquire();
210
+ const held = this.#admissionHeld?.() === true;
211
+ // Work = running rows, an in-flight prefill (finish it even under
212
+ // drain — the row is already half-admitted), or admissible pending.
213
+ const hasWork =
214
+ this.#running.length > 0 ||
215
+ this.#prefill !== null ||
216
+ (!held && this.#pending.length > 0);
217
+ if (!hasWork) {
218
+ if (release) { release(); release = null; } // let the serial lane in
219
+ await new Promise<void>((r) => { this.#wake = r; });
220
+ this.#wake = null;
221
+ continue;
222
+ }
223
+ if (!release && this.#lock) release = await this.#lock.acquire();
137
224
 
138
- while (this.#pending.length > 0 && this.#running.length < this.#maxBatch) {
139
- const row = this.#pending.shift()!;
225
+ // Start at most one joiner prefill (drain-aware); it advances one
226
+ // chunk per iteration, interleaved with the decode step below.
227
+ if (!this.#prefill && !held &&
228
+ this.#pending.length > 0 && this.#running.length < this.#maxBatch)
229
+ this.#prefill = { row: this.#pending.shift()!, solo: this.model.makeCache(), pos: 0 };
230
+
231
+ if (this.#prefill) {
232
+ const p = this.#prefill;
140
233
  try {
141
- await this.#admit(row);
234
+ if (await this.#prefillChunk(p)) this.#prefill = null;
142
235
  } catch (e) {
143
- row.reject(e);
236
+ // Prefill/admission failure is per-row: reject the joiner, keep
237
+ // the running batch.
238
+ this.#prefill = null;
239
+ for (const c of p.solo) c.dispose();
240
+ p.row.reject(e);
144
241
  }
145
242
  }
146
- if (this.#running.length === 0) {
147
- if (this.#pending.length > 0) continue;
148
- if (release) { release(); release = null; }
149
- await new Promise<void>((r) => { this.#wake = r; });
150
- this.#wake = null;
243
+
244
+ // Burst admission: when a joiner just COMPLETED and more batchable
245
+ // requests are queued, admit them before the next decode step (the
246
+ // admit-all-then-step grouping the mlx-lm goldens gate). Long prompts
247
+ // still interleave: a mid-prefill joiner leaves #prefill non-null, so
248
+ // the loop falls through to run a decode step between chunks.
249
+ if (this.#prefill === null && !held &&
250
+ this.#pending.length > 0 && this.#running.length < this.#maxBatch)
151
251
  continue;
152
- }
153
- try {
154
- await this.#step();
155
- } catch (e) {
156
- for (const row of this.#running) row.reject(e);
157
- this.#applyFilter([]);
252
+
253
+ if (this.#running.length > 0) {
254
+ try {
255
+ await this.#step();
256
+ } catch (e) {
257
+ // A forward/sampling error can't be attributed to one row — drop
258
+ // the batch. (A row's onToken error is contained in #emitRows.)
259
+ for (const row of this.#running) row.reject(e);
260
+ this.#applyFilter([]);
261
+ }
158
262
  }
159
263
  await new Promise<void>((r) => setImmediate(r));
160
264
  }
@@ -164,40 +268,79 @@ export class BatchScheduler {
164
268
  }
165
269
  }
166
270
 
167
- /** Solo-prefill a joining request, emit its first token, and (if it survives)
168
- * re-merge it with the running batch (per-layer, by attention type). */
169
- async #admit(row: Row): Promise<void> {
170
- const solo = this.model.makeCache();
171
- const ids = ops.fromInt32(row.req.promptIds, [1, row.req.promptIds.length]);
172
- const h = this.model.forwardHidden(ids, solo);
271
+ /** Advance a joiner's solo prefill by one chunk. Non-final chunks forward +
272
+ * eval the cache and return false (the caller interleaves a decode step).
273
+ * The final chunk samples token 0, emits it, and — if the row survives —
274
+ * merges it into the running batch; returns true (admission complete). */
275
+ async #prefillChunk(p: PrefillState): Promise<boolean> {
276
+ const prompt = p.row.req.promptIds;
277
+ if (prompt.length - p.pos > this.#prefillChunkSize) {
278
+ const chunk = prompt.slice(p.pos, p.pos + this.#prefillChunkSize);
279
+ const ids = ops.fromInt32(chunk, [1, chunk.length]);
280
+ const h = this.model.forwardHidden(ids, p.solo);
281
+ ids.dispose();
282
+ h.dispose(); // logits never computed for non-final chunks
283
+ ops.evalAll(p.solo.flatMap((c) => c.state()));
284
+ clearCache(); // serial prefill's per-chunk clear (generate.ts)
285
+ p.pos += this.#prefillChunkSize;
286
+ return false;
287
+ }
288
+
289
+ // Final chunk: slice the LAST hidden position BEFORE the lm_head — running
290
+ // logitsFromHidden on the whole [1,Lp,H] hidden materializes a [1,Lp,V]
291
+ // transient (~4.3 GB bf16 at Gemma V=262k, 8k prompt). Same reorder as the
292
+ // serial path (generate.ts prefill).
293
+ const chunk = prompt.slice(p.pos);
294
+ const ids = ops.fromInt32(chunk, [1, chunk.length]);
295
+ const h = this.model.forwardHidden(ids, p.solo);
173
296
  ids.dispose();
174
- const lg = this.model.logitsFromHidden(h);
297
+ const [, Lc, H] = h.shape as [number, number, number];
298
+ const hLast = h.slice([0, Lc - 1, 0], [1, Lc, H]);
175
299
  h.dispose();
176
- const [, Lp, V] = lg.shape as [number, number, number];
177
- const last = lg.slice([0, Lp - 1, 0], [1, Lp, V]);
300
+ const lg = this.model.logitsFromHidden(hLast); // [1,1,V]
301
+ hLast.dispose();
302
+ const V = lg.shape[2]!;
303
+ const last2 = ops.reshape(lg, [1, V]);
178
304
  lg.dispose();
179
- const last2 = ops.reshape(last, [1, V]);
180
- last.dispose();
181
- const tok = this.#readToken(row.req.sample(last2, 0));
305
+ const tok = this.#readToken(p.row.req.sample(last2, 0));
182
306
  last2.dispose();
183
- row.generated = 1;
184
- clearCache();
307
+ p.row.sampled = 1;
308
+ p.row.generated = 1;
309
+ clearCache(); // drop the prefill transients (serial's token-0 clear)
185
310
 
186
- const stop = await this.#emit(row, tok);
311
+ const stop = await this.#emit(p.row, tok);
187
312
  if (stop !== "continue") {
188
- for (const c of solo) c.dispose();
189
- this.#finish(row, stop);
190
- return;
313
+ for (const c of p.solo) c.dispose();
314
+ this.#finish(p.row, stop);
315
+ return true;
191
316
  }
317
+ await this.#mergeJoiner(p);
318
+ return true;
319
+ }
320
+
321
+ /** Merge a fully-prefilled joiner with the running batch, layer by layer
322
+ * (re-merge; `extend` is the later refinement). Flushes the decode pipeline
323
+ * first so the row set is settled and the next step starts cold. */
324
+ async #mergeJoiner(p: PrefillState): Promise<void> {
325
+ await this.#flushPipeline();
192
326
 
193
- // Re-merge the running batch + the new row, layer by layer.
194
327
  const prev = this.#inners;
195
328
  const prevPad = this.#fullLeftPad;
196
329
  const B = this.#running.length;
197
330
  const newInners: LayerInner[] = [];
198
331
  let newFullPad = this.#fullLeftPad;
199
332
  for (let layer = 0; layer < this.#kinds.length; layer++) {
200
- const soloC = solo[layer] as KVCache | RotatingKVCache;
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
+ }
343
+ const soloC = p.solo[layer] as KVCache | RotatingKVCache;
201
344
  const [sk, sv] = soloC.temporalView();
202
345
  const newRow: Row1 = { keys: sk, values: sv };
203
346
  if (this.#kinds[layer] === "rot") {
@@ -248,55 +391,123 @@ export class BatchScheduler {
248
391
  }
249
392
  }
250
393
  if (prev) for (const c of prev) c.dispose();
251
- for (const c of solo) c.dispose();
394
+ for (const c of p.solo) c.dispose();
252
395
  this.#inners = newInners;
253
396
  this.#fullLeftPad = newFullPad;
254
- this.#running.push(row);
397
+ this.#running.push(p.row);
255
398
  }
256
399
 
257
- /** One batched decode step: forward all rows, sample per row, emit, evict. */
400
+ /** One PIPELINED batched decode step (mlx-lm GenerationBatch._step):
401
+ * 1. forward all rows from the UNREAD pending token array (or, pipeline
402
+ * cold, from the rows' last emitted tokens), sample each live row's next
403
+ * token on its [1,V] slice, asyncEval the new [B] token array;
404
+ * 2. THEN sync-read the previous step's tokens (overlapping the readback
405
+ * with this step's compute), emit them, and evict finished rows.
406
+ * Rows that finish get one extra harmless KV write from the already-built
407
+ * step; filter drops the row (mlx-lm behaves identically). Length-finished
408
+ * rows are known in advance and are NOT sampled (placeholder slot). */
258
409
  async #step(): Promise<void> {
259
410
  const rows = this.#running;
260
411
  const B = rows.length;
261
412
  const inners = this.#inners!;
262
- // Per-layer forward cache: rot layers use the persistent BatchedRotatingCache
263
- // directly; full layers get a fresh BatchedDecodeMaskCache wrapper.
264
- const fwd: Cache[] = inners.map((c) =>
265
- c instanceof BatchedRotatingCache ? c : new BatchedDecodeMaskCache(c, B, this.#fullLeftPad, null),
266
- );
267
- let toks: number[];
268
- try {
269
- const ids = ops.fromInt32(rows.map((r) => r.current), [B, 1]);
270
- const h = this.model.forwardHidden(ids, fwd);
271
- ids.dispose();
272
- const lg = this.model.logitsFromHidden(h); // [B,1,V]
273
- h.dispose();
274
- const V = lg.shape[2]!;
275
- const sampled: MlxArray[] = [];
276
- for (let b = 0; b < B; b++) {
277
- const rl = lg.slice([b, 0, 0], [b + 1, 1, V]);
278
- const rl2 = ops.reshape(rl, [1, V]);
279
- rl.dispose();
280
- sampled.push(rows[b]!.req.sample(rl2, rows[b]!.generated));
281
- rl2.dispose();
413
+
414
+ // A row is live if it still needs tokens sampled; a row whose pending
415
+ // unread token is its last (sampled == maxTokens) only awaits emission.
416
+ const anyLive = rows.some((r) => r.sampled < r.req.maxTokens);
417
+ let nextToks: MlxArray | null = null;
418
+ if (anyLive) {
419
+ // Per-layer forward cache: rot layers use the persistent
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.
423
+ const fwd: Cache[] = inners.map((c) =>
424
+ c instanceof BatchedRotatingCache || c instanceof SSMCache
425
+ ? c
426
+ : new BatchedDecodeMaskCache(c, B, this.#fullLeftPad, null),
427
+ );
428
+ try {
429
+ const ids = this.#pendingToks
430
+ ? ops.reshape(this.#pendingToks, [B, 1]) // feed the unread tokens
431
+ : ops.fromInt32(rows.map((r) => r.current), [B, 1]); // pipeline cold
432
+ const h = this.model.forwardHidden(ids, fwd);
433
+ ids.dispose();
434
+ const lg = this.model.logitsFromHidden(h); // [B,1,V]
435
+ h.dispose();
436
+ const V = lg.shape[2]!;
437
+ const sampled: MlxArray[] = [];
438
+ for (let b = 0; b < B; b++) {
439
+ const row = rows[b]!;
440
+ if (row.sampled >= row.req.maxTokens) {
441
+ // Length-doomed row: evicted right after the emission below ever
442
+ // uses this slot as input — placeholder keeps the [B] alignment.
443
+ sampled.push(ops.fromInt32([0], [1]));
444
+ continue;
445
+ }
446
+ const rl = lg.slice([b, 0, 0], [b + 1, 1, V]);
447
+ const rl2 = ops.reshape(rl, [1, V]);
448
+ rl.dispose();
449
+ sampled.push(row.req.sample(rl2, row.sampled));
450
+ row.sampled++;
451
+ rl2.dispose();
452
+ }
453
+ lg.dispose();
454
+ nextToks = ops.concatAxis(sampled, 0); // [B]
455
+ for (const t of sampled) t.dispose();
456
+ ops.asyncEvalAll([nextToks]); // dispatch; read NEXT iteration
457
+ } finally {
458
+ // Free the step's RoPE arrays; do NOT dispose (full wrappers would free
459
+ // their persistent inner; rot caches persist across steps).
460
+ for (const c of fwd) (c as { releaseRopeArr?: () => void }).releaseRopeArr?.();
282
461
  }
283
- lg.dispose();
284
- const tokArr = ops.concatAxis(sampled, 0); // [B]
285
- for (const t of sampled) t.dispose();
286
- toks = [...tokArr.toFloat32()].map((x) => Math.round(x));
287
- tokArr.dispose();
288
- } finally {
289
- // Free the step's RoPE arrays; do NOT dispose (full wrappers would free
290
- // their persistent inner; rot caches persist across steps).
291
- for (const c of fwd) (c as { releaseRopeArr?: () => void }).releaseRopeArr?.();
292
462
  }
293
- clearCache();
463
+ this.#steps++;
464
+ if (this.#steps % 256 === 0) clearCache(); // serial's cadence, not per-step
294
465
 
466
+ // Read + emit the PREVIOUS step's tokens while the new step computes.
467
+ const prev = this.#pendingToks;
468
+ this.#pendingToks = nextToks;
469
+ // Kill switch / A-B lever (house style, cf. MLX_BUN_COMPILED_DECODE=0):
470
+ // MLX_BUN_BATCH_NO_PIPELINE=1 reads THIS step's tokens synchronously —
471
+ // set from process start `prev` is always null, so the flush below IS the
472
+ // whole phase 2. Same math either way (pipelining is scheduling).
473
+ if (NO_PIPELINE) {
474
+ await this.#flushPipeline();
475
+ return;
476
+ }
477
+ if (prev) {
478
+ const toks = [...prev.toFloat32()].map((x) => Math.round(x));
479
+ prev.dispose();
480
+ await this.#emitRows(toks); // also filters #pendingToks on eviction
481
+ }
482
+ }
483
+
484
+ /** Read out the pipeline register (if any): emit its tokens and evict
485
+ * finished rows, leaving the pipeline cold. Called before a join merges. */
486
+ async #flushPipeline(): Promise<void> {
487
+ const prev = this.#pendingToks;
488
+ if (!prev) return;
489
+ this.#pendingToks = null;
490
+ const toks = [...prev.toFloat32()].map((x) => Math.round(x));
491
+ prev.dispose();
492
+ await this.#emitRows(toks);
493
+ }
494
+
495
+ /** Emit one read-back token per running row; evict finished rows. A row's
496
+ * onToken throwing rejects THAT row and evicts it — siblings continue. */
497
+ async #emitRows(toks: number[]): Promise<void> {
498
+ const rows = this.#running;
499
+ const B = rows.length;
295
500
  const keep: number[] = [];
296
501
  for (let b = 0; b < B; b++) {
297
502
  const row = rows[b]!;
298
503
  row.generated++;
299
- const disp = await this.#emit(row, toks[b]!);
504
+ let disp: "continue" | "stop" | "length";
505
+ try {
506
+ disp = await this.#emit(row, toks[b]!);
507
+ } catch (e) {
508
+ row.reject(e); // containment: this row only (mlx-lm `remove`)
509
+ continue; // not kept → evicted by the filter below
510
+ }
300
511
  if (disp === "continue") keep.push(b);
301
512
  else this.#finish(row, disp);
302
513
  }
@@ -324,7 +535,8 @@ export class BatchScheduler {
324
535
  });
325
536
  }
326
537
 
327
- /** Evict rows not in `keep` (sorted ascending) from the batched KV. */
538
+ /** Evict rows not in `keep` (sorted ascending) from the batched KV and the
539
+ * pipeline register. */
328
540
  #applyFilter(keep: number[]): void {
329
541
  const inners = this.#inners!;
330
542
  if (keep.length === 0) {
@@ -332,6 +544,8 @@ export class BatchScheduler {
332
544
  this.#inners = null;
333
545
  this.#fullLeftPad = [];
334
546
  this.#running = [];
547
+ this.#pendingToks?.dispose();
548
+ this.#pendingToks = null;
335
549
  return;
336
550
  }
337
551
  const out: LayerInner[] = [];
@@ -339,6 +553,9 @@ export class BatchScheduler {
339
553
  if (inner instanceof BatchedRotatingCache) {
340
554
  inner.filter(keep); // in-place (mutates + drops rows + reduces padding)
341
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);
342
559
  } else {
343
560
  const [k0, v0] = inner.temporalView();
344
561
  const f = filterKVRows(k0, v0, keep);
@@ -352,6 +569,13 @@ export class BatchScheduler {
352
569
  this.#inners = out;
353
570
  this.#fullLeftPad = keep.map((i) => this.#fullLeftPad[i]!);
354
571
  this.#running = keep.map((i) => this.#running[i]!);
572
+ if (this.#pendingToks) {
573
+ const idx = ops.fromInt32(keep, [keep.length]);
574
+ const next = ops.takeAxis(this.#pendingToks, idx, 0);
575
+ idx.dispose();
576
+ this.#pendingToks.dispose();
577
+ this.#pendingToks = next;
578
+ }
355
579
  }
356
580
 
357
581
  #readToken(t: MlxArray): number {