mlx-bun 0.0.9 → 0.0.11

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 (179) hide show
  1. package/README.md +66 -21
  2. package/THIRD_PARTY_LICENSES.md +1 -0
  3. package/docs/archive/mlx-bun-lab-report.html +275 -0
  4. package/docs/archive/wwdc-mlx-bun.html +848 -0
  5. package/docs/design/audio-input-plan.md +356 -0
  6. package/docs/design/batching-perf-path.md +255 -0
  7. package/docs/design/batching-v2-plan.md +23 -9
  8. package/docs/design/bucketing-stage.md +2 -1
  9. package/docs/design/compat-cli-surface-design.md +4 -3
  10. package/docs/design/decode-speed-program.md +172 -0
  11. package/docs/design/docs-reorg-plan.md +1 -1
  12. package/docs/design/dspark-serving-program.md +371 -0
  13. package/docs/design/dspark-speculative-decoding.md +167 -5
  14. package/docs/design/faithful-l1-consolidation.md +310 -0
  15. package/docs/design/generic-model-support.md +8 -5
  16. package/docs/design/grammar-spec-batching-integration.md +301 -0
  17. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  18. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  19. package/docs/design/omlx-adoption-map.md +98 -0
  20. package/docs/design/orpo-training.md +34 -30
  21. package/docs/design/paged-kv-cache.md +214 -0
  22. package/docs/design/parallel-slots.md +82 -49
  23. package/docs/design/parity-tier-dag.md +43 -33
  24. package/docs/design/repo-cleanup-plan.md +189 -0
  25. package/docs/design/runtime-isolation.md +19 -3
  26. package/docs/design/segmented-backward-training.md +18 -2
  27. package/docs/design/ssd-kv-cold-tier.md +288 -0
  28. package/docs/design/structured-output.md +484 -0
  29. package/docs/design/the-dreaming-master-plan.md +2 -3
  30. package/docs/design/turboquant-kv.md +235 -0
  31. package/docs/design/unified-engine-frontier-plan.md +774 -0
  32. package/docs/design/web-chat-beat-matrix.md +315 -0
  33. package/docs/design/web-chat-redesign.md +1112 -0
  34. package/docs/investigations/decode-roofline-lookagain.md +92 -6
  35. package/docs/investigations/dspark-handoff.md +182 -16
  36. package/docs/investigations/kernel-perf-review-2026-07.md +105 -26
  37. package/docs/investigations/lab-build-journal.md +2 -2
  38. package/docs/investigations/openwiki-evaluation.md +249 -0
  39. package/docs/planning/IDEAS.md +14 -0
  40. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  41. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  42. package/docs/planning/chat-ui-vision.md +50 -0
  43. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  44. package/docs/planning/release-notes-v0.0.10.md +57 -0
  45. package/docs/planning/release-notes-v0.0.11.md +239 -0
  46. package/docs/reference/cli.md +35 -16
  47. package/docs/reference/distribution.md +13 -6
  48. package/docs/reference/embedding.md +35 -14
  49. package/docs/reference/features-matrix.md +97 -0
  50. package/docs/reference/library-api.md +30 -9
  51. package/docs/reference/memory.md +26 -6
  52. package/docs/reference/models.md +11 -1
  53. package/docs/reference/orpo-quickstart.md +3 -4
  54. package/docs/reference/server-api.md +634 -22
  55. package/docs/reference/server-config.md +251 -119
  56. package/docs/reference/training.md +20 -16
  57. package/package.json +5 -3
  58. package/src/anthropic.ts +13 -0
  59. package/src/audio/conformer.ts +647 -0
  60. package/src/audio/decode.ts +133 -0
  61. package/src/audio/features.ts +202 -0
  62. package/src/audio/transcode.ts +67 -0
  63. package/src/cli.ts +402 -107
  64. package/src/config.ts +49 -2
  65. package/src/download.ts +40 -0
  66. package/src/eval/README.md +7 -6
  67. package/src/eval/kl.ts +59 -3
  68. package/src/eval/quality-db.ts +2 -2
  69. package/src/eval/runner.ts +3 -3
  70. package/src/expert-offload.ts +15 -2
  71. package/src/flags.ts +13 -0
  72. package/src/generate.ts +330 -16
  73. package/src/grammar.ts +585 -0
  74. package/src/hub-rest.ts +242 -0
  75. package/src/kv-store.ts +618 -122
  76. package/src/lora.ts +8 -0
  77. package/src/md-modules.d.ts +42 -0
  78. package/src/media-fetch.ts +284 -0
  79. package/src/memory/rest.ts +245 -0
  80. package/src/memory/vault.ts +74 -0
  81. package/src/mlx/array.ts +127 -19
  82. package/src/mlx/autograd.ts +2 -2
  83. package/src/mlx/ffi.ts +27 -3
  84. package/src/mlx/metal-kernel.ts +7 -4
  85. package/src/mlx/ops.ts +179 -23
  86. package/src/mlx/turboquant-ops.ts +831 -0
  87. package/src/mlx/turboquant-tables.ts +237 -0
  88. package/src/model/batched-mask.ts +62 -1
  89. package/src/model/batched-quant.ts +206 -0
  90. package/src/model/batched-rotating-quant.ts +346 -0
  91. package/src/model/batched-rotating.ts +46 -1
  92. package/src/model/compiled-decode.ts +4 -15
  93. package/src/model/factory.ts +10 -4
  94. package/src/model/gemma4-base.ts +391 -34
  95. package/src/model/gemma4.ts +122 -33
  96. package/src/model/generated/gemma4-12b.ts +9 -13
  97. package/src/model/generated/gemma4-26b.ts +10 -5
  98. package/src/model/generated/gemma4-e4b.ts +14 -9
  99. package/src/model/minicpm5.ts +58 -102
  100. package/src/model/paged-kv.ts +253 -0
  101. package/src/model/qwen3-delta.ts +123 -15
  102. package/src/model/qwen3-moe.ts +404 -0
  103. package/src/model/qwen3.ts +9 -8
  104. package/src/model/qwen3_5.ts +99 -25
  105. package/src/model/support.ts +12 -1
  106. package/src/model/universal/dense.ts +40 -5
  107. package/src/model/universal/rope.ts +36 -21
  108. package/src/pi-web.ts +1299 -82
  109. package/src/preflight.ts +27 -8
  110. package/src/prompt-cache.ts +338 -32
  111. package/src/quantize/quantizer.ts +16 -1
  112. package/src/registry.ts +61 -3
  113. package/src/serve/batch-scheduler.ts +1001 -76
  114. package/src/serve/generation-gateway.ts +292 -30
  115. package/src/serve/isolate.ts +417 -0
  116. package/src/serve/lane-registry.ts +44 -0
  117. package/src/serve/session-search.ts +326 -0
  118. package/src/server.ts +1460 -105
  119. package/src/spec/assistant-source.ts +150 -0
  120. package/src/spec/deepspec-source.ts +143 -0
  121. package/src/spec/dflash-source.ts +124 -0
  122. package/src/spec/dspark/ab-stats.ts +136 -0
  123. package/src/spec/dspark/calibration.ts +112 -0
  124. package/src/spec/dspark/deepspec-module.ts +801 -0
  125. package/src/spec/dspark/generate-dflash.ts +72 -20
  126. package/src/spec/dspark/loader.ts +23 -0
  127. package/src/spec/dspark/module-dflash.ts +278 -36
  128. package/src/spec/dspark/quantize-drafter.ts +91 -0
  129. package/src/spec/generate.ts +59 -25
  130. package/src/spec/ngram-source.ts +132 -0
  131. package/src/spec/serve-loop.ts +536 -0
  132. package/src/spec/source.ts +93 -0
  133. package/src/spec/two-model.ts +171 -0
  134. package/src/ssd-cache.ts +285 -0
  135. package/src/tokenizer.ts +288 -2
  136. package/src/tool-approvals.ts +131 -0
  137. package/src/tool-call.ts +408 -20
  138. package/src/train/flash-cce.ts +29 -26
  139. package/src/train/job.ts +0 -2
  140. package/src/train/loss.ts +53 -11
  141. package/src/train/prefix-shared.ts +30 -7
  142. package/src/train/segmented.ts +150 -46
  143. package/src/train/trainer.ts +22 -15
  144. package/src/vision/prompt.ts +217 -48
  145. package/src/web/app.html +1184 -1789
  146. package/src/web/app.js +5667 -0
  147. package/src/web/icon.svg +13 -0
  148. package/src/web/manifest.webmanifest +24 -0
  149. package/src/web/src/adapters-panel.ts +288 -0
  150. package/src/web/src/api.ts +57 -0
  151. package/src/web/src/assistant.ts +485 -0
  152. package/src/web/src/chat.ts +1050 -0
  153. package/src/web/src/composer.ts +959 -0
  154. package/src/web/src/dataset.ts +132 -0
  155. package/src/web/src/finetune.ts +267 -0
  156. package/src/web/src/hub.ts +428 -0
  157. package/src/web/src/main.ts +98 -0
  158. package/src/web/src/markdown.ts +0 -0
  159. package/src/web/src/memory-panel.ts +572 -0
  160. package/src/web/src/model-picker.ts +159 -0
  161. package/src/web/src/palette.ts +346 -0
  162. package/src/web/src/protocol.ts +62 -0
  163. package/src/web/src/quantize.ts +167 -0
  164. package/src/web/src/rag.ts +274 -0
  165. package/src/web/src/sessions.ts +469 -0
  166. package/src/web/src/shell.ts +804 -0
  167. package/src/web/src/status.ts +252 -0
  168. package/src/web/src/ui-catalog.ts +92 -0
  169. package/src/web/sw.js +71 -0
  170. package/src/web/vendor/README.md +61 -0
  171. package/src/web/vendor/hljs-LICENSE +29 -0
  172. package/src/web/vendor/hljs-theme.css +59 -0
  173. package/src/web/vendor/hljs.js +26 -0
  174. package/src/model/fused-decode-kernel.ts +0 -312
  175. package/src/model/fused-geglu-kernel.ts +0 -198
  176. package/src/model/fused-mlp-kernel.ts +0 -169
  177. package/src/model/fused-swiglu-kernel.ts +0 -240
  178. package/src/model/steel-linear-kernel.ts +0 -96
  179. package/src/status-page.html +0 -497
@@ -21,9 +21,12 @@
21
21
  // mask). Full layers share one leftPad/offset (all rows advance together); the
22
22
  // rotating caches self-track per-row leftPad/offset as the ring wraps. The
23
23
  // per-row absolute position stays consistent across both (full: offset-leftPad;
24
- // rot: offsetArr) — see docs/design/parallel-slots.md. Models whose caches
25
- // don't support the dynamic-B ops (e.g. Qwen3.5's SSMCache) never reach this
26
- // scheduler — GenerationGateway.willBatch gates on cache capability.
24
+ // rot: offsetArr) — see docs/design/parallel-slots.md. Hybrid gated-DeltaNet
25
+ // models (Qwen3.5) add "ssm" layers: SSMCache state is plain [B,...] with no
26
+ // temporal axis and no padding (rows solo-prefill unpadded, decode feeds one
27
+ // real token per row), so merge/filter are B-axis concat/take and the cache
28
+ // passes through the step unwrapped (no mask, no per-row RoPE — full layers
29
+ // carry positions). Gate: GenerationGateway.willBatch on cache capability.
27
30
  //
