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
@@ -31,30 +31,61 @@
31
31
  // (reproducibility ⇒ solo, matching mlx-lm's _is_batchable). Temperature /
32
32
  // top-p / top-k DO batch (each row samples with its own seed). Full-attention
33
33
  // 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).
34
+ // layer's cache by type. Prompt-cache prefix reuse works on the batch lane
35
+ // too (Phase 3.2): joiners take() at admission; never-merged lone rows put()
36
+ // back on finish (merged rows' KV is not extracted — their entries age out).
36
37
 
37
38
  import { MlxArray } from "../mlx/array";
39
+ import * as ops from "../mlx/ops";
38
40
  import type { RuntimeModel } from "../model/factory";
39
41
  import { DiffusionGemmaModel } from "../model/diffusion-gemma";
40
42
  import { KVCache, RotatingKVCache } from "../model/gemma4-base";
43
+ import { SSMCache } from "../model/qwen3-delta";
44
+ import { UniversalDenseModel } from "../model/universal/dense";
41
45
  import type { GenerateOptions, GenerateStats, TokenLogprobs } from "../generate";
42
- import { makeSampler, toLogprobs } from "../sampler";
43
- import { BatchScheduler } from "./batch-scheduler";
46
+ import type { KvQuantSpec, TurboQuantScheme } from "../config";
47
+ import { makeSampler, makeLogitsProcessors, toLogprobs } from "../sampler";
48
+ import { BatchScheduler, type RowPromptCache } from "./batch-scheduler";
44
49
 
45
50
  /** Async mutex: acquire() resolves to a release fn; releases run FIFO. */
46
51
  class AsyncMutex {
47
52
  #tail: Promise<void> = Promise.resolve();
53
+ /** Holders + waiters. > 0 means somebody owns or wants the engine. */
54
+ #pending = 0;
55
+ get locked(): boolean {
56
+ return this.#pending > 0;
57
+ }
48
58
  acquire(): Promise<() => void> {
59
+ this.#pending++;
49
60
  let release!: () => void;
50
61
  const gate = new Promise<void>((r) => { release = r; });
51
62
  const wait = this.#tail;
52
63
  this.#tail = this.#tail.then(() => gate);
53
- return wait.then(() => release);
64
+ let released = false;
65
+ return wait.then(() => () => {
66
+ if (released) return; // idempotent: a double release must not skew #pending
67
+ released = true;
68
+ this.#pending--;
69
+ release();
70
+ });
54
71
  }
55
72
  }
56
73
 
57
- export type Vision = { embeddings: MlxArray; imageMask: MlxArray };
74
+ /** Embeddings-prefill payload for media prompts (vision and/or audio — the
75
+ * name predates audio). Any request carrying one routes to the serial lane
76
+ * (shape.hasVision) and prefills through generate()'s promptEmbeddings
77
+ * path, bypassing the prompt cache. */
78
+ export type Vision = {
79
+ embeddings: MlxArray;
80
+ /** bool [L] image-token mask for the bidirectional attention overlay.
81
+ * Absent when the prompt carries ANY audio — audio(-containing) prompts
82
+ * run fully causal (audio-input-plan.md §3.3 Q1). */
83
+ imageMask?: MlxArray;
84
+ /** bool [L] union multimodal soft-token mask (image | audio) for
85
+ * per-layer-input id zeroing. Absent on the legacy vision-only shape,
86
+ * where zeroing falls back to imageMask. */
87
+ multimodalMask?: MlxArray;
88
+ };
58
89
 
59
90
  /** Per-token sink: returning `false` halts this generation (stop sequence).
60
91
  * `logprobs` is only populated on the serial lane when the request asked for
@@ -82,34 +113,74 @@ export interface RequestShape {
82
113
  userSeed: boolean;
83
114
  /** KV quantization is active (kvConfig/kvBits) — batched is bf16-only in v1. */
84
115
  kvQuant: boolean;
