mlx-bun 0.0.8 → 0.0.9

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