mlx-bun 0.0.10 → 0.0.12

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 (216) hide show
  1. package/README.md +107 -32
  2. package/THIRD_PARTY_LICENSES.md +10 -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 +58 -35
  7. package/docs/design/batching-v2-plan.md +23 -9
  8. package/docs/design/bucketing-stage.md +2 -1
  9. package/docs/design/colibri-glm52-port.md +1479 -0
  10. package/docs/design/compat-cli-surface-design.md +4 -3
  11. package/docs/design/decode-speed-program.md +172 -0
  12. package/docs/design/docs-reorg-plan.md +1 -1
  13. package/docs/design/dspark-serving-program.md +371 -0
  14. package/docs/design/dspark-speculative-decoding.md +167 -5
  15. package/docs/design/faithful-l1-consolidation.md +310 -0
  16. package/docs/design/generic-model-support.md +8 -5
  17. package/docs/design/grammar-spec-batching-integration.md +301 -0
  18. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  19. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  20. package/docs/design/omlx-adoption-map.md +14 -6
  21. package/docs/design/orpo-training.md +34 -30
  22. package/docs/design/paged-kv-cache.md +214 -0
  23. package/docs/design/parallel-slots.md +82 -49
  24. package/docs/design/parity-tier-dag.md +43 -33
  25. package/docs/design/pre-colibri-stabilization.md +329 -0
  26. package/docs/design/repo-cleanup-plan.md +189 -0
  27. package/docs/design/runtime-isolation.md +19 -3
  28. package/docs/design/segmented-backward-training.md +3 -2
  29. package/docs/design/ssd-kv-cold-tier.md +130 -0
  30. package/docs/design/structured-output.md +484 -0
  31. package/docs/design/the-dreaming-master-plan.md +2 -3
  32. package/docs/design/turboquant-kv.md +235 -0
  33. package/docs/design/unified-engine-frontier-plan.md +774 -0
  34. package/docs/design/web-chat-beat-matrix.md +315 -0
  35. package/docs/design/web-chat-redesign.md +1112 -0
  36. package/docs/investigations/colibri-oracle-pin.md +1284 -0
  37. package/docs/investigations/dspark-handoff.md +182 -16
  38. package/docs/investigations/indexshare-performance-spike.md +357 -0
  39. package/docs/investigations/lab-build-journal.md +2 -2
  40. package/docs/investigations/openwiki-evaluation.md +249 -0
  41. package/docs/planning/IDEAS.md +14 -0
  42. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  43. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  44. package/docs/planning/chat-ui-vision.md +50 -0
  45. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  46. package/docs/planning/release-notes-v0.0.11.md +239 -0
  47. package/docs/planning/release-notes-v0.0.12.md +80 -0
  48. package/docs/reference/cli.md +63 -18
  49. package/docs/reference/distribution.md +37 -6
  50. package/docs/reference/embedding.md +40 -16
  51. package/docs/reference/features-matrix.md +99 -0
  52. package/docs/reference/library-api.md +69 -20
  53. package/docs/reference/memory.md +26 -6
  54. package/docs/reference/models.md +60 -3
  55. package/docs/reference/orpo-quickstart.md +3 -4
  56. package/docs/reference/server-api.md +717 -24
  57. package/docs/reference/server-config.md +265 -129
  58. package/docs/reference/training.md +24 -16
  59. package/package.json +5 -3
  60. package/src/anthropic.ts +67 -44
  61. package/src/audio/conformer.ts +647 -0
  62. package/src/audio/decode.ts +133 -0
  63. package/src/audio/features.ts +202 -0
  64. package/src/audio/transcode.ts +67 -0
  65. package/src/chat-template.ts +155 -10
  66. package/src/cli.ts +483 -113
  67. package/src/config.ts +116 -24
  68. package/src/diffusion/diffusion-generate.ts +14 -8
  69. package/src/download.ts +143 -12
  70. package/src/eval/README.md +7 -6
  71. package/src/eval/ifeval.ts +63 -120
  72. package/src/eval/kl.ts +59 -3
  73. package/src/eval/quality-db.ts +2 -2
  74. package/src/eval/runner.ts +17 -8
  75. package/src/eval/tasks/ifeval.ts +167 -31
  76. package/src/expert-io.ts +391 -0
  77. package/src/expert-offload.ts +15 -2
  78. package/src/expert-residency.ts +1134 -0
  79. package/src/expert-usage.ts +415 -0
  80. package/src/flags.ts +13 -0
  81. package/src/generate.ts +438 -35
  82. package/src/grammar.ts +585 -0
  83. package/src/hf-push.ts +1 -1
  84. package/src/hub-rest.ts +242 -0
  85. package/src/index.ts +14 -2
  86. package/src/jobs/runner.ts +37 -18
  87. package/src/kv-store.ts +586 -93
  88. package/src/lora.ts +85 -14
  89. package/src/md-modules.d.ts +42 -0
  90. package/src/media-fetch.ts +426 -0
  91. package/src/memory/rest.ts +245 -0
  92. package/src/memory/vault.ts +74 -0
  93. package/src/mlx/array.ts +149 -19
  94. package/src/mlx/autograd.ts +53 -16
  95. package/src/mlx/checkpoint.ts +46 -13
  96. package/src/mlx/custom-vjp.ts +83 -35
  97. package/src/mlx/ffi.ts +27 -3
  98. package/src/mlx/metal-kernel.ts +7 -4
  99. package/src/mlx/ops.ts +153 -23
  100. package/src/mlx/turboquant-ops.ts +831 -0
  101. package/src/mlx/turboquant-tables.ts +237 -0
  102. package/src/model/batched-mask.ts +62 -1
  103. package/src/model/batched-quant.ts +206 -0
  104. package/src/model/batched-rotating-quant.ts +346 -0
  105. package/src/model/batched-rotating.ts +46 -1
  106. package/src/model/compiled-decode.ts +4 -15
  107. package/src/model/factory.ts +103 -6
  108. package/src/model/gemma4-base.ts +395 -39
  109. package/src/model/gemma4.ts +122 -33
  110. package/src/model/generated/gemma4-12b.ts +9 -13
  111. package/src/model/generated/gemma4-26b.ts +10 -5
  112. package/src/model/generated/gemma4-e4b.ts +14 -9
  113. package/src/model/glm52-atlas.ts +511 -0
  114. package/src/model/glm52-cache.ts +706 -0
  115. package/src/model/glm52-config.ts +262 -0
  116. package/src/model/glm52-container.ts +305 -0
  117. package/src/model/glm52-coupling.ts +388 -0
  118. package/src/model/glm52-dsa.ts +602 -0
  119. package/src/model/glm52-expert-layout.ts +253 -0
  120. package/src/model/glm52-layout.ts +152 -0
  121. package/src/model/glm52-memory.ts +473 -0
  122. package/src/model/glm52-mla.ts +786 -0
  123. package/src/model/glm52-moe.ts +223 -0
  124. package/src/model/glm52-pilot.ts +537 -0
  125. package/src/model/glm52-reference.ts +445 -0
  126. package/src/model/glm52-residency.ts +557 -0
  127. package/src/model/glm52-resident-weights.ts +135 -0
  128. package/src/model/glm52-streamed-experts.ts +453 -0
  129. package/src/model/glm52-streamed-metal.ts +610 -0
  130. package/src/model/glm52-weights.ts +503 -0
  131. package/src/model/glm52.ts +1153 -0
  132. package/src/model/minicpm5.ts +58 -102
  133. package/src/model/paged-kv.ts +253 -0
  134. package/src/model/qwen3-delta.ts +76 -15
  135. package/src/model/qwen3-moe.ts +404 -0
  136. package/src/model/qwen3.ts +9 -8
  137. package/src/model/qwen3_5.ts +88 -23
  138. package/src/model/support.ts +19 -1
  139. package/src/model/universal/dense.ts +40 -5
  140. package/src/model/universal/rope.ts +36 -21
  141. package/src/native/expert_io.c +523 -0
  142. package/src/native-pack.ts +6 -4
  143. package/src/pi-web.ts +1299 -82
  144. package/src/preflight.ts +27 -8
  145. package/src/prompt-cache.ts +293 -33
  146. package/src/quantize/quantizer.ts +16 -1
  147. package/src/registry.ts +57 -2
  148. package/src/responses.ts +62 -49
  149. package/src/serve/batch-scheduler.ts +1050 -78
  150. package/src/serve/generation-gateway.ts +252 -24
  151. package/src/serve/isolate.ts +417 -0
  152. package/src/serve/lane-registry.ts +44 -0
  153. package/src/serve/session-search.ts +326 -0
  154. package/src/server.ts +1701 -175
  155. package/src/spec/assistant-source.ts +150 -0
  156. package/src/spec/deepspec-source.ts +143 -0
  157. package/src/spec/dflash-source.ts +124 -0
  158. package/src/spec/dspark/ab-stats.ts +136 -0
  159. package/src/spec/dspark/calibration.ts +112 -0
  160. package/src/spec/dspark/deepspec-module.ts +801 -0
  161. package/src/spec/dspark/generate-dflash.ts +72 -20
  162. package/src/spec/dspark/loader.ts +23 -0
  163. package/src/spec/dspark/module-dflash.ts +278 -36
  164. package/src/spec/dspark/quantize-drafter.ts +91 -0
  165. package/src/spec/generate.ts +59 -25
  166. package/src/spec/glm52-mtp-source.ts +392 -0
  167. package/src/spec/ngram-source.ts +132 -0
  168. package/src/spec/serve-loop.ts +617 -0
  169. package/src/spec/source.ts +118 -0
  170. package/src/spec/two-model.ts +171 -0
  171. package/src/ssd-cache.ts +111 -35
  172. package/src/tokenizer.ts +288 -2
  173. package/src/tool-approvals.ts +131 -0
  174. package/src/tool-call.ts +438 -22
  175. package/src/train/flash-cce.ts +2 -2
  176. package/src/train/job.ts +0 -12
  177. package/src/train/lora-params.ts +17 -6
  178. package/src/train/trainer.ts +60 -30
  179. package/src/vision/prompt.ts +217 -48
  180. package/src/web/app.html +1197 -1791
  181. package/src/web/app.js +5694 -0
  182. package/src/web/icon.svg +13 -0
  183. package/src/web/manifest.webmanifest +24 -0
  184. package/src/web/src/adapters-panel.ts +288 -0
  185. package/src/web/src/api.ts +57 -0
  186. package/src/web/src/assistant.ts +485 -0
  187. package/src/web/src/chat.ts +1050 -0
  188. package/src/web/src/composer.ts +959 -0
  189. package/src/web/src/dataset.ts +132 -0
  190. package/src/web/src/finetune.ts +267 -0
  191. package/src/web/src/hub.ts +428 -0
  192. package/src/web/src/main.ts +98 -0
  193. package/src/web/src/markdown.ts +0 -0
  194. package/src/web/src/memory-panel.ts +572 -0
  195. package/src/web/src/model-picker.ts +159 -0
  196. package/src/web/src/palette.ts +346 -0
  197. package/src/web/src/protocol.ts +62 -0
  198. package/src/web/src/quantize.ts +167 -0
  199. package/src/web/src/rag.ts +274 -0
  200. package/src/web/src/sessions.ts +469 -0
  201. package/src/web/src/shell.ts +804 -0
  202. package/src/web/src/status.ts +300 -0
  203. package/src/web/src/ui-catalog.ts +92 -0
  204. package/src/web/sw.js +71 -0
  205. package/src/web/vendor/README.md +61 -0
  206. package/src/web/vendor/hljs-LICENSE +29 -0
  207. package/src/web/vendor/hljs-theme.css +59 -0
  208. package/src/web/vendor/hljs.js +26 -0
  209. package/src/web-tools.ts +54 -2
  210. package/src/weights.ts +82 -19
  211. package/src/model/fused-decode-kernel.ts +0 -312
  212. package/src/model/fused-geglu-kernel.ts +0 -198
  213. package/src/model/fused-mlp-kernel.ts +0 -169
  214. package/src/model/fused-swiglu-kernel.ts +0 -240
  215. package/src/model/steel-linear-kernel.ts +0 -96
  216. package/src/status-page.html +0 -497