28
31
  // Engine mechanics (the serial decode loop's hygiene, transplanted —
29
32
  // batching-v2-plan step 3):
@@ -53,16 +56,56 @@
53
56
 
54
57
  import { MlxArray } from "../mlx/array";
55
58
  import * as ops from "../mlx/ops";
56
- import { clearCache } from "../mlx/ffi";
57
- import { KVCache, RotatingKVCache, type Cache } from "../model/gemma4-base";
58
- import { BatchedDecodeMaskCache, mergeKVRows, filterKVRows } from "../model/batched-mask";
59
+ import { clearCache, Dtype } from "../mlx/ffi";
60
+ import { flagOn } from "../flags";
61
+ import { CompiledDecode } from "../model/compiled-decode";
62
+ import type { Gemma4Model } from "../model/gemma4";
63
+ import {
64
+ KVCache, QuantizedKVCache, RotatingKVCache, RotatingQuantizedKVCache, type Cache,
65
+ } from "../model/gemma4-base";
66
+ import { BatchedDecodeMaskCache, mergeKVRows, extendKVRows, extractKVRow, filterKVRows } from "../model/batched-mask";
67
+ import {
68
+ BatchedQuantDecodeMaskCache, extendQuantRows, extractQuantRow, filterQuantRows, mergeQuantRows,
69
+ type QuantRow,
70
+ } from "../model/batched-quant";
71
+ import { BatchedRotatingQuantCache } from "../model/batched-rotating-quant";
72
+ import type { KvQuantSpec } from "../config";
59
73
  import { BatchedRotatingCache } from "../model/batched-rotating";
74
+ import { cloneKvCaches } from "../kv-store";
75
+ import { SSMCache } from "../model/qwen3-delta";
60
76
  import type { RuntimeModel } from "../model/factory";
77
+ import type { GrammarController } from "../grammar";
78
+ import { kvBytesAt } from "../fit";
79
+ import { toLogprobs } from "../sampler";
61
80
 
62
81
  /** Decode-pipeline kill switch (read once at load, like the serial loop's
63
82
  * MLX_BUN_COMPILED_DECODE): 1 ⇒ read each step's tokens synchronously. */
64
83
  const NO_PIPELINE = process.env.MLX_BUN_BATCH_NO_PIPELINE === "1";
65
84
 
85
+ /** Per-step phase timing (MLX_BUN_BATCH_STEP_TRACE=1, debug-only): where a
86
+ * decode step's wall time goes — graph BUILD+dispatch (host), the pipelined
87
+ * READ of the previous step's tokens (GPU wait), row EMIT (onToken/SSE), and
88
+ * the GAP between consecutive steps (drive-loop + everything else). Sums
89
+ * print via `stepTraceReport()` (the b1 profile experiment calls it). */
90
+ const STEP_TRACE = process.env.MLX_BUN_BATCH_STEP_TRACE === "1";
91
+ const STEP_T = { t0: 0, lastEnd: 0, build: 0, read: 0, emit: 0, gap: 0, n: 0 };
92
+ export function stepTraceReport(): string {
93
+ const per = (x: number) => (STEP_T.n ? (x / STEP_T.n).toFixed(3) : "0");
94
+ const s = `steps=${STEP_T.n} build=${per(STEP_T.build)}ms read=${per(STEP_T.read)}ms emit=${per(STEP_T.emit)}ms gap=${per(STEP_T.gap)}ms (per step)`;
95
+ STEP_T.t0 = STEP_T.lastEnd = STEP_T.build = STEP_T.read = STEP_T.emit = STEP_T.gap = STEP_T.n = 0;
96
+ return s;
97
+ }
98
+
99
+ // NOTE — oMLX-style "adaptive burst decode" (several steps per event-loop
100
+ // yield, omlx/engine_core.py _step_burst) was ported here 2026-07-02 and
101
+ // REFUTED by measurement: on this runtime it REGRESSED cpm5 B=4 aggregate
102
+ // 345→289 tok/s, batch-lane B=1 149→121, and TTFT ~+100 ms (first-token SSE
103
+ // flush waits out the burst budget). Their win exists because each Python
104
+ // step hand-off ping-pongs the GIL with asyncio/uvicorn (~1 ms/token); Bun's
105
+ // setImmediate hop costs microseconds, so bursting here only delays socket
106
+ // flushes. Don't re-add without new evidence — the per-yield step below is
107
+ // the measured optimum (docs/design/batching-perf-path.md P4 notes).
108
+
66
109
  /** A token sampler for one row: (logits [1,V], step) → token array [1] on
67
110
  * device. Greedy is `(l) => ops.argmaxAxis(l, -1)`; richer closures fold in
68
111
  * temperature / top-p / logits processors + history (built per request from
@@ -81,17 +124,55 @@ export interface BatchRequest {
81
124
  * THIS row only (its submit promise rejects; siblings continue). May be
82
125
  * async; keep it cheap — it runs inline in the step loop. */
83
126
  onToken: (token: number) => void | boolean | Promise<void | boolean>;
127
+ /** The row's sampler is PLAIN GREEDY (temperature 0, no curve) with no
128
+ * logits processors and no grammar — set by the gateway when true. Lets
129
+ * the scheduler take the vectorized sampling fast path (ONE
130
+ * log-softmax+argmax over [B,V] instead of B slice/sample/concat graphs)
131
+ * when EVERY live row qualifies. Numerically identical per row
132
+ * (row-independent ops, same per-row shapes); the per-row closure path
133
+ * is the fallback and the MLX_BUN_BATCH_VEC_SAMPLE=0 kill switch. */
134
+ plainGreedy?: boolean;
135
+ /** Grammar controller for this row (B1: per-row matchers). When set, the
136
+ * scheduler drives it: `accept()` after each emitted token (fires the async
137
+ * bitmask fill), `await ready()` before the row's next sample (the sample
138
+ * closure applies `applyMask`). Termination (grammar satisfied) is an
139
+ * additional per-row stop source. The gateway OWNS disposal (finally around
140
+ * submit()); the scheduler uses but never owns. Null on the degrade path. */
141
+ grammar?: GrammarController;
142
+ /** Stable cache boundary from the server's template probe (the serial
143
+ * lane's options.snapshotAt): the prompt prefix the NEXT turn's render
144
+ * preserves. The scheduler snapshots a trim-free prompt-cache entry at
145
+ * min(this, promptIds.length-1) during the solo prefill — the oracle
146
+ * invariant (mlx-lm insert_segments) that gives wrapped-ring models a
147
+ * reuse path. Absent = still capped at promptIds.length-1. */
148
+ snapshotAt?: number;
84
149
  }
85
150
 
86
151
  export interface BatchStats {
87
152
  promptTokens: number;
88
153
  generatedTokens: number;
89
- /** Prompt tokens served from a pre-warmed cache. Always 0 in v1 (each row is
90
- * solo-prefilled from scratch; prompt-cache reuse under batching is later). */
154
+ /** Prompt tokens served from the prompt cache (Phase 3.2): a joiner's solo
155
+ * prefill starts from the longest usable cached prefix, exactly like the
156
+ * serial lane's runGeneration take(). 0 on a cold prefill. */
91
157
  cachedTokens: number;
92
158
  finishReason: "stop" | "length";
93
159
  }
94
160
 
161
+ /** The slice of PromptCache the scheduler drives (structural — the server's
162
+ * PromptCache satisfies it). take() on admission (any joiner: a restored
163
+ * prefix + suffix prefill is byte-safe whether the row later merges or
164
+ * not). put() paths: rows that finish NEVER-MERGED (adopted lone rows,
165
+ * Phase 3.2) hand their pristine serial caches over zero-copy; rows that
166
+ * finish INSIDE a multi-row batch get their KV EXTRACTED per row into
167
+ * fresh serial caches first (mlx-lm server.py:872 extract_cache — the
168
+ * cross-request reuse concurrent agents live on). */
169
+ export interface RowPromptCache {
170
+ take(prompt: number[], ns?: string): {
171
+ tokens: number[]; caches: Cache[]; retain?: () => void;
172
+ } | null;
173
+ put(tokens: number[], caches: Cache[], ns?: string, retain?: () => void): void;
174
+ }
175
+
95
176
  interface Row {
96
177
  req: BatchRequest;
97
178
  resolve: (s: BatchStats) => void;
@@ -100,6 +181,28 @@ interface Row {
100
181
  generated: number; // tokens emitted so far (incl. a terminating EOS)
101
182
  sampled: number; // sample() calls so far (leads `generated` by 1 in-pipeline)
102
183
  promptTokens: number;
184
+ /** Prompt tokens restored from the prompt cache at admission (stats). */
185
+ cachedTokens: number;
186
+ /** Generated tokens whose KV actually entered the cache (fed as a step
187
+ * input) — serial generate()'s `forwarded` list. promptIds+fed is the
188
+ * exact token coverage of the row's caches, the put() entry key.
189
+ * PER-ROW EXACT for every row: pushes are gated on the pending
190
+ * register's per-slot real/placeholder flags (#pendingReal), so a
191
+ * doomed slot's placeholder is never recorded — and if one ever DID
192
+ * feed a forward, `fedTainted` flips instead (see #step's read). */
193
+ fed: number[];
194
+ /** A placeholder slot value fed this row's KV (junk token in the cache):
195
+ * the coverage key is no longer derivable — extraction refuses.
196
+ * Structurally unreachable today (a doomed row always evicts at the
197
+ * emit that follows its placeholder, filtering the slot before the
198
+ * next forward; cold steps never contain doomed rows because a flush
199
+ * emits every in-flight token) — this flag makes `fed` provably exact
200
+ * instead of resting on that timing chain. */
201
+ fedTainted: boolean;
202
+ /** Ever merged into a multi-row batch (or poisoned by a reject): its KV
203
+ * is interleaved in batched buffers — a finish put() must EXTRACT the
204
+ * row (#extractRowCaches) rather than adopt the inners. */
205
+ merged: boolean;
103
206
  }
104
207
 
105
208
  /** A joiner mid-prefill: `pos` prompt tokens are already in `solo`. Advanced
@@ -108,6 +211,13 @@ interface PrefillState {
108
211
  row: Row;
109
212
  solo: Cache[];
110
213
  pos: number;
214
+ /** Release hook carried by an SSD-restored prompt-cache entry (must run
215
+ * only after `solo`/the adopted caches are disposed — see PromptCacheEntry). */
216
+ retain?: () => void;
217
+ /** Pending boundary-snapshot position: #prefillChunk splits its chunking
218
+ * exactly here, clones + put()s the trim-free prefix entry, then nulls
219
+ * it. null = no snapshot for this row (short prompt / cached past it). */
220
+ snapAt: number | null;
111
221
  }
112
222
 
113
223
  /** Held while the batch is active; the serial fallback acquires the same lock. */