116
+ /** TurboQuant is active (docs/design/turboquant-kv.md). Solo-only in v1:
117
+ * TurboQuantKVCache is a novel Cache implementation (not a KVCache/
118
+ * RotatingKVCache subclass), so #modelCachesBatchable() already excludes
119
+ * it automatically once a request's cache is converted — this flag is the
120
+ * BELT on top of that automatic BRACES, an explicit refusal at the
121
+ * request-shape level so a turbo request never reaches the scheduler even
122
+ * before any cache conversion has happened. Both layers exist on purpose. */
123
+ turboQuant: boolean;
85
124
  /** Any of the mlx-lm sampler/processor extensions is active: min_p, XTC,
86
- * logit_bias, presence/frequency penalty. Safe v1: they ALL route to the
87
- * serial lane alongside repetition penalty. min_p/XTC are per-row samplers
88
- * and could batch (the batched lane already builds a per-row sampler);
89
- * keeping them serial until the batched path grows per-row logits
90
- * processors keeps one gate for the whole family. */
125
+ * logit_bias, presence/frequency penalty. INFORMATIONAL ONLY: since the
126
+ * batched lane grew per-row samplers/logits processors these all batch —
127
+ * willBatch() deliberately does not gate on this field (see its
128
+ * repetitionPenalty note). Kept so /stats and lane tracing can show what
129
+ * a request carries. */
91
130
  hasLogitsExtras: boolean;
131
+ /** A grammar controller compiled for this request (response_format /
132
+ * guided_*). Degrade-path requests (compile failed → prompt injection)
133
+ * have NO controller and stay batchable — the injection already happened
134
+ * at the prompt level. B0 routes grammar to serial; B1 makes it batchable
135
+ * (per-row matchers), with MLX_BUN_GRAMMAR_BATCH=0 forcing the B0 serial
136
+ * fallback as the A/B + kill switch. */
137
+ hasGrammar: boolean;
92
138
  /** The request asked for logprobs/top_logprobs capture. Batch-lane logprobs
93
139
  * are deferred (the scheduler's per-row sampler doesn't capture or read
94
140
  * back logprob arrays yet), so these route to the serial lane like the
95
141
  * other mlx-lm request extensions above. */
96
142
  wantsLogprobs: boolean;
143
+ /** A draft model is configured (serve --draft-model). Server-level and
144
+ * upstream-parity: mlx_lm.server sets is_batchable = (draft is None), so
145
+ * every request routes serial while a draft is mounted — speculation is a
146
+ * B=1 latency optimization, batching a throughput one; they are different
147
+ * modes by design (grammar-spec-batching-integration.md). */
148
+ hasDraft: boolean;
97
149
  }
98
150
 