@@ -20,43 +20,70 @@
20
20
  // curve endpoints, adapter mount/unmount) goes through runExclusive() so there
21
21
  // is exactly ONE mutual-exclusion domain.
22
22
  //
23
- // v1 batchable gate (the rest → serial): batch>1 AND every model cache is a
24
- // dynamic-B-capable type (KVCache | RotatingKVCache — mirrors mlx-lm
25
- // server.py's all-caches-have-merge check; Qwen3.5's SSMCache has no
26
- // merge/filter/temporalView, so hybrid models route serial until the ArraysCache
27
- // port — batching-v2-plan item h) AND bf16 KV (no kv-quant — the batched
28
- // scheduler runs bf16; mixed-precision-KV batching is the novel-combo L2
29
- // follow-up) AND no vision AND no LoRA adapters AND no repetition penalty
30
- // (per-row logits processors are a later refinement) AND no user-fixed seed
23
+ // Batchable gate (the rest → serial): batch>1 AND every model cache is a
24
+ // dynamic-B-capable built-in type or implements BatchableCache (mirrors mlx-lm
25
+ // server.py's all-caches-have-merge check). GLM's capability owns compressed
26
+ // MLA/DSA merge/extract/filter and byte projection; Qwen3.5's SSM cache owns
27
+ // its row state. The remaining request-shape exclusions include vision, LoRA,
28
+ // serial-only KV modes, and user-fixed seed
31
29
  // (reproducibility ⇒ solo, matching mlx-lm's _is_batchable). Temperature /