@@ -125,9 +235,41 @@ export interface BatchSchedulerOptions {
125
235
  admissionHeld?: () => boolean;
126
236
  /** Joiner prefill chunk length (default 2048, the serial loop's constant). */
127
237
  prefillChunkSize?: number;
238
+ /** Aggregate KV-byte budget across all running rows (batching-perf-path
239
+ * P3 admission, via `--kv-budget`). A joiner whose PROJECTED KV
240
+ * (kvBytesAt(config, prompt + maxTokens), sliding window already capped)
241
+ * would push the batch's projected total over this ceiling WAITS in the
242
+ * queue (FIFO, head-of-line — no starvation, no reorder) until rows
243
+ * evict. A request that can NEVER fit (over budget alone, empty batch)
244
+ * is rejected instead of deadlocking. Unset = unlimited (v1 behavior). */
245
+ kvBudgetBytes?: number;
246
+ /** Per-layer mixed-precision KV scheme (kv_config.json entries) — Phase
247
+ * 3.1 batched quantized KV. Server-wide constant (one scheme per server
248
+ * lifetime), so it lives here at construction, not on BatchRequest.
249
+ * P1 scope: only layers whose cache is a plain full-attention KVCache
250
+ * convert; the GATEWAY admits batching only when every configured
251
+ * layerIdx is such a layer (rotating/uniform stay serial). Each joiner's
252
+ * solo prefill converts at the SAME chunk boundaries as the serial
253
+ * maybeQuantizeKv, so a row's quantized bytes are bit-exact vs serial
254
+ * `--kv-quant config` by construction (the L2-oracle composition rule). */
255
+ kvConfig?: KvQuantSpec[];
256
+ /** Prompt-cache hook (Phase 3.2): admission take()s the longest usable
257
+ * prefix into the joiner's solo caches (suffix-only prefill — the
258
+ * multi-turn chat TTFT path); rows that finish never-merged put() their
259
+ * caches back. Adapter requests never reach the batch lane, so the
260
+ * namespace is always "" here. Runs under the gateway mutex domain, so
261
+ * take/put never race the serial lane's use of the same cache. */
262
+ promptCache?: RowPromptCache;
128
263
  }
129
264
 
130
- type LayerInner = KVCache | BatchedRotatingCache;
265
+ type LayerInner =
266
+ | KVCache
267
+ | QuantizedKVCache
268
+ | BatchedRotatingCache
269
+ | BatchedRotatingQuantCache // rot layer under a kv_config scheme (milestone 2)
270
+ | RotatingKVCache // adopted lone-row state only (see #mergeJoiner adopt)
271
+ | RotatingQuantizedKVCache // adopted lone-row state, quantized rot layer
272
+ | SSMCache;
131
273
  type Row1 = { keys: MlxArray; values: MlxArray };
132
274
 
133
275
  export class BatchScheduler {
@@ -139,37 +281,113 @@ export class BatchScheduler {
139
281
  /** Sampled-but-unread token array [B], aligned with #running — the decode
140
282
  * pipeline register. Filtered/disposed alongside the batched KV. */
141
283
  #pendingToks: MlxArray | null = null;
284
+ /** Per-slot flags for #pendingToks: true = a REAL sampled token of that
285
+ * row's stream; false = a doomed/terminated row's placeholder (fed-token
286
+ * accounting must skip it — see Row.fed/fedTainted). Filtered/cleared in
287
+ * lockstep with the register. */
288
+ #pendingReal: boolean[] | null = null;
142
289
  #steps = 0; // decode-step counter (clearCache cadence)
290
+ #lastHop = 0; // last macrotask yield (B=1 fast-path rate limiting)
143
291
  #looping = false;
144
292
  #wake: (() => void) | null = null;
145
293
  readonly #maxBatch: number;
146
294
  readonly #lock: ExclusiveLock | undefined;
147
295
  readonly #admissionHeld: (() => boolean) | undefined;
148
296
  readonly #prefillChunkSize: number;
149
- readonly #kinds: ("full" | "rot")[]; // per-layer attention type
297
+ readonly #kvBudgetBytes: number | undefined;
298
+ readonly #promptCache: RowPromptCache | undefined;
299
+ /** Retain hook of the currently-ADOPTED row's cache entry (at most one:
300
+ * only a lone adopted row holds un-copied entry caches). Runs after the
301
+ * adopted caches are disposed, or transfers back on put(). */
302
+ #adoptedRetain: (() => void) | null = null;
303
+ readonly #kinds: ("full" | "rot" | "ssm")[]; // per-layer attention type
150
304
  readonly #rotMaxSize: number[]; // per-layer sliding window (rot layers only)
305
+ /** layerIdx → mixed-precision spec (Phase 3.1); null = bf16 batch (v1). */
306
+ readonly #kvByLayer: Map<number, KvQuantSpec> | null;
307
+ /** Compiled decode runner for the B=1 serial-class case (Phase 3.2) —
308
+ * same eligibility gate as generate.ts (gemma dense, kill switch
309
+ * MLX_BUN_COMPILED_DECODE); adapters never reach the batch lane. Set
310
+ * to null permanently on a failed step (serial disables per
311
+ * generation; the scheduler is one long-lived "generation"). */
312
+ #compiled: CompiledDecode | null;
151
313
 
152
314
  constructor(private readonly model: RuntimeModel, opts: BatchSchedulerOptions) {
153
315
  this.#maxBatch = Math.max(1, Math.floor(opts.maxBatch));
154
316
  this.#lock = opts.lock;
155
317
  this.#admissionHeld = opts.admissionHeld;
156
318
  this.#prefillChunkSize = Math.max(1, Math.floor(opts.prefillChunkSize ?? 2048));
319
+ this.#kvBudgetBytes = opts.kvBudgetBytes;
320
+ this.#promptCache = opts.promptCache;
157
321
  const proto = model.makeCache(); // fresh caches hold no buffers
158
- this.#kinds = proto.map((c) => (c instanceof RotatingKVCache ? "rot" : "full"));
322
+ this.#kinds = proto.map((c) =>
323
+ c instanceof RotatingKVCache ? "rot" : c instanceof SSMCache ? "ssm" : "full",
324
+ );
325
+ this.#kvByLayer = opts.kvConfig?.length
326
+ ? new Map(opts.kvConfig.map((e) => [e.layerIdx, e]))
327
+ : null;
159
328
  this.#rotMaxSize = proto.map((c) => (c instanceof RotatingKVCache ? c.maxSize : 0));
160
329
  for (const c of proto) c.dispose();
330
+ this.#compiled =
331
+ flagOn("MLX_BUN_COMPILED_DECODE", true) &&
332
+ model.config.modelType.startsWith("gemma4") &&
333
+ !model.config.text.enableMoeBlock
334
+ ? CompiledDecode.for(model as Gemma4Model)
335
+ : null;
161
336
  }
162
337
 
163
338
  get activeRows(): number {
164
339
  return this.#running.length;
165
340
  }
166
341
 
342
+ get pendingRows(): number {
343
+ return this.#pending.length + (this.#prefill ? 1 : 0);
344
+ }
345
+
346
+ /** Projected KV bytes of one row at its worst case (full prompt + full
347
+ * completion; the sliding-window term is window-capped by kvBytesAt). */
348
+ #rowKvBytes(row: Row): number {
349
+ return kvBytesAt(this.model.config, row.promptTokens + row.req.maxTokens);
350
+ }
351
+
352
+ /** Projected aggregate KV of everything admitted (running + mid-prefill). */
353
+ get projectedKvBytes(): number {
354
+ let total = this.#running.reduce((a, r) => a + this.#rowKvBytes(r), 0);
355
+ if (this.#prefill) total += this.#rowKvBytes(this.#prefill.row);
356
+ return total;
357
+ }
358
+
359
+ get kvBudgetBytes(): number | undefined {
360
+ return this.#kvBudgetBytes;
361
+ }
362
+
363
+ /** KV-budget admission for the queue head. True = admit now. A candidate
364
+ * that cannot fit even alone is rejected here (never deadlocks the
365
+ * queue); one that fits alone but not alongside the current batch waits. */
366
+ #kvAdmits(candidate: Row): boolean {
367
+ if (this.#kvBudgetBytes === undefined) return true;
368
+ const need = this.#rowKvBytes(candidate);
369
+ if (need > this.#kvBudgetBytes && this.#running.length === 0 && !this.#prefill) {
370
+ this.#pending.shift();
371
+ candidate.reject(
372
+ new Error(
373
+ `kv budget: request needs ~${(need / 1e9).toFixed(2)} GB KV ` +
374
+ `(prompt ${candidate.promptTokens} + max_tokens ${candidate.req.maxTokens}), ` +
375
+ `over --kv-budget ${(this.#kvBudgetBytes / 1e9).toFixed(2)} GB — ` +
376
+ `lower max_tokens or raise the budget`,
377
+ ),
378
+ );
379
+ return false;
380
+ }
381
+ return this.projectedKvBytes + need <= this.#kvBudgetBytes;
382
+ }
383
+
167
384
  /** Submit a request; resolves when its row finishes (EOS, stop, or length). */
168
385
  submit(req: BatchRequest): Promise<BatchStats> {
169
386
  return new Promise<BatchStats>((resolve, reject) => {
170
387
  this.#pending.push({
171
388
  req, resolve, reject,
172
389
  current: 0, generated: 0, sampled: 0, promptTokens: req.promptIds.length,
390
+ cachedTokens: 0, fed: [], fedTainted: false, merged: false,
173
391
  });
174
392
  this.#ensureLoop();
175
393
  });
@@ -209,8 +427,31 @@ export class BatchScheduler {
209
427
  // Start at most one joiner prefill (drain-aware); it advances one
210
428
  // chunk per iteration, interleaved with the decode step below.
211
429
  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 };
430
+ this.#pending.length > 0 && this.#running.length < this.#maxBatch &&
431
+ this.#kvAdmits(this.#pending[0]!)) {
432
+ const row = this.#pending.shift()!;
433
+ // Prompt-cache reuse (Phase 3.2): start the solo prefill from the
434
+ // longest usable cached prefix — the serial lane's take(), same
435
+ // semantics (entry ownership transfers; put back or dispose).
436
+ const hit = this.#promptCache?.take(row.req.promptIds) ?? null;
437
+ if (hit) row.cachedTokens = hit.tokens.length;
438
+ // Boundary snapshot (the serial lane's A1, ported): a trim-free
439
+ // strict-prefix entry at min(stableLen, len-1) for every
440
+ // substantial cold prefill — same gates as runGeneration
441
+ // (>= 256 tokens, extends past the cached prefix). Without this
442
+ // the batch lane's ONLY entry was the untrimmable [prompt+gen]
443
+ // finish-time put — a total ctx-repeat miss once rings wrap
444
+ // (12B batched 84 s vs serial 0.4 s, 2026-07-06).
445
+ const len = row.req.promptIds.length;
446
+ const boundary = Math.min(row.req.snapshotAt ?? len, len - 1);
447
+ const snapAt =
448
+ this.#promptCache && boundary >= 256 && boundary > (hit?.tokens.length ?? 0)
449
+ ? boundary
450
+ : null;
451
+ this.#prefill = hit
452
+ ? { row, solo: hit.caches, pos: hit.tokens.length, retain: hit.retain, snapAt }
453
+ : { row, solo: this.model.makeCache(), pos: 0, snapAt };
454
+ }
214
455
 
215
456
  if (this.#prefill) {
216
457
  const p = this.#prefill;
@@ -221,6 +462,7 @@ export class BatchScheduler {
221
462
  // the running batch.
222
463
  this.#prefill = null;
223
464
  for (const c of p.solo) c.dispose();
465
+ p.retain?.();
224
466
  p.row.reject(e);
225
467
  }
226
468
  }