99
151
  export class GenerationGateway {
100
152
  readonly #mutex = new AsyncMutex();
101
153
  readonly #batch: number;
102
154
  #scheduler: BatchScheduler | null = null;
155
+ #rowsSubmitted = 0;
103
156
  /** Serial-lane requests waiting for / holding the mutex. While > 0 the
104
157
  * scheduler pauses admission (drain) so they can't be starved. */
105
158
  #serialWaiters = 0;
106
159
  /** Lazy, memoized: can this model's caches do the dynamic-B ops? */
107
160
  #cacheBatchable: boolean | null = null;
161
+ /** Lazy, memoized: can the server's kv scheme run under batching?
162
+ * (Phase 3.1: kvConfig whose every configured layerIdx is a plain
163
+ * full-attention KVCache — uniform kvBits and rotating-layer configs
164
+ * stay serial. The scheme is a server-lifetime constant, so one memo.) */
165
+ #kvSchemeBatchable: boolean | null = null;
108
166
 
109
167
  constructor(
110
168
  private readonly model: RuntimeModel,
111
169
  batch: number,
112
170
  private readonly serialRun: SerialRun,
171
+ private readonly opts: {
172
+ kvBudgetBytes?: number;
173
+ /** The server-wide KV scheme (server.ts kvScheme) — threaded to the
174
+ * scheduler at construction when batchable (Phase 3.1). turboQuant is
175
+ * never threaded to the scheduler (always solo-only — see willBatch);
176
+ * it's here only so the gateway can see it's active. */
177
+ kvScheme?: { kvBits?: number; kvConfig?: KvQuantSpec[]; turboQuant?: TurboQuantScheme };
178
+ /** The server's prompt cache (Phase 3.2): batch-lane joiners take()
179
+ * the longest usable prefix at admission; never-merged rows put()
180
+ * back on finish. Safe to share with the serial lane — both use it
181
+ * only inside this gateway's mutual-exclusion domain. */
182
+ promptCache?: RowPromptCache;
183
+ } = {},
113
184
  ) {
114
185
  this.#batch = Math.max(1, Math.floor(batch));
115
186
  }
@@ -124,21 +195,110 @@ export class GenerationGateway {
124
195
  return this.#scheduler?.activeRows ?? 0;
125
196
  }
126
197
 
198
+ /** Queued + mid-prefill rows waiting behind the batch (0 when idle). */
199
+ get pendingRows(): number {
200
+ return this.#scheduler?.pendingRows ?? 0;
201
+ }
202
+
203
+ /** True while ANY engine work is active or queued, on EITHER lane: the
204
+ * mutex is held/awaited (a serial generation, the batch scheduler's whole
205
+ * active period, curve endpoints, adapter mounts) or batch rows are
206
+ * running/pending. activeRows/pendingRows alone MISS the serial lane —
207
+ * a serial generation holds the mutex but shows zero rows. */
208
+ get busy(): boolean {
209
+ return this.#mutex.locked || this.activeRows > 0 || this.pendingRows > 0;
210
+ }
211
+
212
+ /** Resolves once the engine is idle (poll-based, ~`pollMs` granularity —
213
+ * cheap vs the multi-hundred-ms flushes it paces; resolves without a
214
+ * timer when already idle). The SSD write-behind gates each per-tensor
215
+ * flush step on this so durability work never steals blocking
216
+ * GPU-sync + writeSync slices from an active decode (the 2026-07-07
217
+ * decode@ctx contamination — see server.ts's write-behind block). */
218
+ async onIdle(pollMs = 20): Promise<void> {
219
+ while (this.busy) await new Promise<void>((r) => setTimeout(r, pollMs));
220
+ }
221
+
222
+ /** Cumulative rows routed to the batch lane since server start. The serial
223
+ * lane never advances this — /stats' race-free lane-routing observable
224
+ * (active/pending are instantaneous and read 0 once a request finishes). */
225
+ get submittedRows(): number {
226
+ return this.#rowsSubmitted;
227
+ }
228
+
229
+ /** Projected aggregate KV bytes of admitted rows / the --kv-budget cap. */
230
+ get kvBytes(): { projected: number; budget: number | null } {
231
+ return {
232
+ projected: this.#scheduler?.projectedKvBytes ?? 0,
233
+ budget: this.#scheduler?.kvBudgetBytes ?? this.opts.kvBudgetBytes ?? null,
234
+ };
235
+ }
236
+
127
237
  /** Cache-capability gate (mirrors mlx-lm server.py's all-caches-have-merge
128
238
  * check): the scheduler's dynamic-B ops (temporalView/merge/filter) exist
129
- * only on KVCache and RotatingKVCache. A hybrid model (Qwen3.5's SSMCache)
130
- * routes serial until the batched-SSM port (batching-v2-plan item h). */
239
+ * on KVCache, RotatingKVCache, and SSMCache (hybrid gated-DeltaNet models
240
+ * — Qwen3.5 — batch via SSMCache.mergeRows/filter; MLX_BUN_BATCH_SSM=0 is
241
+ * the kill switch back to serial routing). */
131
242
  #modelCachesBatchable(): boolean {
132
243
  if (this.#cacheBatchable === null) {
244
+ // Tier-0 universal models: batchable for PLAIN full-attention archs
245
+ // only. The 2026-07-03 uneven-row bug (scalar-offset RoPE decoded
246
+ // padded rows at wrong positions) is fixed via
247
+ // UniversalRope.applyDynamic + ropeOffsetArr and GATED token-exact vs
248
+ // mlx-lm B=2 on Llama-3.2-3B (static + dynamic join/leave,
249
+ // tests/batched-decode-parity.test.ts "Llama 3B Tier-0"). The
250
+ // maskArray archs (gemma2-family: forwardLayers builds a pad-blind
251
+ // causal mask) and sliding-window universal archs remain UNVALIDATED
252
+ // cells → serial, per the per-model-cell discipline.
253
+ if (this.model instanceof UniversalDenseModel) {
254
+ const a = this.model.args;
255
+ this.#cacheBatchable =
256
+ !a.maskArray && !a.layerTypes?.includes("sliding_attention");
257
+ return this.#cacheBatchable;
258
+ }
259
+ const ssmOk = process.env.MLX_BUN_BATCH_SSM !== "0";
133
260
  const proto = this.model.makeCache(); // fresh caches hold no buffers
134
261
  this.#cacheBatchable = proto.every(
135
- (c) => c instanceof KVCache || c instanceof RotatingKVCache,
262
+ (c) =>
263
+ c instanceof KVCache ||
264
+ c instanceof RotatingKVCache ||
265
+ (ssmOk && c instanceof SSMCache),
136
266
  );
137
267
  for (const c of proto) c.dispose();
138
268
  }