32
30
  // top-p / top-k DO batch (each row samples with its own seed). Full-attention
33
31
  // AND sliding-window (Gemma) models both batch — the scheduler assembles each
34
- // layer's cache by type. Prompt-cache prefix reuse is bypassed under batching
35
- // in v1 (cachedTokens = 0).
32
+ // layer's cache by type. Prompt-cache prefix reuse works on the batch lane
33
+ // too (Phase 3.2): joiners take() at admission; never-merged lone rows put()
34
+ // back on finish (merged rows' KV is not extracted — their entries age out).
36
35
 
37
36
  import { MlxArray } from "../mlx/array";
38
37
  import * as ops from "../mlx/ops";
39
38
  import type { RuntimeModel } from "../model/factory";
40
39
  import { DiffusionGemmaModel } from "../model/diffusion-gemma";
41
- import { KVCache, RotatingKVCache } from "../model/gemma4-base";
40
+ import { KVCache, RotatingKVCache, isBatchableCache } from "../model/gemma4-base";
42
41
  import { SSMCache } from "../model/qwen3-delta";
42
+ import { UniversalDenseModel } from "../model/universal/dense";
43
43
  import type { GenerateOptions, GenerateStats, TokenLogprobs } from "../generate";
44
+ import type { KvQuantSpec, TurboQuantScheme } from "../config";
44
45
  import { makeSampler, makeLogitsProcessors, toLogprobs } from "../sampler";