@@ -231,7 +473,9 @@ export class BatchScheduler {
231
473
  // still interleave: a mid-prefill joiner leaves #prefill non-null, so
232
474
  // the loop falls through to run a decode step between chunks.
233
475
  if (this.#prefill === null && !held &&
234
- this.#pending.length > 0 && this.#running.length < this.#maxBatch)
476
+ this.#pending.length > 0 && this.#running.length < this.#maxBatch &&
477
+ (this.#kvBudgetBytes === undefined ||
478
+ this.projectedKvBytes + this.#rowKvBytes(this.#pending[0]!) <= this.#kvBudgetBytes))
235
479
  continue;
236
480
 
237
481
  if (this.#running.length > 0) {
@@ -240,10 +484,25 @@ export class BatchScheduler {
240
484
  } catch (e) {
241
485
  // A forward/sampling error can't be attributed to one row — drop
242
486
  // the batch. (A row's onToken error is contained in #emitRows.)
487
+ // dropOnly: mid-step caches may be inconsistent — never put().
243
488
  for (const row of this.#running) row.reject(e);
244
- this.#applyFilter([]);
489
+ this.#applyFilter([], true);
245
490
  }
246
491
  }
492
+ // The per-step macrotask hop exists so joiners/admin interleave at
493
+ // token granularity. With ONE row and an empty queue there is
494
+ // nobody to interleave — rate-limit the hop to the serial loop's
495
+ // 25 ms cadence (identical responsiveness budget; generate()'s
496
+ // hoppingOnToken does exactly this). This was the B=1 drive-loop
497
+ // tax: one setImmediate round-trip per token ≈ the 0.4–0.8% gap
498
+ // vs pure serial (paired-measured 2026-07-05).
499
+ if (
500
+ this.#running.length === 1 && this.#pending.length === 0 &&
501
+ !this.#prefill && this.#admissionHeld?.() !== true &&
502
+ performance.now() - this.#lastHop < 25
503
+ )
504
+ continue;
505
+ this.#lastHop = performance.now();
247
506
  await new Promise<void>((r) => setImmediate(r));
248
507
  }
249
508
  } finally {
@@ -252,28 +511,111 @@ export class BatchScheduler {
252
511
  }
253
512
  }
254
513
 
514
+ /** Per-layer mixed-precision conversion of a joiner's SOLO caches — the
515
+ * scheduler-side mirror of the serial maybeQuantizeKv (generate.ts): same
516
+ * per-layer map, same skip rules (empty cache, already quantized), same
517
+ * streaming discipline (evalAll the converted layer's state so the bf16
518
+ * source frees before the next layer converts). Called at every prefill
519
+ * chunk boundary AND once before merge, exactly where the serial loop
520
+ * calls maybeQuantizeKv — that placement is what makes a row's quantized
521
+ * bytes bit-exact vs serial `--kv-quant config`. P1: plain KVCache
522
+ * layers only (the gateway guarantees the config maps only to those). */
523
+ #quantizeSolo(solo: Cache[]): void {
524
+ if (!this.#kvByLayer) return;
525
+ for (let i = 0; i < solo.length; i++) {
526
+ const e = this.#kvByLayer.get(i);
527
+ const c = solo[i]!;
528
+ if (!e || c.offset === 0) continue;
529
+ // Milestone 2: rotating layers convert too — RotatingKVCache.
530
+ // toQuantized → RotatingQuantizedKVCache, exactly maybeQuantizeKv's
531
+ // dispatch (generate.ts). Converted caches don't match either class
532
+ // (the four cache classes are siblings), so re-conversion never fires.
533
+ if (!(c instanceof KVCache || c instanceof RotatingKVCache)) continue;
534
+ const q = c.toQuantized(e.groupSize, e.bits);
535
+ solo[i] = q;
536
+ ops.evalAll(q.state());
537
+ clearCache();
538
+ }
539
+ }
540
+
255
541
  /** Advance a joiner's solo prefill by one chunk. Non-final chunks forward +
256
542
  * eval the cache and return false (the caller interleaves a decode step).
257
543
  * The final chunk samples token 0, emits it, and — if the row survives —
258
544
  * merges it into the running batch; returns true (admission complete). */
259
545
  async #prefillChunk(p: PrefillState): Promise<boolean> {
260
546
  const prompt = p.row.req.promptIds;
261
- if (prompt.length - p.pos > this.#prefillChunkSize) {
547
+ // Boundary-snapshot chunking (mirrors generate.ts's snapshotAt split):
548
+ // chunk edges land EXACTLY on snapAt so the clone is taken while the
549
+ // caches hold precisely that prefix — slicing tokens against caches at
550
+ // any other offset would store corrupt entries. The extra split only
551
+ // changes chunk shapes for opted-in rows: the same numerics class as a
552
+ // prompt-cache-hit continuation prefill (GEMV/GEMM reduction order).
553
+ if (p.snapAt !== null && p.pos < p.snapAt) {
554
+ const end = Math.min(p.pos + this.#prefillChunkSize, p.snapAt);
555
+ const chunk = prompt.slice(p.pos, end);
556
+ const ids = ops.fromInt32(chunk, [1, chunk.length]);
557
+ const h = this.model.forwardHidden(ids, p.solo);
558
+ ids.dispose();
559
+ h.dispose();
560
+ ops.evalAll(p.solo.flatMap((c) => c.state()));
561
+ this.#quantizeSolo(p.solo); // serial converts at every chunk boundary
562
+ clearCache();
563
+ p.pos = end;
564
+ if (p.pos === p.snapAt) {
565
+ try {
566
+ this.#promptCache!.put(prompt.slice(0, p.snapAt), cloneKvCaches(p.solo));
567
+ } catch (e) {
568
+ console.warn(`batch-lane boundary snapshot skipped: ${(e as Error).message}`);
569
+ }
570
+ p.snapAt = null;
571
+ }
572
+ return false;
573
+ }
574
+ // Oracle prefill convention (see generate.ts prefill): drain only to
575
+ // len-1 in chunks, then step-0 logits from an L=1 forward of the LAST
576
+ // prompt token — mirrors mlx-lm's batched engine (insert_segments'
577
+ // forced final 1-token segment + GenerationBatch._step,
578
+ // generate.py:1645/1327) and keeps this lane bit-exact with the serial
579
+ // lane (unified == --batch 1 == oracle). The L=1 step runs on the solo
580
+ // caches BEFORE merge, so per-row serial equivalence is by construction.
581
+ // MLX_BUN_PREFILL_TAIL_SPLIT=0 restores the old convention.
582
+ const tailSplit = flagOn("MLX_BUN_PREFILL_TAIL_SPLIT", true);
583
+ if ((tailSplit ? prompt.length - p.pos - 1 : prompt.length - p.pos) >
584
+ this.#prefillChunkSize) {
262
585
  const chunk = prompt.slice(p.pos, p.pos + this.#prefillChunkSize);
263
586
  const ids = ops.fromInt32(chunk, [1, chunk.length]);
264
587
  const h = this.model.forwardHidden(ids, p.solo);
265
588
  ids.dispose();
266
589
  h.dispose(); // logits never computed for non-final chunks
267
590
  ops.evalAll(p.solo.flatMap((c) => c.state()));
591
+ this.#quantizeSolo(p.solo); // serial converts at every chunk boundary
268
592
  clearCache(); // serial prefill's per-chunk clear (generate.ts)
269
593
  p.pos += this.#prefillChunkSize;
270
594
  return false;
271
595
  }
272
596
 
597
+ // Tail split: forward the remaining head [p.pos, len-1) as a plain chunk
598
+ // (serial chunk-boundary semantics: eval + quantize + clear), leaving
599
+ // exactly one token for the L=1 step-0 forward below. Head + step share
600
+ // this tick — same chunk-tick count as before, plus one L=1 dispatch
601
+ // (~a single decode step) in the admission tick's no-yield window.
602
+ if (tailSplit && p.pos < prompt.length - 1) {
603
+ const head = prompt.slice(p.pos, prompt.length - 1);
604
+ const ids = ops.fromInt32(head, [1, head.length]);
605
+ const h = this.model.forwardHidden(ids, p.solo);
606
+ ids.dispose();
607
+ h.dispose();
608
+ ops.evalAll(p.solo.flatMap((c) => c.state()));
609
+ this.#quantizeSolo(p.solo);
610
+ clearCache();
611
+ p.pos = prompt.length - 1;
612
+ }
613
+
273
614
  // Final chunk: slice the LAST hidden position BEFORE the lm_head — running
274
615
  // logitsFromHidden on the whole [1,Lp,H] hidden materializes a [1,Lp,V]
275
616
  // transient (~4.3 GB bf16 at Gemma V=262k, 8k prompt). Same reorder as the
276
- // serial path (generate.ts prefill).
617
+ // serial path (generate.ts prefill). Under tailSplit this chunk is exactly
618
+ // the last prompt token (L=1).
277
619
  const chunk = prompt.slice(p.pos);
278
620
  const ids = ops.fromInt32(chunk, [1, chunk.length]);
279
621
  const h = this.model.forwardHidden(ids, p.solo);
@@ -292,12 +634,38 @@ export class BatchScheduler {
292
634
  p.row.generated = 1;
293
635
  clearCache(); // drop the prefill transients (serial's token-0 clear)
294
636
 
637
+ // Grammar (B1): the mask0 was applied inside the sample closure (the
638
+ // controller is primed at compile). After reading token 0, advance the
639
+ // matcher — fires the async fill for the NEXT step, overlapping the merge
640
+ // below. If the grammar is already satisfied at token 0 (a 1-token grammar,
641
+ // e.g. guided_choice landing on a single-token option), emit + finish("stop")
642
+ // WITHOUT merging into the batch — the row never joins #running.
643
+ if (p.row.req.grammar) {
644
+ p.row.req.grammar.accept(tok);
645
+ if (p.row.req.grammar.isTerminated) {
646
+ const stop = await this.#emit(p.row, tok);
647
+ // Token 0 was sampled but never fed — the caches cover exactly the
648
+ // prompt, a clean prompt-only entry (put-or-dispose).
649
+ this.#putOrDispose(p.solo, p.row.req.promptIds, p.retain);
650
+ this.#finish(p.row, stop === "continue" ? "stop" : stop);
651
+ return true;
652
+ }
653
+ }
654
+
295
655
  const stop = await this.#emit(p.row, tok);
296
656
  if (stop !== "continue") {
297
- for (const c of p.solo) c.dispose();
657
+ this.#putOrDispose(p.solo, p.row.req.promptIds, p.retain);
298
658
  this.#finish(p.row, stop);
299
659
  return true;
300
660
  }
661
+ // Default tail-split path: the caches were already converted at the head
662
+ // boundary above (oracle composition: prefill ids[:-1] → convert → L=1
663
+ // step-0), so this call is an idempotent no-op (converted caches match
664
+ // neither serial class). It is LOAD-BEARING only under
665
+ // MLX_BUN_PREFILL_TAIL_SPLIT=0 — the old serial order: token 0 sampled
666
+ // from the unconverted final-chunk logits, THEN the caches convert
667
+ // (before decode step 1 == before the merge).
668
+ this.#quantizeSolo(p.solo);
301
669
  await this.#mergeJoiner(p);
302
670
  return true;
303
671
  }
@@ -308,19 +676,154 @@ export class BatchScheduler {
308
676
  async #mergeJoiner(p: PrefillState): Promise<void> {
309
677
  await this.#flushPipeline();
310
678
 
679
+ // ADOPT, don't copy (unified-engine plan Phase 3.2): a row joining an
680
+ // EMPTY batch keeps its solo caches as the batch inners — a pointer
681
+ // handoff, zero bytes moved (the old path ran the full merge machinery
682
+ // to produce a byte-identical [1,...] copy). The copy now happens only
683
+ // when a SECOND row joins and a genuinely new layout must exist. The
684
+ // prize beyond the saved copy: the lone row's caches stay SERIAL-CLASS
685
+ // (KVCache / RotatingKVCache / QuantizedKVCache), so the B=1 step is
686
+ // literally the serial graph, and compiled decode + prompt-cache
687
+ // take/put become possible for it. The rot branch below knows how to
688
+ // treat an adopted RotatingKVCache as the merge's first row.
689
+ if (!this.#inners) {
690
+ this.#inners = p.solo as LayerInner[];
691
+ this.#fullLeftPad = [0];
692
+ this.#adoptedRetain = p.retain ?? null;
693
+ this.#running.push(p.row);
694
+ return;
695
+ }
696
+
311
697
  const prev = this.#inners;
312
698
  const prevPad = this.#fullLeftPad;
313
699
  const B = this.#running.length;
314
700
  const newInners: LayerInner[] = [];
315
701
  let newFullPad = this.#fullLeftPad;
316
702
  for (let layer = 0; layer < this.#kinds.length; layer++) {
703
+ if (this.#kinds[layer] === "ssm") {
704
+ // No temporal axis, no left-pad: B-axis concat of the state slots.
705
+ // mergeRows steals the solo arrays when the batch starts cold, so the
706
+ // unconditional p.solo dispose below stays safe either way.
707
+ newInners.push(SSMCache.mergeRows(
708
+ (prev?.[layer] as SSMCache | undefined) ?? null,
709
+ p.solo[layer] as SSMCache,
710
+ ));
711
+ continue;
712
+ }
713
+ if (p.solo[layer] instanceof QuantizedKVCache) {
714
+ // Phase 3.1 — quantized full layer: same merge/extend shapes as the
715
+ // bf16 branch below, over (packed, scales, biases) triples. The solo
716
+ // row was converted by #quantizeSolo with the serial ops, so its
717
+ // bytes already bit-match serial `--kv-quant config`; this branch
718
+ // only re-arranges rows along the batch axis.
719
+ const qSolo = p.solo[layer] as QuantizedKVCache;
720
+ const [qk, qv] = qSolo.temporalView();
721
+ const qRow = { keys: qk, values: qv };
722
+ const dispose3 = (t: { packed: MlxArray; scales: MlxArray; biases: MlxArray }) => {
723
+ t.packed.dispose(); t.scales.dispose(); t.biases.dispose();
724
+ };
725
+ const prevQ = prev?.[layer] as QuantizedKVCache | undefined;
726
+ if (prevQ && process.env.MLX_BUN_BATCH_EXTEND !== "0") {
727
+ const [k0, v0] = prevQ.temporalView();
728
+ const ext = extendQuantRows(k0, v0, prevPad, qRow);
729
+ dispose3(k0); dispose3(v0);
730
+ newFullPad = ext.leftPad;
731
+ const c = new QuantizedKVCache(qSolo.groupSize, qSolo.bits);
732
+ c.restoreState(ext.keys, ext.values, ext.width);
733
+ newInners.push(c);
734
+ } else {
735
+ const rows: { keys: typeof qk; values: typeof qv }[] = [];
736
+ if (prevQ) {
737
+ const [k0, v0] = prevQ.temporalView(); // [B,H,off,*]
738
+ const S = k0.packed.shape[2]!;
739
+ for (let b = 0; b < B; b++) {
740
+ const pad = prevPad[b]!;
741
+ const cutRow = (t: typeof k0): typeof k0 => ({
742
+ packed: t.packed.slice([b, 0, pad, 0], [b + 1, t.packed.shape[1]!, S, t.packed.shape[3]!]),
743
+ scales: t.scales.slice([b, 0, pad, 0], [b + 1, t.scales.shape[1]!, S, t.scales.shape[3]!]),
744
+ biases: t.biases.slice([b, 0, pad, 0], [b + 1, t.biases.shape[1]!, S, t.biases.shape[3]!]),
745
+ });
746
+ rows.push({ keys: cutRow(k0), values: cutRow(v0) });
747
+ }
748
+ dispose3(k0); dispose3(v0);
749
+ }
750
+ rows.push(qRow);
751
+ const merged = mergeQuantRows(rows);
752
+ newFullPad = merged.leftPad;
753
+ const c = new QuantizedKVCache(qSolo.groupSize, qSolo.bits);
754
+ c.restoreState(merged.keys, merged.values, merged.width);
755
+ newInners.push(c);
756
+ for (const r of rows) { dispose3(r.keys); dispose3(r.values); }
757
+ }
758
+ // qRow views are disposed via the rows loop above or here for extend
759
+ if (prevQ && process.env.MLX_BUN_BATCH_EXTEND !== "0") { dispose3(qRow.keys); dispose3(qRow.values); }
760
+ else if (!prevQ) { /* disposed in the rows loop */ }
761
+ continue;
762
+ }
763
+ // NOTE: the quantized-rotating branch below never uses this layer's
764
+ // bf16 temporalView — calling it before the branch leaked the pair
765
+ // (six arrays per second-row join on quantized rotating layers,
766
+ // introduced in 859572d; 2026-07-07 review fix). The view is taken
767
+ // AFTER the branch, on the paths that actually consume it.
317
768
  const soloC = p.solo[layer] as KVCache | RotatingKVCache;
769
+ if (this.#kinds[layer] === "rot" && p.solo[layer] instanceof RotatingQuantizedKVCache) {
770
+ // Milestone 2 — QUANTIZED rotating layer: the solo row converted at
771
+ // the serial boundaries (#quantizeSolo), so its ring bytes are the
772
+ // serial oracle's; this branch re-arranges temporal triples across
773
+ // the batch axis (the bf16 rot merge over triples).
774
+ const qSolo = p.solo[layer] as RotatingQuantizedKVCache;
775
+ const [qk, qv] = qSolo.temporalView();
776
+ const rows: QuantRow[] = [];
777
+ const offsets: number[] = [];
778
+ const dispose3 = (t: QuantRow) => {
779
+ t.keys.packed.dispose(); t.keys.scales.dispose(); t.keys.biases.dispose();
780
+ t.values.packed.dispose(); t.values.scales.dispose(); t.values.biases.dispose();
781
+ };
782
+ const prevC = prev?.[layer];
783
+ if (prevC instanceof BatchedRotatingQuantCache) {
784
+ const [k0, v0] = prevC.temporalView(); // [B,H,valid,*] triples, temporal
785
+ const valid = k0.packed.shape[2]!;
786
+ for (let b = 0; b < B; b++) {
787
+ const pad = Math.max(0, prevC.leftPad[b]!);
788
+ const cutRow = (t: typeof k0): typeof k0 => ({
789
+ packed: t.packed.slice([b, 0, pad, 0], [b + 1, t.packed.shape[1]!, valid, t.packed.shape[3]!]),
790
+ scales: t.scales.slice([b, 0, pad, 0], [b + 1, t.scales.shape[1]!, valid, t.scales.shape[3]!]),
791
+ biases: t.biases.slice([b, 0, pad, 0], [b + 1, t.biases.shape[1]!, valid, t.biases.shape[3]!]),
792
+ });
793
+ rows.push({ keys: cutRow(k0), values: cutRow(v0) });
794
+ offsets.push(prevC.offsetArr[b]!);
795
+ }
796
+ for (const t of [k0, v0]) { t.packed.dispose(); t.scales.dispose(); t.biases.dispose(); }
797
+ } else if (prevC instanceof RotatingQuantizedKVCache) {
798
+ // Adopted lone row (Phase 3.2 adopt): its chronological triples
799
+ // are the merge's first row, pad 0 by definition.
800
+ const [k0, v0] = prevC.temporalView();
801
+ rows.push({ keys: k0, values: v0 });
802
+ offsets.push(prevC.offset);
803
+ }
804
+ rows.push({ keys: qk, values: qv });
805
+ offsets.push(qSolo.offset);
806
+ newInners.push(BatchedRotatingQuantCache.merge(
807
+ rows, offsets, this.#rotMaxSize[layer]!, qSolo.groupSize, qSolo.bits,
808
+ ));
809
+ for (const r of rows) dispose3(r);
810
+ continue;
811
+ }
318
812
  const [sk, sv] = soloC.temporalView();
319
813
  const newRow: Row1 = { keys: sk, values: sv };
320
814
  if (this.#kinds[layer] === "rot") {
321
815
  const rows: Row1[] = [];
322
816
  const offsets: number[] = [];
323
- const prevRot = prev?.[layer] as BatchedRotatingCache | undefined;
817
+ const prevC = prev?.[layer];
818
+ if (prevC instanceof RotatingKVCache) {
819
+ // Adopted lone row (Phase 3.2): a plain serial rotating cache —
820
+ // its chronological view is the merge's first row, same as a
821
+ // fresh solo (pad 0 by definition).
822
+ const [k0, v0] = prevC.temporalView();
823
+ rows.push({ keys: k0, values: v0 });
824
+ offsets.push(prevC.offset);
825
+ }
826
+ const prevRot = prevC instanceof BatchedRotatingCache ? prevC : undefined;
324
827
  if (prevRot) {
325
828
  const [k0, v0] = prevRot.temporalView(); // [B,H,valid,D]
326
829
  const [, H, valid, D] = k0.shape as [number, number, number, number];
@@ -340,32 +843,58 @@ export class BatchScheduler {
340
843
  newInners.push(BatchedRotatingCache.merge(rows, offsets, this.#rotMaxSize[layer]!));
341
844
  for (const r of rows) { r.keys.dispose(); r.values.dispose(); }
342
845
  } else {
343
- const rows: Row1[] = [];
344
846
  const prevFull = prev?.[layer] as KVCache | undefined;
345
- if (prevFull) {
847
+ if (prevFull && process.env.MLX_BUN_BATCH_EXTEND !== "0") {
848
+ // extend-join (mlx-lm BatchKVCache.extend semantics, P0): append
849
+ // the new right-justified row to the running buffer in ONE pad +
850
+ // ONE concat — no per-row extraction. Existing pads grow, never
851
+ // shrink (the re-merge below re-normalizes them instead; both are
852
+ // masked, both token-exact vs their mlx-lm protocol twin).
853
+ // MLX_BUN_BATCH_EXTEND=0 = the O(B·S) re-merge, kill switch/A-B.
346
854
  const [k0, v0] = prevFull.temporalView(); // [B,H,off,D]
347
- const [, H, off, D] = k0.shape as [number, number, number, number];
348
- const vD = v0.shape[3]!;
349
- for (let b = 0; b < B; b++) {
350
- const pad = prevPad[b]!;
351
- rows.push({
352
- keys: k0.slice([b, 0, pad, 0], [b + 1, H, off, D]),
353
- values: v0.slice([b, 0, pad, 0], [b + 1, H, off, vD]),
354
- });
355
- }
855
+ const ext = extendKVRows(k0, v0, prevPad, newRow);
356
856
  k0.dispose(); v0.dispose();
857
+ newFullPad = ext.leftPad;
858
+ const c = new KVCache();
859
+ c.restoreState(ext.keys, ext.values, ext.width);
860
+ newInners.push(c);
861
+ newRow.keys.dispose(); newRow.values.dispose();
862
+ } else {
863
+ const rows: Row1[] = [];
864
+ if (prevFull) {
865
+ const [k0, v0] = prevFull.temporalView(); // [B,H,off,D]
866
+ const [, H, off, D] = k0.shape as [number, number, number, number];
867
+ const vD = v0.shape[3]!;
868
+ for (let b = 0; b < B; b++) {
869
+ const pad = prevPad[b]!;
870
+ rows.push({
871
+ keys: k0.slice([b, 0, pad, 0], [b + 1, H, off, D]),
872
+ values: v0.slice([b, 0, pad, 0], [b + 1, H, off, vD]),
873
+ });
874
+ }
875
+ k0.dispose(); v0.dispose();
876
+ }
877
+ rows.push(newRow);
878
+ const merged = mergeKVRows(rows);
879
+ newFullPad = merged.leftPad;
880
+ const c = new KVCache();
881
+ c.restoreState(merged.keys, merged.values, merged.width);
882
+ newInners.push(c);
883
+ for (const r of rows) { r.keys.dispose(); r.values.dispose(); }
357
884
  }
358
- rows.push(newRow);
359
- const merged = mergeKVRows(rows);
360
- newFullPad = merged.leftPad;
361
- const c = new KVCache();
362
- c.restoreState(merged.keys, merged.values, merged.width);
363
- newInners.push(c);
364
- for (const r of rows) { r.keys.dispose(); r.values.dispose(); }
365
885
  }
366
886
  }
367
887
  if (prev) for (const c of prev) c.dispose();
888
+ // An adopted row's entry-backed arrays are gone after the prev dispose;
889
+ // run its retain now. The joiner's likewise after its solo dispose.
890
+ this.#adoptedRetain?.();
891
+ this.#adoptedRetain = null;
368
892
  for (const c of p.solo) c.dispose();
893
+ p.retain?.();
894
+ // Every row in a REAL merge has its KV interleaved in batched buffers —
895
+ // no longer prompt-cache put() candidates.
896
+ for (const r of this.#running) r.merged = true;
897
+ p.row.merged = true;
369
898
  this.#inners = newInners;
370
899
  this.#fullLeftPad = newFullPad;
371
900
  this.#running.push(p.row);
@@ -381,6 +910,11 @@ export class BatchScheduler {
381
910
  * step; filter drops the row (mlx-lm behaves identically). Length-finished
382
911
  * rows are known in advance and are NOT sampled (placeholder slot). */
383
912
  async #step(): Promise<void> {
913
+ if (STEP_TRACE) {
914
+ const now = performance.now();
915
+ if (STEP_T.lastEnd) STEP_T.gap += now - STEP_T.lastEnd;
916
+ STEP_T.t0 = now;
917
+ }
384
918
  const rows = this.#running;
385
919
  const B = rows.length;
386
920
  const inners = this.#inners!;
@@ -388,55 +922,166 @@ export class BatchScheduler {
388
922
  // A row is live if it still needs tokens sampled; a row whose pending
389
923
  // unread token is its last (sampled == maxTokens) only awaits emission.
390
924
  const anyLive = rows.some((r) => r.sampled < r.req.maxTokens);
925
+ // Grammar (B1): if any row has a LIVE grammar controller (not terminated,
926
+ // still sampling), take the read-before-build shape — the matcher's
927
+ // acceptToken needs the token id as a JS number, so the previous step's
928
+ // [B] token array is read back NOW (before building the next graph),
929
+ // accept()ed per row (firing async fills that overlap the forward), then
930
+ // ready() is awaited before this step's sample. This is the serial loop's
931
+ // grammar resolution transplanted to the batch. Batches with NO live
932
+ // grammar row keep the pipelined path byte-identical (zero cost when
933
+ // unused). The trade: while a grammar row is live the batch runs
934
+ // effectively NO_PIPELINE (readback no longer overlaps GPU compute) —
935
+ // bounded by the readback (~0.1 ms) + fills (0.004–0.19 ms/row, overlapped
936
+ // with the graph build). Serial grammar pays the identical trade today.
937
+ const hasLiveGrammar = anyLive && rows.some(
938
+ (r) => r.req.grammar && !r.req.grammar.isTerminated,
939
+ );
940
+ if (hasLiveGrammar) return this.#stepGrammar();
941
+
391
942
  let nextToks: MlxArray | null = null;
943
+ let nextReal: boolean[] | null = null;
392
944
  if (anyLive) {
945
+ // Fed-token accounting (prompt-cache put): a COLD step feeds each
946
+ // row's `current` (values known now, always a real emitted token); a
947
+ // pipelined step feeds the pending array, whose values are pushed at
948
+ // the read below gated on the per-slot real flags.
949
+ if (!this.#pendingToks) for (const r of rows) r.fed.push(r.current);
393
950
  // 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
- );
951
+ // BatchedRotatingCache directly; ssm layers are already [B,...] state
952
+ // with no padding (no mask, no per-row RoPE — pass through); full
953
+ // layers get a fresh BatchedDecodeMaskCache wrapper — UNLESS no row
954
+ // has left padding. UNPADDED FAST PATH (unified-engine plan Phase 2,
955
+ // the B=1 case above all): with every leftPad 0 the wrapper's two
956
+ // jobs vanish — the padding mask (KVCache.makeMask(1) is the empty
957
+ // mask, exactly the serial loop's) and the per-row rope positions
958
+ // (every row sits at the shared scalar offset). The bare cache then
959
+ // dispatches the SAME per-step graph serial builds; the wrapper
960
+ // otherwise costs a host mask build + ~8 device nodes PER FULL LAYER
961
+ // PER TOKEN (the Phase-0 constant ~4–6 ms/step host tax at B=1).
962
+ const unpadded = this.#fullLeftPad.every((p) => p === 0);
963
+ // Compiled decode at B=1 (Phase 3.2): after adopt-don't-copy, a lone
964
+ // row's caches are SERIAL-CLASS, so the serial engine's compiled step
965
+ // replays the same C++ graph here — closing the batch lane's last
966
+ // B=1 host-tax gap (e4b's ~7%). Guards: gemma dense (constructor),
967
+ // serial-class caches (supports — a merged batch's BatchedRotating
968
+ // layers fail it), unpadded, and a uint32 pipeline register (the
969
+ // trace signature; per-row int32 samplers take the graph path).
970
+ // Grammar batches use #stepGrammar and stay on the graph path.
971
+ let lg: MlxArray | null = null;
972
+ let evalWith: MlxArray[] = [];
973
+ if (
974
+ this.#compiled && B === 1 && unpadded &&
975
+ (!this.#pendingToks || this.#pendingToks.dtype === Dtype.uint32) &&
976
+ // A filtered-to-one BATCHED rot-quant cache subclasses the serial
977
+ // class (so supports() passes) but carries batched ring state —
978
+ // exclude it; only truly-adopted serial caches replay compiled.
979
+ !inners.some((c) => c instanceof BatchedRotatingQuantCache) &&
980
+ CompiledDecode.supports(inners as Cache[])
981
+ ) {
982
+ let cur = this.#pendingToks;
983
+ let owned = false;
984
+ if (!cur) {
985
+ const i = ops.fromInt32([rows[0]!.current], [1]);
986
+ cur = i.astype(Dtype.uint32);
987
+ i.dispose();
988
+ owned = true;
989
+ }
990
+ try {
991
+ const r = this.#compiled.step(cur, inners as Cache[]);
992
+ lg = r.logits; // [1,1,V]
993
+ evalWith = r.evalWith;
994
+ } catch (e) {
995
+ // A failed step is transactional (see generate.ts) — safe to
996
+ // re-forward the same token on the graph path below.
997
+ this.#compiled = null;
998
+ console.warn(`batch lane: compiled decode disabled: ${e}`);
999
+ } finally {
1000
+ if (owned) cur.dispose();
1001
+ }
1002
+ }
1003
+ let fwd: Cache[] | null = null;
399
1004
  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();
1005
+ if (!lg) {
1006
+ fwd = inners.map((c) =>
1007
+ c instanceof BatchedRotatingCache || c instanceof BatchedRotatingQuantCache ||
1008
+ c instanceof SSMCache || unpadded
1009
+ ? c
1010
+ : c instanceof QuantizedKVCache
1011
+ ? new BatchedQuantDecodeMaskCache(c, B, this.#fullLeftPad)
1012
+ : new BatchedDecodeMaskCache(c, B, this.#fullLeftPad, null),
1013
+ );
1014
+ const ids = this.#pendingToks
1015
+ ? ops.reshape(this.#pendingToks, [B, 1]) // feed the unread tokens
1016
+ : ops.fromInt32(rows.map((r) => r.current), [B, 1]); // pipeline cold
1017
+ const h = this.model.forwardHidden(ids, fwd);
1018
+ ids.dispose();
1019
+ lg = this.model.logitsFromHidden(h); // [B,1,V]
1020
+ h.dispose();
1021
+ }
407
1022
  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;
1023
+ // Vectorized homogeneous sampling (batching-perf-path P0): when every
1024
+ // LIVE row is plain greedy, one log-softmax+argmax over [B,V] replaces
1025
+ // B slice/sample/concat graphs. Per-row identical math (row-independent
1026
+ // ops, same per-row shapes — argmax over log-softmax mirrors the
1027
+ // closure's toLogprobs→argmax exactly, tie behavior included). Doomed
1028
+ // rows get a real argmax instead of the placeholder 0 — equally
1029
+ // harmless (the slot is filtered before it is ever emitted; its only
1030
+ // use is one KV write on the row's own, about-to-evict row).
1031
+ const vecOk =
1032
+ process.env.MLX_BUN_BATCH_VEC_SAMPLE !== "0" &&
1033
+ rows.every((r) => r.sampled >= r.req.maxTokens || r.req.plainGreedy);
1034
+ // Doomed slots hold placeholders (vec path: a real argmax value,
1035
+ // equally not part of the row's stream) — flag them so the fed
1036
+ // accounting at the read stays per-row exact.
1037
+ nextReal = rows.map((r) => r.sampled < r.req.maxTokens);
1038
+ if (vecOk) {
1039
+ const flat = ops.reshape(lg, [B, V]);
1040
+ lg.dispose();
1041
+ const lp = toLogprobs(flat);
1042
+ flat.dispose();
1043
+ nextToks = ops.argmaxAxis(lp, -1); // [B]
1044
+ lp.dispose();
1045
+ for (const row of rows) if (row.sampled < row.req.maxTokens) row.sampled++;
1046
+ } else {
1047
+ const sampled: MlxArray[] = [];
1048
+ for (let b = 0; b < B; b++) {
1049
+ const row = rows[b]!;
1050
+ if (row.sampled >= row.req.maxTokens) {
1051
+ // Length-doomed row: evicted right after the emission below ever
1052
+ // uses this slot as input — placeholder keeps the [B] alignment.
1053
+ sampled.push(ops.fromInt32([0], [1]));
1054
+ continue;
1055
+ }
1056
+ const rl = lg.slice([b, 0, 0], [b + 1, 1, V]);
1057
+ const rl2 = ops.reshape(rl, [1, V]);
1058
+ rl.dispose();
1059
+ sampled.push(row.req.sample(rl2, row.sampled));
1060
+ row.sampled++;
1061
+ rl2.dispose();
416
1062
  }
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();
1063
+ lg.dispose();
1064
+ nextToks = ops.concatAxis(sampled, 0); // [B]
1065
+ for (const t of sampled) t.dispose();
423
1066
  }
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
1067
+ // dispatch; read NEXT iteration. evalWith: the compiled step's
1068
+ // cache-update nodes must ride the same async_eval (generate.ts).
1069
+ ops.asyncEvalAll([nextToks, ...evalWith]);
428
1070
  } finally {
429
1071
  // Free the step's RoPE arrays; do NOT dispose (full wrappers would free
430
1072
  // their persistent inner; rot caches persist across steps).
431
- for (const c of fwd) (c as { releaseRopeArr?: () => void }).releaseRopeArr?.();
1073
+ if (fwd) for (const c of fwd) (c as { releaseRopeArr?: () => void }).releaseRopeArr?.();
432
1074
  }
433
1075
  }
434
1076
  this.#steps++;
435
1077
  if (this.#steps % 256 === 0) clearCache(); // serial's cadence, not per-step
1078
+ if (STEP_TRACE) STEP_T.build += performance.now() - STEP_T.t0;
436
1079
 
437
1080
  // Read + emit the PREVIOUS step's tokens while the new step computes.
438
1081
  const prev = this.#pendingToks;
1082
+ const prevReal = this.#pendingReal;
439
1083
  this.#pendingToks = nextToks;
1084
+ this.#pendingReal = nextReal;
440
1085
  // Kill switch / A-B lever (house style, cf. MLX_BUN_COMPILED_DECODE=0):
441
1086
  // MLX_BUN_BATCH_NO_PIPELINE=1 reads THIS step's tokens synchronously —
442
1087
  // set from process start `prev` is always null, so the flush below IS the
@@ -446,10 +1091,23 @@ export class BatchScheduler {
446
1091
  return;
447
1092
  }
448
1093
  if (prev) {
449
- const toks = [...prev.toFloat32()].map((x) => Math.round(x));
1094
+ const tRead = STEP_TRACE ? performance.now() : 0;
1095
+ const toks = prev.toIntTokens();
450
1096
  prev.dispose();
1097
+ // These values were the step's forward input (fed) iff a forward ran.
1098
+ // Placeholder slots are excluded from `fed` — and taint their row if
1099
+ // they ever fed (see Row.fedTainted; structurally unreachable today).
1100
+ if (anyLive)
1101
+ for (let b = 0; b < B; b++) {
1102
+ if (!prevReal || prevReal[b]) rows[b]!.fed.push(toks[b]!);
1103
+ else rows[b]!.fedTainted = true;
1104
+ }
1105
+ if (STEP_TRACE) STEP_T.read += performance.now() - tRead;
1106
+ const tEmit = STEP_TRACE ? performance.now() : 0;
451
1107
  await this.#emitRows(toks); // also filters #pendingToks on eviction
1108
+ if (STEP_TRACE) { STEP_T.emit += performance.now() - tEmit; STEP_T.n++; }
452
1109
  }
1110
+ if (STEP_TRACE) STEP_T.lastEnd = performance.now();
453
1111
  }
454
1112
 
455
1113
  /** Read out the pipeline register (if any): emit its tokens and evict
@@ -458,17 +1116,149 @@ export class BatchScheduler {
458
1116
  const prev = this.#pendingToks;
459
1117
  if (!prev) return;
460
1118
  this.#pendingToks = null;
461
- const toks = [...prev.toFloat32()].map((x) => Math.round(x));
1119
+ this.#pendingReal = null; // flushed values never fed — nothing to account
1120
+ const toks = prev.toIntTokens();
462
1121
  prev.dispose();
1122
+ // Grammar rows: the flushed tokens are EMITTED below, so their matchers
1123
+ // must advance here — #stepGrammar's accept only covers tokens it reads
1124
+ // from a live pending array, and after this flush it cold-starts (no
1125
+ // accept). Skipping this left the matcher one token behind its stream on
1126
+ // every mid-decode join → one-step-stale masks → invalid output (found
1127
+ // by the feature-matrix conformance gate, 2026-07-03; the fill fired
1128
+ // here is awaited by the next #stepGrammar's ready()).
1129
+ for (let b = 0; b < this.#running.length && b < toks.length; b++) {
1130
+ const g = this.#running[b]!.req.grammar;
1131
+ if (g && !g.isTerminated) g.accept(toks[b]!);
1132
+ }
463
1133
  await this.#emitRows(toks);
464
1134
  }
465
1135
 
1136
+ /** The read-before-build decode step for batches with ≥1 live grammar row
1137
+ * (B1). Mirrors the serial loop's grammar resolution: read the previous
1138
+ * step's [B] tokens NOW (acceptToken needs JS numbers), accept() per row
1139
+ * (fires async fills), build the forward graph (fills overlap), await
1140
+ * ready(), sample per live row (the closure applies applyMask), then emit
1141
+ * the values read in step 1 — no second readback. Terminated grammar rows
1142
+ * keep their [B] slot through the forward (placeholder, not sampled) and
1143
+ * finish("stop") in #emitRows. On a cold start (first step after prefill)
1144
+ * there is no pending array to read; the prefill already accepted token 0
1145
+ * and fired the fill, so we just await ready() + sample. */
1146
+ async #stepGrammar(): Promise<void> {
1147
+ const rows = this.#running;
1148
+ const B = rows.length;
1149
+ const inners = this.#inners!;
1150
+
1151
+ // (1) Read the pending [B] token array (host copy for accept). The device
1152
+ // array is kept for the forward input below; disposed after sampling.
1153
+ // Cold start: pendingToks null → prevVals empty (prefill handled tok0).
1154
+ const prev = this.#pendingToks;
1155
+ const prevVals: number[] =
1156
+ prev ? prev.toIntTokens() : [];
1157
+ if (process.env.MLX_BUN_GRAMMAR_DEBUG === "1")
1158
+ console.log(`[sg] B=${B} prevVals=${JSON.stringify(prevVals)} current=${JSON.stringify(rows.map((r) => r.current))} sampled=${JSON.stringify(rows.map((r) => r.sampled))}`);
1159
+
1160
+ // (2) accept() per live grammar row — fires that row's async bitmask fill.
1161
+ for (let b = 0; b < B && prevVals.length; b++) {
1162
+ const g = rows[b]!.req.grammar;
1163
+ if (g && !g.isTerminated) g.accept(prevVals[b]!);
1164
+ }
1165
+
1166
+ let nextToks: MlxArray | null = null;
1167
+ let nextReal: boolean[] | null = null;
1168
+ const anyLive = rows.some((r) => r.sampled < r.req.maxTokens);
1169
+ if (anyLive) {
1170
+ // Fed-token accounting (prompt-cache put) — mirror of #step's read:
1171
+ // real slots push, a placeholder that fed taints its row.
1172
+ const prevReal = this.#pendingReal;
1173
+ if (prev)
1174
+ for (let b = 0; b < B; b++) {
1175
+ if (!prevReal || prevReal[b]) rows[b]!.fed.push(prevVals[b]!);
1176
+ else rows[b]!.fedTainted = true;
1177
+ }
1178
+ else for (const r of rows) r.fed.push(r.current);
1179
+ // Unpadded fast path — same rule as #step (bare caches == serial graph).
1180
+ const unpadded = this.#fullLeftPad.every((p) => p === 0);
1181
+ const fwd: Cache[] = inners.map((c) =>
1182
+ c instanceof BatchedRotatingCache || c instanceof BatchedRotatingQuantCache ||
1183
+ c instanceof SSMCache || unpadded
1184
+ ? c
1185
+ : c instanceof QuantizedKVCache
1186
+ ? new BatchedQuantDecodeMaskCache(c, B, this.#fullLeftPad)
1187
+ : new BatchedDecodeMaskCache(c, B, this.#fullLeftPad, null),
1188
+ );
1189
+ try {
1190
+ // (3) Build the forward graph (host-side; the fills overlap it).
1191
+ const ids = prev
1192
+ ? ops.reshape(prev, [B, 1]) // feed the unread tokens (device array)
1193
+ : ops.fromInt32(rows.map((r) => r.current), [B, 1]); // pipeline cold
1194
+ const h = this.model.forwardHidden(ids, fwd);
1195
+ ids.dispose();
1196
+ const lg = this.model.logitsFromHidden(h); // [B,1,V]
1197
+ h.dispose();
1198
+ const V = lg.shape[2]!;
1199
+
1200
+ // (4) await ready() on every live grammar row — the fills fired in (2)
1201
+ // overlapped the graph build above. Usually already resolved.
1202
+ const liveGrammar = rows.filter(
1203
+ (r) => r.req.grammar && !r.req.grammar.isTerminated,
1204
+ );
1205
+ if (liveGrammar.length)
1206
+ await Promise.all(liveGrammar.map((r) => r.req.grammar!.ready()));
1207
+
1208
+ // (5) Sample per live row (the closure applies applyMask after the
1209
+ // logits processors). Terminated grammar rows + length-doomed rows
1210
+ // take a placeholder (one harmless KV write, then evicted).
1211
+ const sampled: MlxArray[] = [];
1212
+ nextReal = rows.map(
1213
+ (r) => r.sampled < r.req.maxTokens && !r.req.grammar?.isTerminated,
1214
+ );
1215
+ for (let b = 0; b < B; b++) {
1216
+ const row = rows[b]!;
1217
+ if (
1218
+ row.sampled >= row.req.maxTokens ||
1219
+ row.req.grammar?.isTerminated
1220
+ ) {
1221
+ sampled.push(ops.fromInt32([0], [1]));
1222
+ continue;
1223
+ }
1224
+ const rl = lg.slice([b, 0, 0], [b + 1, 1, V]);
1225
+ const rl2 = ops.reshape(rl, [1, V]);
1226
+ rl.dispose();
1227
+ sampled.push(row.req.sample(rl2, row.sampled));
1228
+ row.sampled++;
1229
+ rl2.dispose();
1230
+ }
1231
+ lg.dispose();
1232
+ nextToks = ops.concatAxis(sampled, 0); // [B]
1233
+ for (const t of sampled) t.dispose();
1234
+ ops.asyncEvalAll([nextToks]);
1235
+ } finally {
1236
+ for (const c of fwd)
1237
+ (c as { releaseRopeArr?: () => void }).releaseRopeArr?.();
1238
+ }
1239
+ }
1240
+ this.#steps++;
1241
+ if (this.#steps % 256 === 0) clearCache();
1242
+
1243
+ // The device array `prev` has now fed the forward + been read for accept;
1244
+ // dispose it and install the new pending array. On cold start prev is null.
1245
+ prev?.dispose();
1246
+ this.#pendingToks = nextToks;
1247
+ this.#pendingReal = nextReal;
1248
+
1249
+ // (6) Emit the values read in (1) — no second readback. Terminated grammar
1250
+ // rows finish("stop") via #emit's isTerminated check; the filter evicts.
1251
+ // Cold start: prevVals empty (prefill emitted tok0) → nothing to emit.
1252
+ if (prevVals.length) await this.#emitRows(prevVals);
1253
+ }
1254
+
466
1255
  /** Emit one read-back token per running row; evict finished rows. A row's
467
1256
  * onToken throwing rejects THAT row and evicts it — siblings continue. */
468
1257
  async #emitRows(toks: number[]): Promise<void> {
469
1258
  const rows = this.#running;
470
1259
  const B = rows.length;
471
1260
  const keep: number[] = [];
1261
+ const done: { b: number; row: Row }[] = []; // clean finishes (stop/length)
472
1262
  for (let b = 0; b < B; b++) {
473
1263
  const row = rows[b]!;
474
1264
  row.generated++;
@@ -477,12 +1267,27 @@ export class BatchScheduler {
477
1267
  disp = await this.#emit(row, toks[b]!);
478
1268
  } catch (e) {
479
1269
  row.reject(e); // containment: this row only (mlx-lm `remove`)
1270
+ row.merged = true; // poison: a rejected row is never put() back
1271
+ row.fedTainted = true;
480
1272
  continue; // not kept → evicted by the filter below
481
1273
  }
482
1274
  if (disp === "continue") keep.push(b);
483
- else this.#finish(row, disp);
1275
+ else {
1276
+ done.push({ b, row });
1277
+ this.#finish(row, disp);
1278
+ }
1279
+ }
1280
+ if (keep.length < B) {
1281
+ // Per-row prompt-cache extraction (mlx-lm server.py:864-880 →
1282
+ // BatchGenerator.extract_cache): a MERGED row's KV lives interleaved
1283
+ // in the batched inners, so it is pulled out into fresh serial caches
1284
+ // BEFORE filter() mutates them. Never-merged lone rows skip this —
1285
+ // #applyFilter's keep=[] path put()s their adopted caches zero-copy.
1286
+ // Rejected rows aren't in `done`; the whole-batch error path calls
1287
+ // #applyFilter(dropOnly) directly and never reaches here.
1288
+ for (const { b, row } of done) this.#extractAndPut(b, row);
1289
+ this.#applyFilter(keep);
484
1290
  }
485
- if (keep.length < B) this.#applyFilter(keep);
486
1291
  }
487
1292
 
488
1293
  /** Account one sampled token for a row. Mirrors generate(): EOS terminates
@@ -492,6 +1297,10 @@ export class BatchScheduler {
492
1297
  if (row.req.eosTokenIds.includes(token)) return "stop";
493
1298
  const cont = await row.req.onToken(token);
494
1299
  if (cont === false) return "stop";
1300
+ // Grammar termination: the matcher is satisfied (e.g. closing `}` accepted).
1301
+ // The final token has been delivered via onToken above; halt with "stop" so
1302
+ // the row finishes + evicts rather than sampling into an all--inf mask.
1303
+ if (row.req.grammar?.isTerminated) return "stop";
495
1304
  if (row.generated >= row.req.maxTokens) return "length";
496
1305
  row.current = token;
497
1306
  return "continue";
@@ -501,22 +1310,119 @@ export class BatchScheduler {
501
1310
  row.resolve({
502
1311
  promptTokens: row.promptTokens,
503
1312
  generatedTokens: row.generated,
504
- cachedTokens: 0,
1313
+ cachedTokens: row.cachedTokens,
505
1314
  finishReason: reason,
506
1315
  });
507
1316
  }
508
1317
 
1318
+ /** Finish-time disposition of serial-class caches covering exactly
1319
+ * `tokens`: put() into the prompt cache when the hook is present and the
1320
+ * offset lines up (defensive — a mismatch means the accounting is wrong
1321
+ * and the entry would corrupt future hits), else dispose. `retain` rides
1322
+ * along per the PromptCacheEntry contract (runs after dispose). */
1323
+ #putOrDispose(caches: Cache[], tokens: number[], retain?: () => void): void {
1324
+ if (this.#promptCache) {
1325
+ const withOff = caches.find(
1326
+ (c) => typeof (c as { offset?: unknown }).offset === "number",
1327
+ ) as { offset: number } | undefined;
1328
+ if (withOff && withOff.offset === tokens.length) {
1329
+ this.#promptCache.put(tokens, caches, "", retain);
1330
+ return;
1331
+ }
1332
+ }
1333
+ for (const c of caches) c.dispose();
1334
+ retain?.();
1335
+ }
1336
+
1337
+ /** Extract a finishing MERGED row's KV into fresh serial caches and put()
1338
+ * them keyed by [promptIds + fed] — the batch-lane mirror of mlx-lm
1339
+ * server.py:872 (extract_cache → prompt_cache.insert_cache). Gates:
1340
+ * promptTokens >= 256 (the boundary-snapshot substantiality gate,
1341
+ * server.ts) and an exact coverage key (!fedTainted). Refusal
1342
+ * (#extractRowCaches null) just disposes-by-omission — the row's KV dies
1343
+ * with the filter, exactly the pre-extraction behavior. */
1344
+ #extractAndPut(b: number, row: Row): void {
1345
+ if (!this.#promptCache || !row.merged || row.fedTainted) return;
1346
+ if (row.promptTokens < 256) return;
1347
+ const tokens = [...row.req.promptIds, ...row.fed];
1348
+ const caches = this.#extractRowCaches(b, tokens.length);
1349
+ if (!caches) return;
1350
+ // Materialize the owned copies without stalling the in-flight step (the
1351
+ // slices depend on this step's KV writes): async — the batched source
1352
+ // buffers free once the copies land, instead of being pinned by a lazy
1353
+ // graph inside an idle cache entry.
1354
+ ops.asyncEvalAll(caches.flatMap((c) => c.state()));
1355
+ this.#putOrDispose(caches, tokens);
1356
+ }
1357
+
1358
+ /** Row `b` of every layer as OWNED serial-class caches, or null when a
1359
+ * layer kind can't be extracted (then the caller drops the row's KV as
1360
+ * before). Bit-exactness: merge/extend/filter/decode are byte-preserving
1361
+ * per row (tests/batched-decode-parity, tests/batched-rotating,
1362
+ * tests/batched-rotating-quant) and each extract is a pure slice+copy of
1363
+ * those bytes (tests/batched-extract), so an extracted row's bytes ==
1364
+ * the solo run's. */
1365
+ #extractRowCaches(b: number, expectTokens: number): Cache[] | null {
1366
+ const out: Cache[] = [];
1367
+ for (const inner of this.#inners!) {
1368
+ let c: Cache | null;
1369
+ if (inner instanceof BatchedRotatingQuantCache) c = inner.extractRow(b);
1370
+ else if (inner instanceof BatchedRotatingCache) c = inner.extractRow(b);
1371
+ else if (inner instanceof SSMCache)
1372
+ // Coverage gate: recurrent state is UNTRIMMABLE, so an entry is only
1373
+ // valid when the row's own advance count equals the [promptIds+fed]
1374
+ // key EXACTLY (a mismatched entry would silently corrupt every future
1375
+ // exact-hit). Defensive — a miss here means the fed accounting broke.
1376
+ c = inner.conv && inner.rowOffset(b) === expectTokens ? inner.extractRow(b) : null;
1377
+ else if (
1378
+ inner instanceof RotatingQuantizedKVCache || // adopted serial state never
1379
+ inner instanceof RotatingKVCache // coexists with a merged row (defensive)
1380
+ )
1381
+ c = null;
1382
+ else if (inner instanceof QuantizedKVCache)
1383
+ c = inner.keys ? extractQuantRow(inner, this.#fullLeftPad[b]!, b) : null;
1384
+ else c = inner.keys ? extractKVRow(inner, this.#fullLeftPad[b]!, b) : null;
1385
+ if (!c) {
1386
+ for (const d of out) d.dispose();
1387
+ return null;
1388
+ }
1389
+ out.push(c);
1390
+ }
1391
+ return out;
1392
+ }
1393
+
509
1394
  /** Evict rows not in `keep` (sorted ascending) from the batched KV and the
510
1395
  * pipeline register. */
511
- #applyFilter(keep: number[]): void {
1396
+ #applyFilter(keep: number[], dropOnly = false): void {
512
1397
  const inners = this.#inners!;
513
1398
  if (keep.length === 0) {
514
- for (const c of inners) c.dispose();
1399
+ // Prompt-cache put (Phase 3.2): a lone NEVER-MERGED row's inners are
1400
+ // its adopted serial-class caches, covering exactly prompt+fed — hand
1401
+ // them back to the cache instead of disposing. dropOnly (the batch-
1402
+ // drop error path) and poisoned rows dispose as before; MERGED rows'
1403
+ // KV was already extracted per row in #emitRows (owned copies), so
1404
+ // disposing the batched inners here is safe either way.
1405
+ const solo =
1406
+ !dropOnly && this.#running.length === 1 && !this.#running[0]!.merged
1407
+ ? this.#running[0]!
1408
+ : null;
1409
+ if (solo) {
1410
+ this.#putOrDispose(
1411
+ inners as Cache[],
1412
+ [...solo.req.promptIds, ...solo.fed],
1413
+ this.#adoptedRetain ?? undefined,
1414
+ );
1415
+ } else {
1416
+ for (const c of inners) c.dispose();
1417
+ this.#adoptedRetain?.();
1418
+ }
1419
+ this.#adoptedRetain = null;
515
1420
  this.#inners = null;
516
1421
  this.#fullLeftPad = [];
517
1422
  this.#running = [];
518
1423
  this.#pendingToks?.dispose();
519
1424
  this.#pendingToks = null;
1425
+ this.#pendingReal = null;
520
1426
  return;
521
1427
  }
522
1428
  const out: LayerInner[] = [];
@@ -524,6 +1430,24 @@ export class BatchScheduler {
524
1430
  if (inner instanceof BatchedRotatingCache) {
525
1431
  inner.filter(keep); // in-place (mutates + drops rows + reduces padding)
526
1432
  out.push(inner);
1433
+ } else if (inner instanceof BatchedRotatingQuantCache) {
1434
+ inner.filter(keep); // in-place B-axis take on all six components
1435
+ out.push(inner);
1436
+ } else if (inner instanceof RotatingQuantizedKVCache || inner instanceof RotatingKVCache) {
1437
+ // Adopted lone-row state exists only at B=1, where the only filter
1438
+ // is the keep=[] dispose-all handled above — unreachable.
1439
+ throw new Error("applyFilter: adopted serial rotating cache cannot be row-filtered");
1440
+ } else if (inner instanceof SSMCache) {
1441
+ inner.filter(keep); // in-place B-axis take on both state slots
1442
+ out.push(inner);
1443
+ } else if (inner instanceof QuantizedKVCache) {
1444
+ const [k0, v0] = inner.temporalView();
1445
+ const f = filterQuantRows(k0, v0, keep);
1446
+ for (const t of [k0, v0]) { t.packed.dispose(); t.scales.dispose(); t.biases.dispose(); }
1447
+ const c = new QuantizedKVCache(inner.groupSize, inner.bits);
1448
+ c.restoreState(f.keys, f.values, inner.offset);
1449
+ out.push(c);
1450
+ inner.dispose();
527
1451
  } else {
528
1452
  const [k0, v0] = inner.temporalView();
529
1453
  const f = filterKVRows(k0, v0, keep);
@@ -544,10 +1468,11 @@ export class BatchScheduler {
544
1468
  this.#pendingToks.dispose();
545
1469
  this.#pendingToks = next;
546
1470
  }
1471
+ if (this.#pendingReal) this.#pendingReal = keep.map((i) => this.#pendingReal![i]!);
547
1472
  }
548
1473
 
549
1474
  #readToken(t: MlxArray): number {
550
- const v = Math.round(t.toFloat32()[0]!);
1475
+ const v = t.toIntTokens()[0]!;
551
1476
  t.dispose();
552
1477
  return v;
553
1478
  }