139
269
  return this.#cacheBatchable;
140
270
  }
141
271
 
272
+ /** Phase 3.1 + milestone 2: the server's kv scheme batches iff it is a
273
+ * per-layer kvConfig (the L2 mixed scheme) whose every configured layer
274
+ * is a full-attention KVCache (BatchedQuantDecodeMaskCache + quantized
275
+ * merge/extend/filter) or a rotating RotatingKVCache
276
+ * (BatchedRotatingQuantCache — milestone 2, gemma's kv_config). Uniform
277
+ * kvBits (quantizedKvStart threshold semantics via the serial no-byLayer
278
+ * path) and configs naming SSM layers stay serial. */
279
+ #kvBatchable(): boolean {
280
+ if (this.#kvSchemeBatchable === null) {
281
+ const scheme = this.opts.kvScheme;
282
+ if (!scheme || (!scheme.kvBits && !scheme.kvConfig?.length)) {
283
+ // Only consulted when shape.kvQuant is SET: a kv-quant request with
284
+ // no scheme threaded to the gateway cannot be applied by the
285
+ // scheduler — batching it would SILENTLY DROP the quantization (the
286
+ // exact composition bug optiq serve ships). Route serial.
287
+ this.#kvSchemeBatchable = false;
288
+ } else if (scheme.kvBits || !scheme.kvConfig?.length) {
289
+ this.#kvSchemeBatchable = false; // uniform bits: serial
290
+ } else {
291
+ const proto = this.model.makeCache();
292
+ this.#kvSchemeBatchable = scheme.kvConfig.every(
293
+ (e) => proto[e.layerIdx] instanceof KVCache ||
294
+ proto[e.layerIdx] instanceof RotatingKVCache,
295
+ );
296
+ for (const c of proto) c.dispose();
297
+ }
298
+ }
299
+ return this.#kvSchemeBatchable;
300
+ }
301
+
142
302
  /** Decide whether a request joins the batch or runs serially. */