45
- import { BatchScheduler } from "./batch-scheduler";
46
+ import { BatchScheduler, type RowPromptCache } from "./batch-scheduler";
46
47
 
47
48
  /** Async mutex: acquire() resolves to a release fn; releases run FIFO. */
48
49
  class AsyncMutex {
49
50
  #tail: Promise<void> = Promise.resolve();
51
+ /** Holders + waiters. > 0 means somebody owns or wants the engine. */
52
+ #pending = 0;
53
+ get locked(): boolean {
54
+ return this.#pending > 0;
55
+ }
50
56
  acquire(): Promise<() => void> {
57
+ this.#pending++;
51
58
  let release!: () => void;
52
59
  const gate = new Promise<void>((r) => { release = r; });
53
60
  const wait = this.#tail;
54
61
  this.#tail = this.#tail.then(() => gate);
55
- return wait.then(() => release);
62
+ let released = false;
63
+ return wait.then(() => () => {
64
+ if (released) return; // idempotent: a double release must not skew #pending
65
+ released = true;
66
+ this.#pending--;
67
+ release();
68
+ });
56
69
  }
57
70
  }
58
71
 
59
- export type Vision = { embeddings: MlxArray; imageMask: MlxArray };
72
+ /** Embeddings-prefill payload for media prompts (vision and/or audio — the
73
+ * name predates audio). Any request carrying one routes to the serial lane
74
+ * (shape.hasVision) and prefills through generate()'s promptEmbeddings
75
+ * path, bypassing the prompt cache. */
76
+ export type Vision = {
77
+ embeddings: MlxArray;
78
+ /** bool [L] image-token mask for the bidirectional attention overlay.
79
+ * Absent when the prompt carries ANY audio — audio(-containing) prompts
80
+ * run fully causal (audio-input-plan.md §3.3 Q1). */
81
+ imageMask?: MlxArray;
82
+ /** bool [L] union multimodal soft-token mask (image | audio) for
83
+ * per-layer-input id zeroing. Absent on the legacy vision-only shape,
84
+ * where zeroing falls back to imageMask. */
85
+ multimodalMask?: MlxArray;
86
+ };
60
87
 
61
88
  /** Per-token sink: returning `false` halts this generation (stop sequence).
62
89
  * `logprobs` is only populated on the serial lane when the request asked for
@@ -84,41 +111,87 @@ export interface RequestShape {
84
111
  userSeed: boolean;
85
112
  /** KV quantization is active (kvConfig/kvBits) — batched is bf16-only in v1. */
86
113
  kvQuant: boolean;
114
+ /** TurboQuant is active (docs/design/turboquant-kv.md). Solo-only in v1:
115
+ * TurboQuantKVCache is a novel Cache implementation (not a KVCache/
116
+ * RotatingKVCache subclass), so #modelCachesBatchable() already excludes
117
+ * it automatically once a request's cache is converted — this flag is the
118
+ * BELT on top of that automatic BRACES, an explicit refusal at the
119
+ * request-shape level so a turbo request never reaches the scheduler even
120
+ * before any cache conversion has happened. Both layers exist on purpose. */
121
+ turboQuant: boolean;
87
122
  /** Any of the mlx-lm sampler/processor extensions is active: min_p, XTC,
88
- * logit_bias, presence/frequency penalty. Safe v1: they ALL route to the
89
- * serial lane alongside repetition penalty. min_p/XTC are per-row samplers
90
- * and could batch (the batched lane already builds a per-row sampler);
91
- * keeping them serial until the batched path grows per-row logits
92
- * processors keeps one gate for the whole family. */
123
+ * logit_bias, presence/frequency penalty. INFORMATIONAL ONLY: since the
124
+ * batched lane grew per-row samplers/logits processors these all batch —
125
+ * willBatch() deliberately does not gate on this field (see its
126
+ * repetitionPenalty note). Kept so /stats and lane tracing can show what
127
+ * a request carries. */
93
128
  hasLogitsExtras: boolean;
129
+ /** A grammar controller compiled for this request (response_format /
130
+ * guided_*). Degrade-path requests (compile failed → prompt injection)
131
+ * have NO controller and stay batchable — the injection already happened
132
+ * at the prompt level. B0 routes grammar to serial; B1 makes it batchable
133
+ * (per-row matchers), with MLX_BUN_GRAMMAR_BATCH=0 forcing the B0 serial
134
+ * fallback as the A/B + kill switch. */
135
+ hasGrammar: boolean;
94
136
  /** The request asked for logprobs/top_logprobs capture. Batch-lane logprobs
95
137
  * are deferred (the scheduler's per-row sampler doesn't capture or read
96
138
  * back logprob arrays yet), so these route to the serial lane like the
97
139
  * other mlx-lm request extensions above. */
98
140
  wantsLogprobs: boolean;
141
+ /** A draft model is configured (serve --draft-model). Server-level and
142
+ * upstream-parity: mlx_lm.server sets is_batchable = (draft is None), so
143
+ * every request routes serial while a draft is mounted — speculation is a
144
+ * B=1 latency optimization, batching a throughput one; they are different
145
+ * modes by design (grammar-spec-batching-integration.md). */
146
+ hasDraft: boolean;
99
147
  }
100
148
 