143
303
  willBatch(shape: RequestShape): boolean {
144
304
  // DiffusionGemma is non-autoregressive — the batch scheduler assumes the AR
@@ -146,14 +306,32 @@ export class GenerationGateway {
146
306
  if (this.model instanceof DiffusionGemmaModel) return false;
147
307
  if (!this.batchingEnabled) return false;
148
308
  if (!this.#modelCachesBatchable()) return false;
309
+ // repetitionPenalty / logits extras (min_p, XTC, logit_bias,
310
+ // presence/frequency penalties) batch: the per-row sampler folds in the
311
+ // same makeSampler/makeLogitsProcessors closures the serial lane runs,
312
+ // over a per-row device-side token history. (Load-bearing beyond opt-in
313
+ // knobs: some models — Qwen3.5 — ship a default repetition penalty in
314
+ // generation_config.json, which used to route EVERY request serial.)
149
315
  return (
150
316
  !shape.hasVision &&
151
317
  !shape.hasAdapters &&
152
- !shape.hasRepetitionPenalty &&
153
- !shape.hasLogitsExtras &&
154
318
  !shape.wantsLogprobs &&
155
319
  !shape.userSeed &&
156
- !shape.kvQuant
320
+ // Phase 3.1: a kv-quant request batches when the server's scheme is
321
+ // the batchable kvConfig composition (the scheduler applies it);
322
+ // otherwise it routes serial exactly as before.
323
+ !(shape.kvQuant && !this.#kvBatchable()) &&
324
+ // TurboQuant: solo-only in v1, unconditionally — never partially
325
+ // batchable like kvConfig. The automatic instanceof exclusion in
326
+ // #modelCachesBatchable() already covers this once a cache converts;
327
+ // this is the explicit belt-and-braces refusal at request-shape time
328
+ // (mirrors the kvQuant pattern above — both layers exist on purpose).
329
+ !shape.turboQuant &&
330
+ !shape.hasDraft &&
331
+ // Grammar: B1 makes it batchable (per-row matchers) unless the kill
332
+ // switch forces serial. MLX_BUN_GRAMMAR_BATCH=0 = B0 behavior (serial),
333
+ // the A/B + kill lever for the new code, house style.
334
+ !(shape.hasGrammar && process.env.MLX_BUN_GRAMMAR_BATCH === "0")
157
335
  );
158
336
  }
159
337
 
@@ -187,27 +365,104 @@ export class GenerationGateway {
187
365
  vision: Vision | undefined,
188
366
  shape: RequestShape,
189
367
  ): Promise<GenerateStats> {
190
- if (!this.willBatch(shape))
191
- return this.runExclusive(() => this.serialRun(promptIds, options, onToken, vision));
368
+ if (!this.willBatch(shape)) {
369
+ // Serial decode is an unbroken microtask chain (FFI + generator
370
+ // resumes): without a periodic macrotask hop the event loop starves for
371
+ // the WHOLE generation — /stats, /health, and new-connection accepts
372
+ // stall until the request finishes (measured 2.5 s on a 512-token cpm5
373
+ // run). Rate-limited to ≥25 ms — the SSE flush hop's budget, ~0.1% of
374
+ // decode — and applied HERE so every serial caller (streaming or not,
375
+ // all four API surfaces) is covered. The batch lane needs nothing: its
376
+ // drive loop yields per step (measured /stats ~8 ms mid-generation).
377
+ let lastHop = performance.now();
378
+ const hop = (): Promise<void> => new Promise<void>((r) => setImmediate(r));
379
+ const hoppingOnToken: OnToken = (token, lp) => {
380
+ const r = onToken(token, lp);
381
+ if (r === false) return false;
382
+ if (performance.now() - lastHop < 25) return r;
383
+ lastHop = performance.now();
384
+ if (r instanceof Promise) return r.then((v) => (v === false ? false : hop().then(() => v)));
385
+ return hop().then(() => r);
386
+ };
387
+ return this.runExclusive(() => this.serialRun(promptIds, options, hoppingOnToken, vision));
388
+ }
192
389
 
193
- // Per-row sampler, mirroring generate()'s sampleStep (no logits processors:
194
- // repetition penalty was excluded by willBatch). Greedy (temperature 0) is
195
- // argmax; temp>0 uses this request's own seed → independent per-row RNG.
390
+ // Per-row sampler, mirroring generate()'s sampleStep: logits processors
391
+ // (logit_bias / repetition / presence / frequency penalties — the same
392
+ // device-side closures the serial lane runs) fold in over a per-row token
393
+ // history seeded with the prompt, then the sampler. Greedy (temperature 0)
394
+ // is argmax; temp>0 uses this request's own seed → independent per-row RNG.
196
395
  const sampler = makeSampler(options);
396
+ const processors = makeLogitsProcessors(options);
397
+ // Device-side history (prompt + generated), maintained only when a
398
+ // processor needs it — generate()'s pushHistory, one per row. The sample
399
+ // closure is called once per sampled token in order, so extending it
400
+ // inside the closure keeps history exact.
401
+ let history: MlxArray | null = null;
402
+ if (processors.length > 0) history = ops.fromInt32(promptIds, [promptIds.length]);
197
403
  const sample = (logits1V: MlxArray, step: number): MlxArray => {
198
- const lp = toLogprobs(logits1V);
404
+ let cur = logits1V; // caller-owned; only intermediates are disposed here
405
+ for (const p of processors) {
406
+ const next = p(history, cur);
407
+ if (cur !== logits1V) cur.dispose();
408
+ cur = next;
409
+ }
410
+ // Grammar mask (B1): after the standard processors, before the sampler —
411
+ // same "grammar has the final say" ordering as serial sampleStep.
412
+ // Precondition: the scheduler has awaited ready() before invoking this
413
+ // closure (the mask for this step is materialized). applyMask is sync.
414
+ if (options.grammar) {
415
+ const masked = options.grammar.applyMask(cur);
416
+ if (cur !== logits1V) cur.dispose();
417
+ cur = masked;
418
+ }
419
+ const lp = toLogprobs(cur);
420
+ if (cur !== logits1V) cur.dispose();
199
421
  const tok = sampler(lp, step);
200
422
  lp.dispose();
423
+ if (history) {
424
+ const t1 = ops.reshape(tok, [1]);
425
+ const prev = history;
426
+ history = ops.concatAxis([prev, t1], 0);
427
+ prev.dispose();
428
+ t1.dispose();
429
+ }
201
430
  return tok;
202
431
  };
203
432
 
204
- const st = await this.#ensureScheduler().submit({
205
- promptIds,
206
- maxTokens: options.maxTokens ?? 512,
207
- eosTokenIds: options.eosTokenIds ?? this.model.config.eosTokenIds,
208
- sample,
209
- onToken,
210
- });
433
+ let st;
434
+ this.#rowsSubmitted++;
435
+ try {
436
+ st = await this.#ensureScheduler().submit({
437
+ promptIds,
438
+ maxTokens: options.maxTokens ?? 512,
439
+ eosTokenIds: options.eosTokenIds ?? this.model.config.eosTokenIds,
440
+ sample,
441
+ // Vectorized-sampling eligibility: mirrors makeSampler's greedy branch
442
+ // (temperature 0, no curve override) with nothing per-row in the way
443
+ // (no processors, no grammar mask).
444
+ plainGreedy:
445
+ (options.temperature ?? 0) === 0 &&
446
+ !options.curve &&
447
+ processors.length === 0 &&
448
+ !options.grammar,
449
+ onToken,
450
+ // B1: pass the per-row grammar controller through. The scheduler drives
451
+ // accept/ready/terminate; this gateway OWNS disposal (finally below)
452
+ // across resolve, reject, eviction, and the whole-batch-drop error path.
453
+ ...(options.grammar ? { grammar: options.grammar } : {}),
454
+ // Stable cache boundary → the scheduler's trim-free prompt-prefix
455
+ // snapshot (same invariant as the serial lane's snapshotAt).
456
+ ...(options.snapshotAt !== undefined ? { snapshotAt: options.snapshotAt } : {}),
457
+ });
458
+ } finally {
459
+ history?.dispose();
460
+ history = null;
461
+ // The controller's WASM matcher/compiled-grammar are per-request; free
462
+ // them on every exit path. (generate()'s finally does this on the serial
463
+ // lane; the batch lane owns it here since the scheduler never does.)
464
+ options.grammar?.dispose();
465
+ }
211
466
 
212
467
  return {
213
468
  promptTokens: st.promptTokens,
@@ -222,6 +477,13 @@ export class GenerationGateway {
222
477
  if (!this.#scheduler)
223
478
  this.#scheduler = new BatchScheduler(this.model, {
224
479
  maxBatch: this.#batch,
480
+ kvBudgetBytes: this.opts.kvBudgetBytes,
481
+ // Phase 3.1: the batchable kvConfig composition is applied by the
482
+ // scheduler (solo rows convert at serial chunk boundaries, then
483
+ // merge as quantized triples). Only threaded when the scheme
484
+ // passed #kvBatchable — otherwise those requests never reach here.
485
+ kvConfig: this.#kvBatchable() ? this.opts.kvScheme?.kvConfig : undefined,
486
+ promptCache: this.opts.promptCache,
225
487
  lock: { acquire: () => this.#mutex.acquire() },
226
488
  // Drain: pause admission while any serial-lane request waits; the
227
489
  // runExclusive finally-block kick()s the loop back awake.