101
149
  export class GenerationGateway {
102
150
  readonly #mutex = new AsyncMutex();
103
151
  readonly #batch: number;
104
152
  #scheduler: BatchScheduler | null = null;
153
+ #rowsSubmitted = 0;
105
154
  /** Serial-lane requests waiting for / holding the mutex. While > 0 the
106
155
  * scheduler pauses admission (drain) so they can't be starved. */
107
156
  #serialWaiters = 0;
108
157
  /** Lazy, memoized: can this model's caches do the dynamic-B ops? */
109
158
  #cacheBatchable: boolean | null = null;
159
+ /** Lazy, memoized: can the server's kv scheme run under batching?
160
+ * (Phase 3.1: kvConfig whose every configured layerIdx is a plain
161
+ * full-attention KVCache — uniform kvBits and rotating-layer configs
162
+ * stay serial. The scheme is a server-lifetime constant, so one memo.) */
163
+ #kvSchemeBatchable: boolean | null = null;
110
164
 
111
165
  constructor(
112
166
  private readonly model: RuntimeModel,
113
167
  batch: number,
114
168
  private readonly serialRun: SerialRun,
169
+ private readonly opts: {
170
+ kvBudgetBytes?: number;
171
+ /** The server-wide KV scheme (server.ts kvScheme) — threaded to the
172
+ * scheduler at construction when batchable (Phase 3.1). turboQuant is
173
+ * never threaded to the scheduler (always solo-only — see willBatch);
174
+ * it's here only so the gateway can see it's active. */
175
+ kvScheme?: { kvBits?: number; kvConfig?: KvQuantSpec[]; turboQuant?: TurboQuantScheme };
176
+ /** The server's prompt cache (Phase 3.2): batch-lane joiners take()
177
+ * the longest usable prefix at admission; never-merged rows put()
178
+ * back on finish. Safe to share with the serial lane — both use it
179
+ * only inside this gateway's mutual-exclusion domain. */
180
+ promptCache?: RowPromptCache;
181
+ } = {},
115
182
  ) {
116
183
  this.#batch = Math.max(1, Math.floor(batch));
117
184
  }
118
185
 
119
186
  /** True if `--batch N` (N>1) is on (batchability is then per-request). */
120
187
  get batchingEnabled(): boolean {
121
- return this.#batch > 1;
188
+ return this.batchMode === "batch";
189
+ }
190
+
191
+ /** Truthful configured/model-capability mode for stats and discovery. */
192
+ get batchMode(): "off" | "serial" | "batch" {
193
+ if (this.#batch <= 1) return "off";
194
+ return this.#modelCachesBatchable() ? "batch" : "serial";
122
195
  }
123
196
 
124
197
  /** Rows currently decoding in the batch (0 if no scheduler / idle). */
@@ -126,6 +199,45 @@ export class GenerationGateway {
126
199
  return this.#scheduler?.activeRows ?? 0;
127
200
  }
128
201
 
202
+ /** Queued + mid-prefill rows waiting behind the batch (0 when idle). */
203
+ get pendingRows(): number {
204
+ return this.#scheduler?.pendingRows ?? 0;
205
+ }
206
+
207
+ /** True while ANY engine work is active or queued, on EITHER lane: the
208
+ * mutex is held/awaited (a serial generation, the batch scheduler's whole
209
+ * active period, curve endpoints, adapter mounts) or batch rows are
210
+ * running/pending. activeRows/pendingRows alone MISS the serial lane —
211
+ * a serial generation holds the mutex but shows zero rows. */
212
+ get busy(): boolean {
213
+ return this.#mutex.locked || this.activeRows > 0 || this.pendingRows > 0;
214
+ }
215
+
216
+ /** Resolves once the engine is idle (poll-based, ~`pollMs` granularity —
217
+ * cheap vs the multi-hundred-ms flushes it paces; resolves without a
218
+ * timer when already idle). The SSD write-behind gates each per-tensor
219
+ * flush step on this so durability work never steals blocking
220
+ * GPU-sync + writeSync slices from an active decode (the 2026-07-07
221
+ * decode@ctx contamination — see server.ts's write-behind block). */
222
+ async onIdle(pollMs = 20): Promise<void> {
223
+ while (this.busy) await new Promise<void>((r) => setTimeout(r, pollMs));
224
+ }
225
+
226
+ /** Cumulative rows routed to the batch lane since server start. The serial
227
+ * lane never advances this — /stats' race-free lane-routing observable
228
+ * (active/pending are instantaneous and read 0 once a request finishes). */
229
+ get submittedRows(): number {
230
+ return this.#rowsSubmitted;
231
+ }
232
+
233
+ /** Projected aggregate KV bytes of admitted rows / the --kv-budget cap. */
234
+ get kvBytes(): { projected: number; budget: number | null } {
235
+ return {
236
+ projected: this.#scheduler?.projectedKvBytes ?? 0,
237
+ budget: this.#scheduler?.kvBudgetBytes ?? this.opts.kvBudgetBytes ?? null,
238
+ };
239
+ }
240
+
129
241
  /** Cache-capability gate (mirrors mlx-lm server.py's all-caches-have-merge
130
242
  * check): the scheduler's dynamic-B ops (temporalView/merge/filter) exist
131
243
  * on KVCache, RotatingKVCache, and SSMCache (hybrid gated-DeltaNet models
@@ -133,12 +245,28 @@ export class GenerationGateway {
133
245
  * the kill switch back to serial routing). */
134
246
  #modelCachesBatchable(): boolean {
135
247
  if (this.#cacheBatchable === null) {
248
+ // Tier-0 universal models: batchable for PLAIN full-attention archs
249
+ // only. The 2026-07-03 uneven-row bug (scalar-offset RoPE decoded
250
+ // padded rows at wrong positions) is fixed via
251
+ // UniversalRope.applyDynamic + ropeOffsetArr and GATED token-exact vs
252
+ // mlx-lm B=2 on Llama-3.2-3B (static + dynamic join/leave,
253
+ // tests/batched-decode-parity.test.ts "Llama 3B Tier-0"). The
254
+ // maskArray archs (gemma2-family: forwardLayers builds a pad-blind
255
+ // causal mask) and sliding-window universal archs remain UNVALIDATED
256
+ // cells → serial, per the per-model-cell discipline.
257
+ if (this.model instanceof UniversalDenseModel) {
258
+ const a = this.model.args;
259
+ this.#cacheBatchable =
260
+ !a.maskArray && !a.layerTypes?.includes("sliding_attention");
261
+ return this.#cacheBatchable;
262
+ }
136
263
  const ssmOk = process.env.MLX_BUN_BATCH_SSM !== "0";
137
264
  const proto = this.model.makeCache(); // fresh caches hold no buffers
138
265
  this.#cacheBatchable = proto.every(
139
266
  (c) =>
140
267
  c instanceof KVCache ||
141
268
  c instanceof RotatingKVCache ||
269
+ isBatchableCache(c) ||
142
270
  (ssmOk && c instanceof SSMCache),
143
271
  );
144
272
  for (const c of proto) c.dispose();
@@ -146,6 +274,36 @@ export class GenerationGateway {
146
274
  return this.#cacheBatchable;
147
275
  }
148
276
 
277
+ /** Phase 3.1 + milestone 2: the server's kv scheme batches iff it is a
278
+ * per-layer kvConfig (the L2 mixed scheme) whose every configured layer
279
+ * is a full-attention KVCache (BatchedQuantDecodeMaskCache + quantized
280
+ * merge/extend/filter) or a rotating RotatingKVCache
281
+ * (BatchedRotatingQuantCache — milestone 2, gemma's kv_config). Uniform
282
+ * kvBits (quantizedKvStart threshold semantics via the serial no-byLayer
283
+ * path) and configs naming SSM layers stay serial. */
284
+ #kvBatchable(): boolean {
285
+ if (this.#kvSchemeBatchable === null) {
286
+ const scheme = this.opts.kvScheme;
287
+ if (!scheme || (!scheme.kvBits && !scheme.kvConfig?.length)) {
288
+ // Only consulted when shape.kvQuant is SET: a kv-quant request with
289
+ // no scheme threaded to the gateway cannot be applied by the
290
+ // scheduler — batching it would SILENTLY DROP the quantization (the
291
+ // exact composition bug optiq serve ships). Route serial.
292
+ this.#kvSchemeBatchable = false;
293
+ } else if (scheme.kvBits || !scheme.kvConfig?.length) {
294
+ this.#kvSchemeBatchable = false; // uniform bits: serial
295
+ } else {
296
+ const proto = this.model.makeCache();
297
+ this.#kvSchemeBatchable = scheme.kvConfig.every(
298
+ (e) => proto[e.layerIdx] instanceof KVCache ||
299
+ proto[e.layerIdx] instanceof RotatingKVCache,
300
+ );
301
+ for (const c of proto) c.dispose();
302
+ }
303
+ }
304
+ return this.#kvSchemeBatchable;
305
+ }
306
+
149
307
  /** Decide whether a request joins the batch or runs serially. */
150
308
  willBatch(shape: RequestShape): boolean {
151
309
  // DiffusionGemma is non-autoregressive — the batch scheduler assumes the AR
@@ -164,7 +322,21 @@ export class GenerationGateway {
164
322
  !shape.hasAdapters &&
165
323
  !shape.wantsLogprobs &&
166
324
  !shape.userSeed &&
167
- !shape.kvQuant
325
+ // Phase 3.1: a kv-quant request batches when the server's scheme is
326
+ // the batchable kvConfig composition (the scheduler applies it);
327
+ // otherwise it routes serial exactly as before.
328
+ !(shape.kvQuant && !this.#kvBatchable()) &&
329
+ // TurboQuant: solo-only in v1, unconditionally — never partially
330
+ // batchable like kvConfig. The automatic instanceof exclusion in
331
+ // #modelCachesBatchable() already covers this once a cache converts;
332
+ // this is the explicit belt-and-braces refusal at request-shape time
333
+ // (mirrors the kvQuant pattern above — both layers exist on purpose).
334
+ !shape.turboQuant &&
335
+ !shape.hasDraft &&
336
+ // Grammar: B1 makes it batchable (per-row matchers) unless the kill
337
+ // switch forces serial. MLX_BUN_GRAMMAR_BATCH=0 = B0 behavior (serial),
338
+ // the A/B + kill lever for the new code, house style.
339
+ !(shape.hasGrammar && process.env.MLX_BUN_GRAMMAR_BATCH === "0")
168
340
  );
169
341
  }
170
342
 
@@ -197,7 +369,16 @@ export class GenerationGateway {
197
369
  onToken: OnToken,
198
370
  vision: Vision | undefined,
199
371
  shape: RequestShape,
372
+ signal?: AbortSignal,
200
373
  ): Promise<GenerateStats> {
374
+ try {
375
+ signal?.throwIfAborted();
376
+ } catch (e) {
377
+ // The gateway is the first component that accepts ownership of a
378
+ // compiled controller. An already-aborted request reaches neither lane.
379
+ options.grammar?.dispose();
380
+ throw e;
381
+ }
201
382
  if (!this.willBatch(shape)) {
202
383
  // Serial decode is an unbroken microtask chain (FFI + generator
203
384
  // resumes): without a periodic macrotask hop the event loop starves for
@@ -210,14 +391,26 @@ export class GenerationGateway {
210
391
  let lastHop = performance.now();
211
392
  const hop = (): Promise<void> => new Promise<void>((r) => setImmediate(r));
212
393
  const hoppingOnToken: OnToken = (token, lp) => {
394
+ if (signal?.aborted) return false;
213
395
  const r = onToken(token, lp);
214
396
  if (r === false) return false;
215
397
  if (performance.now() - lastHop < 25) return r;
216
398
  lastHop = performance.now();
217
- if (r instanceof Promise) return r.then((v) => (v === false ? false : hop().then(() => v)));
218
- return hop().then(() => r);
399
+ if (r instanceof Promise)
400
+ return r.then((v) =>
401
+ v === false || signal?.aborted ? false : hop().then(() => signal?.aborted ? false : v),
402
+ );
403
+ return hop().then(() => signal?.aborted ? false : r);
219
404
  };
220
- return this.runExclusive(() => this.serialRun(promptIds, options, hoppingOnToken, vision));
405
+ return this.runExclusive(async () => {
406
+ signal?.throwIfAborted();
407
+ const stats = await this.serialRun(promptIds, options, hoppingOnToken, vision);
408
+ signal?.throwIfAborted();
409
+ return stats;
410
+ })
411
+ // Covers a serial waiter aborted before serialRun takes ownership.
412
+ // generate() also disposes defensively; the operation is idempotent.
413
+ .finally(() => options.grammar?.dispose());
221
414
  }
222
415
 
223
416
  // Per-row sampler, mirroring generate()'s sampleStep: logits processors
@@ -240,6 +433,15 @@ export class GenerationGateway {
240
433
  if (cur !== logits1V) cur.dispose();
241
434
  cur = next;
242
435
  }
436
+ // Grammar mask (B1): after the standard processors, before the sampler —
437
+ // same "grammar has the final say" ordering as serial sampleStep.
438
+ // Precondition: the scheduler has awaited ready() before invoking this
439
+ // closure (the mask for this step is materialized). applyMask is sync.
440
+ if (options.grammar) {
441
+ const masked = options.grammar.applyMask(cur);
442
+ if (cur !== logits1V) cur.dispose();
443
+ cur = masked;
444
+ }
243
445
  const lp = toLogprobs(cur);
244
446
  if (cur !== logits1V) cur.dispose();
245
447
  const tok = sampler(lp, step);
@@ -255,17 +457,36 @@ export class GenerationGateway {
255
457
  };
256
458
 
257
459
  let st;
460
+ this.#rowsSubmitted++;
258
461
  try {
259
462
  st = await this.#ensureScheduler().submit({
260
463
  promptIds,
261
464
  maxTokens: options.maxTokens ?? 512,
262
465
  eosTokenIds: options.eosTokenIds ?? this.model.config.eosTokenIds,
263
466
  sample,
467
+ // Vectorized-sampling eligibility: mirrors makeSampler's greedy branch
468
+ // (temperature 0, no curve override) with nothing per-row in the way
469
+ // (no processors, no grammar mask).
470
+ plainGreedy:
471
+ (options.temperature ?? 0) === 0 &&
472
+ !options.curve &&
473
+ processors.length === 0 &&
474
+ !options.grammar,
264
475
  onToken,
476
+ ...(signal ? { signal } : {}),
477
+ // B1: pass the per-row grammar controller through. The scheduler drives
478
+ // accept/ready/terminate; this gateway OWNS disposal (finally below)
479
+ // across resolve, reject, eviction, and the whole-batch-drop error path.
480
+ ...(options.grammar ? { grammar: options.grammar } : {}),
481
+ // Stable cache boundary → the scheduler's trim-free prompt-prefix
482
+ // snapshot (same invariant as the serial lane's snapshotAt).
483
+ ...(options.snapshotAt !== undefined ? { snapshotAt: options.snapshotAt } : {}),
265
484
  });
266
485
  } finally {
267
486
  history?.dispose();
268
487
  history = null;
488
+ // The scheduler never owns per-row grammar state.
489
+ options.grammar?.dispose();
269
490
  }
270
491
 
271
492
  return {
@@ -281,6 +502,13 @@ export class GenerationGateway {
281
502
  if (!this.#scheduler)
282
503
  this.#scheduler = new BatchScheduler(this.model, {
283
504
  maxBatch: this.#batch,
505
+ kvBudgetBytes: this.opts.kvBudgetBytes,
506
+ // Phase 3.1: the batchable kvConfig composition is applied by the
507
+ // scheduler (solo rows convert at serial chunk boundaries, then
508
+ // merge as quantized triples). Only threaded when the scheme
509
+ // passed #kvBatchable — otherwise those requests never reach here.
510
+ kvConfig: this.#kvBatchable() ? this.opts.kvScheme?.kvConfig : undefined,
511
+ promptCache: this.opts.promptCache,
284
512
  lock: { acquire: () => this.#mutex.acquire() },
285
513
  // Drain: pause admission while any serial-lane request waits; the
286
514
  // runExclusive finally-block kick()s the loop back awake.