mlx-bun 0.0.10 → 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 (173) 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 +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/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 +14 -6
  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 +3 -2
  27. package/docs/design/ssd-kv-cold-tier.md +130 -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/dspark-handoff.md +182 -16
  35. package/docs/investigations/lab-build-journal.md +2 -2
  36. package/docs/investigations/openwiki-evaluation.md +249 -0
  37. package/docs/planning/IDEAS.md +14 -0
  38. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  39. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  40. package/docs/planning/chat-ui-vision.md +50 -0
  41. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  42. package/docs/planning/release-notes-v0.0.11.md +239 -0
  43. package/docs/reference/cli.md +35 -16
  44. package/docs/reference/distribution.md +13 -6
  45. package/docs/reference/embedding.md +35 -14
  46. package/docs/reference/features-matrix.md +97 -0
  47. package/docs/reference/library-api.md +30 -9
  48. package/docs/reference/memory.md +26 -6
  49. package/docs/reference/models.md +11 -1
  50. package/docs/reference/orpo-quickstart.md +3 -4
  51. package/docs/reference/server-api.md +634 -22
  52. package/docs/reference/server-config.md +250 -121
  53. package/docs/reference/training.md +14 -15
  54. package/package.json +5 -3
  55. package/src/anthropic.ts +13 -0
  56. package/src/audio/conformer.ts +647 -0
  57. package/src/audio/decode.ts +133 -0
  58. package/src/audio/features.ts +202 -0
  59. package/src/audio/transcode.ts +67 -0
  60. package/src/cli.ts +356 -105
  61. package/src/config.ts +49 -2
  62. package/src/download.ts +40 -0
  63. package/src/eval/README.md +7 -6
  64. package/src/eval/kl.ts +59 -3
  65. package/src/eval/quality-db.ts +2 -2
  66. package/src/eval/runner.ts +3 -3
  67. package/src/expert-offload.ts +15 -2
  68. package/src/flags.ts +13 -0
  69. package/src/generate.ts +330 -24
  70. package/src/grammar.ts +585 -0
  71. package/src/hub-rest.ts +242 -0
  72. package/src/kv-store.ts +442 -88
  73. package/src/lora.ts +8 -0
  74. package/src/md-modules.d.ts +42 -0
  75. package/src/media-fetch.ts +284 -0
  76. package/src/memory/rest.ts +245 -0
  77. package/src/memory/vault.ts +74 -0
  78. package/src/mlx/array.ts +106 -19
  79. package/src/mlx/autograd.ts +2 -2
  80. package/src/mlx/ffi.ts +27 -3
  81. package/src/mlx/metal-kernel.ts +7 -4
  82. package/src/mlx/ops.ts +153 -23
  83. package/src/mlx/turboquant-ops.ts +831 -0
  84. package/src/mlx/turboquant-tables.ts +237 -0
  85. package/src/model/batched-mask.ts +62 -1
  86. package/src/model/batched-quant.ts +206 -0
  87. package/src/model/batched-rotating-quant.ts +346 -0
  88. package/src/model/batched-rotating.ts +46 -1
  89. package/src/model/compiled-decode.ts +4 -15
  90. package/src/model/factory.ts +10 -4
  91. package/src/model/gemma4-base.ts +363 -39
  92. package/src/model/gemma4.ts +122 -33
  93. package/src/model/generated/gemma4-12b.ts +9 -13
  94. package/src/model/generated/gemma4-26b.ts +10 -5
  95. package/src/model/generated/gemma4-e4b.ts +14 -9
  96. package/src/model/minicpm5.ts +58 -102
  97. package/src/model/paged-kv.ts +253 -0
  98. package/src/model/qwen3-delta.ts +76 -15
  99. package/src/model/qwen3-moe.ts +404 -0
  100. package/src/model/qwen3.ts +9 -8
  101. package/src/model/qwen3_5.ts +88 -23
  102. package/src/model/support.ts +12 -1
  103. package/src/model/universal/dense.ts +40 -5
  104. package/src/model/universal/rope.ts +36 -21
  105. package/src/pi-web.ts +1299 -82
  106. package/src/preflight.ts +27 -8
  107. package/src/prompt-cache.ts +293 -33
  108. package/src/quantize/quantizer.ts +16 -1
  109. package/src/registry.ts +57 -2
  110. package/src/serve/batch-scheduler.ts +965 -72
  111. package/src/serve/generation-gateway.ts +214 -11
  112. package/src/serve/isolate.ts +417 -0
  113. package/src/serve/lane-registry.ts +44 -0
  114. package/src/serve/session-search.ts +326 -0
  115. package/src/server.ts +1380 -135
  116. package/src/spec/assistant-source.ts +150 -0
  117. package/src/spec/deepspec-source.ts +143 -0
  118. package/src/spec/dflash-source.ts +124 -0
  119. package/src/spec/dspark/ab-stats.ts +136 -0
  120. package/src/spec/dspark/calibration.ts +112 -0
  121. package/src/spec/dspark/deepspec-module.ts +801 -0
  122. package/src/spec/dspark/generate-dflash.ts +72 -20
  123. package/src/spec/dspark/loader.ts +23 -0
  124. package/src/spec/dspark/module-dflash.ts +278 -36
  125. package/src/spec/dspark/quantize-drafter.ts +91 -0
  126. package/src/spec/generate.ts +59 -25
  127. package/src/spec/ngram-source.ts +132 -0
  128. package/src/spec/serve-loop.ts +536 -0
  129. package/src/spec/source.ts +93 -0
  130. package/src/spec/two-model.ts +171 -0
  131. package/src/ssd-cache.ts +107 -35
  132. package/src/tokenizer.ts +288 -2
  133. package/src/tool-approvals.ts +131 -0
  134. package/src/tool-call.ts +408 -20
  135. package/src/train/flash-cce.ts +2 -2
  136. package/src/train/job.ts +0 -2
  137. package/src/train/trainer.ts +1 -4
  138. package/src/vision/prompt.ts +217 -48
  139. package/src/web/app.html +1184 -1789
  140. package/src/web/app.js +5667 -0
  141. package/src/web/icon.svg +13 -0
  142. package/src/web/manifest.webmanifest +24 -0
  143. package/src/web/src/adapters-panel.ts +288 -0
  144. package/src/web/src/api.ts +57 -0
  145. package/src/web/src/assistant.ts +485 -0
  146. package/src/web/src/chat.ts +1050 -0
  147. package/src/web/src/composer.ts +959 -0
  148. package/src/web/src/dataset.ts +132 -0
  149. package/src/web/src/finetune.ts +267 -0
  150. package/src/web/src/hub.ts +428 -0
  151. package/src/web/src/main.ts +98 -0
  152. package/src/web/src/markdown.ts +0 -0
  153. package/src/web/src/memory-panel.ts +572 -0
  154. package/src/web/src/model-picker.ts +159 -0
  155. package/src/web/src/palette.ts +346 -0
  156. package/src/web/src/protocol.ts +62 -0
  157. package/src/web/src/quantize.ts +167 -0
  158. package/src/web/src/rag.ts +274 -0
  159. package/src/web/src/sessions.ts +469 -0
  160. package/src/web/src/shell.ts +804 -0
  161. package/src/web/src/status.ts +252 -0
  162. package/src/web/src/ui-catalog.ts +92 -0
  163. package/src/web/sw.js +71 -0
  164. package/src/web/vendor/README.md +61 -0
  165. package/src/web/vendor/hljs-LICENSE +29 -0
  166. package/src/web/vendor/hljs-theme.css +59 -0
  167. package/src/web/vendor/hljs.js +26 -0
  168. package/src/model/fused-decode-kernel.ts +0 -312
  169. package/src/model/fused-geglu-kernel.ts +0 -198
  170. package/src/model/fused-mlp-kernel.ts +0 -169
  171. package/src/model/fused-swiglu-kernel.ts +0 -240
  172. package/src/model/steel-linear-kernel.ts +0 -96
  173. package/src/status-page.html +0 -497
package/src/generate.ts CHANGED
@@ -15,20 +15,34 @@ import {
15
15
  } from "./mlx/ffi";
16
16
  import * as ops from "./mlx/ops";
17
17
  import { CompiledDecode } from "./model/compiled-decode";
18
- import { Gemma4Model, KVCache, RotatingKVCache, type Cache } from "./model/gemma4";
18
+ import { Gemma4Model, KVCache, RotatingKVCache, TurboQuantKVCache, type Cache } from "./model/gemma4";
19
+ import { PagedKVCache } from "./model/paged-kv";
19
20
  import { DiffusionGemmaModel } from "./model/diffusion-gemma";
20
21
  import { diffusionGenerate } from "./diffusion/diffusion-generate";
21
22
  import type { RuntimeModel } from "./model/factory";
22
- import type { KvQuantSpec } from "./config";
23
+ import type { KvQuantSpec, TurboQuantScheme } from "./config";
24
+ import { flagOn } from "./flags";
23
25
  import {
24
26
  makeLogitsProcessors, makeSampler,
25
27
  type LogitsProcessorOptions, type SamplerOptions, toLogprobs,
26
28
  } from "./sampler";
29
+ import type { GrammarController } from "./grammar";
27
30
 
28
31
  export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions {
29
32
  maxTokens?: number;
30
33
  eosTokenIds?: number[];
31
34
  prefillChunkSize?: number;
35
+ /** Fired once during prefill, at `snapshotAt` tokens (default: the full
36
+ * prompt) and BEFORE any further KV is written — the one moment the
37
+ * caches hold exactly that prefix (post-wrap rings can't be rewound
38
+ * later). The server's prompt-boundary cache snapshot hangs here. */
39
+ onPrefillDone?: () => void;
40
+ /** Token count at which onPrefillDone fires (the STABLE cache boundary —
41
+ * chat prompts end in a generation primer, e.g. 12B's thought-channel
42
+ * tokens, that the NEXT turn's re-render does not contain; snapshotting
43
+ * there would make the entry untrimmable-and-divergent). Prefill is
44
+ * split at this index when it falls mid-prompt. */
45
+ snapshotAt?: number;
32
46
  /** Pre-warmed KV caches (e.g. from the prompt cache). cache[0].offset
33
47
  * prompt tokens are treated as already prefilled; only the suffix is
34
48
  * forwarded. Caller keeps ownership — generate() will not dispose. */
@@ -37,8 +51,16 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
37
51
  * covering the whole prompt; prefilled in one shot (no chunking).
38
52
  * Caller keeps ownership. */
39
53
  promptEmbeddings?: MlxArray;
40
- /** bool [L] marking image tokens (bidirectional attention among them). */
54
+ /** bool [L] marking image tokens (bidirectional attention among them).
55
+ * MUST be unset when the prompt contains any audio — audio prompts run
56
+ * fully causal (audio-input-plan.md §3.3 Q1). */
41
57
  imageMask?: MlxArray;
58
+ /** bool [L] marking ALL multimodal soft tokens (image | audio) for
59
+ * per-layer-input id zeroing (e2b/e4b), decoupled from imageMask so
60
+ * audio-only prompts (no bidirectional mask) still zero their positions.
61
+ * When unset, zeroing falls back to imageMask (the legacy vision shape).
62
+ * Caller keeps ownership. */
63
+ multimodalMask?: MlxArray;
42
64
  /** Quantize full-attention KV caches to this many bits (4 or 8).
43
65
  * Rotating (sliding-window) caches stay bf16 — they're window-capped
44
66
  * and upstream rotating-cache quantization is NYI. */
@@ -52,6 +74,22 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
52
74
  /** Convert once a cache's offset reaches this (uniform-kvBits default
53
75
  * 5000 = mlx-lm; kvConfig default 0 = optiq serve). */
54
76
  quantizedKvStart?: number;
77
+ /** TurboQuant scheme (docs/design/turboquant-kv.md): rotation-based KV
78
+ * quantization, a CLI-only runtime lever in the same class as uniform
79
+ * kvBits (mutually exclusive with kvBits/kvConfig — maybeQuantizeKv
80
+ * checks kvBits/kvConfig first, so set at most one). Full-attention
81
+ * KVCache layers convert via TurboQuantKVCache.fromKVCache;
82
+ * RotatingKVCache (sliding-window) layers stay bf16 in v1 — a one-time
83
+ * warning names the limitation, never a throw. */
84
+ turboQuant?: TurboQuantScheme;
85
+ /** OPTIONAL paged KV storage (docs/design/paged-kv-cache.md): fresh
86
+ * full-attention KVCache layers are replaced with PagedKVCache (block
87
+ * pool + gather-to-contiguous) before prefill. v1 scope: serial batch=1
88
+ * Gemma4-family, bf16 — mutually exclusive with kvBits/kvConfig/
89
+ * turboQuant/draft/compiled decode (callers refuse the combos; the
90
+ * cache swap itself only ever touches plain empty KVCache entries).
91
+ * Values are gated bit-exact vs the plain path (tests/paged-kv*). */
92
+ pagedKv?: { blockSize?: number };
55
93
  /** Mounted LoRA adapter ids to apply (resolved/validated by
56
94
  * AdapterManager.resolveSpec). Residuals sum in order. Set on the
57
95
  * model's LoraState for exactly the duration of this generation —
@@ -70,6 +108,13 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
70
108
  /** Capture the top-k (token id, logprob) pairs per emitted token from the
71
109
  * same distribution (mlx_lm.server `top_logprobs`). 0/unset = off. */
72
110
  topLogprobs?: number;
111
+ /** Grammar-constrained decoding (src/grammar.ts): a compiled GrammarController
112
+ * that masks invalid tokens to -inf each step. L2-class (oMLX oracle). When
113
+ * set, the decode loop takes a slightly different shape: it eager-reads the
114
+ * token id (acceptToken needs a JS number, which the pipelined loop defers),
115
+ * advances the matcher, and awaits the async mask precompute (which overlaps
116
+ * the GPU forward). Non-grammar requests keep the fast pipelined loop. */
117
+ grammar?: GrammarController;
73
118
  }
74
119
 
75
120
  /** Per-token logprob info (only present when GenerateOptions requested it). */
@@ -82,14 +127,35 @@ export interface TokenLogprobs {
82
127
  top?: { id: number; logprob: number }[];
83
128
  }
84
129
 
130
+ /** Materialize every cache's state at a prefill chunk boundary. Unlike
131
+ * KVCache/QuantizedKVCache/RotatingKVCache, TurboQuantKVCache.state()
132
+ * allocates FRESH trimmed slice views on every call (required by its
133
+ * snapshotCache/cloneKvCaches callers in kv-store.ts, which already
134
+ * dispose them) rather than returning its own live-owned arrays — so
135
+ * this chokepoint must dispose that cache kind's state() output itself,
136
+ * or the views leak (unreferenced past this call, only reclaimed by the
137
+ * FinalizationRegistry backstop on GC of the tiny JS wrapper). */
138
+ export function evalCacheState(cache: Cache[]): void {
139
+ const turboState = cache.flatMap((c) => (c instanceof TurboQuantKVCache ? c.state() : []));
140
+ try {
141
+ const liveState = cache.flatMap((c) => (c instanceof TurboQuantKVCache ? [] : c.state()));
142
+ ops.evalAll([...turboState, ...liveState]);
143
+ } finally {
144
+ // Disposed even when evalAll throws (Metal OOM / deferred graph error) —
145
+ // the whole reason this chokepoint exists is that these views are owned.
146
+ for (const a of turboState) a.dispose();
147
+ }
148
+ }
149
+
85
150
  /** Port of mlx-lm maybe_quantize_kv_cache + BOTH halves of optiq serve's
86
151
  * per-layer patched variant (incl. patch_rotating_to_quantized: rotating
87
152
  * caches convert too — Phase 9):
88
153
  * - per-layer bits/group_size selection (kvConfig overrides kvBits,
89
154
  * matching optiq's --kv-config precedence; shipped kv_config.json
90
- * files list full-attention layers only, so rotating quantization
91
- * engages through uniform kvBits — like optiq --kv-bits — or a config
92
- * that names sliding layers), and
155
+ * files cover EVERY cache-owning layer, sliding ones included —
156
+ * verified 12B 48/48, 26B 30/30, e4b 24/24 distinct caches — so
157
+ * rotating quantization engages straight from the config; uniform
158
+ * kvBits — like optiq --kv-bits — reaches them too), and
93
159
  * - STREAMING conversion (optiq streaming_kv_quant / serve.py
94
160
  * patched_maybe_quantize): eval each layer's quantized triples and
95
161
  * clear the buffer pool before building the next layer's conversion.
@@ -99,7 +165,11 @@ export interface TokenLogprobs {
99
165
  * 24 GB Mac). Numerics untouched: same quantize math, only the eval
100
166
  * ordering is forced (tests/kv-quant.test.ts, tests/rotating-kvq.test.ts). */
101
167
  export function maybeQuantizeKv(cache: Cache[], options: GenerateOptions): void {
102
- const { kvBits, kvConfig } = options;
168
+ const { kvBits, kvConfig, turboQuant } = options;
169
+ if (turboQuant) {
170
+ maybeTurboQuantizeKv(cache, turboQuant, options.quantizedKvStart ?? 0);
171
+ return;
172
+ }
103
173
  if (!kvBits && !kvConfig?.length) return;
104
174
  const start = options.quantizedKvStart ?? (kvConfig?.length ? 0 : 5000);
105
175
  const byLayer = kvConfig?.length
@@ -128,6 +198,70 @@ export function maybeQuantizeKv(cache: Cache[], options: GenerateOptions): void
128
198
  }
129
199
  }
130
200
 
201
+ /** Paged-KV conversion (docs/design/paged-kv-cache.md): swap each FRESH
202
+ * plain full-attention KVCache for a PagedKVCache sized to hold
203
+ * `capacityTokens` (prompt + maxTokens — known exactly at generate()
204
+ * setup, so pool exhaustion is unreachable absent an accounting bug).
205
+ * Same in-place-mutation shape as maybeQuantizeKv, but runs ONCE before
206
+ * prefill: paging changes storage layout, not arithmetic, so there is no
207
+ * "convert when populated" trigger. Sliding-window (RotatingKVCache)
208
+ * layers keep today's scheme — mixed paged-full + rotating-sliding is
209
+ * the supported v1 shape. Pre-warmed caches (offset > 0) skip conversion
210
+ * entirely: the serve lane bypasses prompt-cache reuse for paged
211
+ * requests, so this only guards library callers. */
212
+ export function maybePageKv(
213
+ cache: Cache[], options: GenerateOptions, capacityTokens: number,
214
+ ): void {
215
+ if (!options.pagedKv) return;
216
+ if (cache.some((c) => c.offset > 0)) return;
217
+ const blockSize = options.pagedKv.blockSize ?? PagedKVCache.DEFAULT_BLOCK_SIZE;
218
+ for (let i = 0; i < cache.length; i++) {
219
+ if (cache[i] instanceof KVCache) {
220
+ cache[i]!.dispose(); // fresh (offset 0) — nothing stored yet
221
+ cache[i] = new PagedKVCache(capacityTokens, blockSize);
222
+ }
223
+ }
224
+ }
225
+
226
+ /** Emitted once per process: RotatingKVCache (sliding-window) layers are a
227
+ * documented v1 non-goal (docs/design/turboquant-kv.md) — they stay bf16
228
+ * rather than throwing, so mixed full-attention/sliding-window models (e.g.
229
+ * Gemma) still serve correctly under --kv-quant turbo. */
230
+ let warnedTurboRotating = false;
231
+
232
+ /** TurboQuant conversion chokepoint (mirrors the uniform/config branch
233
+ * above): only plain full-attention KVCache instances convert, via
234
+ * TurboQuantKVCache.fromKVCache — RotatingKVCache stays bf16 (warn once,
235
+ * never throw). Same offset===0 skip-empty-cache rule as the affine path. */
236
+ function maybeTurboQuantizeKv(cache: Cache[], scheme: TurboQuantScheme, start: number): void {
237
+ for (let i = 0; i < cache.length; i++) {
238
+ const c = cache[i]!;
239
+ if (c instanceof RotatingKVCache) {
240
+ if (!warnedTurboRotating) {
241
+ warnedTurboRotating = true;
242
+ console.warn(
243
+ "[turbo-quant] sliding-window (RotatingKVCache) layers stay bf16 in v1 " +
244
+ "(full-attention only) — docs/design/turboquant-kv.md.",
245
+ );
246
+ }
247
+ continue;
248
+ }
249
+ if (!(c instanceof KVCache) || c.offset < start || c.offset === 0) continue;
250
+ const tq = TurboQuantKVCache.fromKVCache(c, scheme.kBits, scheme.vBits);
251
+ cache[i] = tq;
252
+ // state() allocates fresh trimmed slice views for this cache kind
253
+ // (see evalCacheState) — dispose after materializing (throw included),
254
+ // or they leak.
255
+ const state = tq.state();
256
+ try {
257
+ ops.evalAll(state);
258
+ } finally {
259
+ for (const a of state) a.dispose();
260
+ }
261
+ clearCache();
262
+ }
263
+ }
264
+
131
265
  export interface GenerateStats {
132
266
  promptTokens: number;
133
267
  /** Prompt tokens skipped via a pre-warmed cache. */
@@ -141,6 +275,14 @@ export interface GenerateStats {
141
275
  * decoded token that was forwarded, including a trailing EOS the
142
276
  * pipeline forwarded before reading it). For PromptCache.put(). */
143
277
  cacheTokens: number[];
278
+ /** Speculative-decoding telemetry (serve --draft-model path only).
279
+ * draftedByPos/acceptedByPos: per-draft-position round counts (index =
280
+ * position within a round's block) — the Phase-1c per-position
281
+ * acceptance signal. */
282
+ spec?: {
283
+ drafted: number; accepted: number; targetCalls: number;
284
+ draftedByPos?: number[]; acceptedByPos?: number[];
285
+ };
144
286
  }
145
287
 
146
288
  export interface GeneratedToken {
@@ -324,6 +466,11 @@ async function* generateInner(
324
466
  throw new Error(
325
467
  `pre-warmed cache (${cachedTokens} tokens) must be a strict prefix of the prompt (${promptTokens.length})`,
326
468
  );
469
+ // Paged KV (default off): swap fresh full-attention caches for paged
470
+ // storage BEFORE prefill. Capacity is exact — the deepest write lands at
471
+ // prompt + maxTokens − 1 (step maxTokens−1's forward), so this bound
472
+ // makes pool exhaustion an accounting-bug tripwire, not a request limit.
473
+ maybePageKv(cache, options, promptTokens.length + maxTokens);
327
474
  /** device-side token history (only maintained when processors need it) */
328
475
  let history: MlxArray | null = null;
329
476
 
@@ -367,6 +514,10 @@ async function* generateInner(
367
514
  const V = logits3d.shape[2]!;
368
515
  let logits = ops.reshape(logits3d, [1, V]);
369
516
  for (const p of processors) logits = disposing(logits, p(history, logits));
517
+ // Grammar mask (src/grammar.ts): apply AFTER the standard processors so the
518
+ // grammar has the final say on validity (a -inf'd token stays -inf through
519
+ // log-softmax). Off (no grammar) = zero cost — the branch is unset.
520
+ if (options.grammar) logits = disposing(logits, options.grammar.applyMask(logits));
370
521
  const lp = toLogprobs(logits);
371
522
  logits.dispose();
372
523
  const tok = sampler(lp, step);
@@ -444,21 +595,65 @@ async function* generateInner(
444
595
  if (needsTokenHistory)
445
596
  history = ops.fromInt32(promptTokens, [promptTokens.length]);
446
597
  // e2b/e4b need the spliced token ids to build per-layer inputs
447
- // (image positions zeroed inside forwardEmbeddings).
598
+ // (multimodal soft-token positions zeroed inside forwardEmbeddings).
448
599
  const embedIds = ops.fromInt32(promptTokens, [1, promptTokens.length]);
449
600
  h0 = model.forwardEmbeddings(
450
601
  options.promptEmbeddings, cache, options.imageMask ?? null, embedIds,
602
+ options.multimodalMask ?? null,
451
603
  );
452
604
  embedIds.dispose();
453
605
  } else {
454
606
  let pos = cachedTokens;
455
- while (promptTokens.length - pos > prefillChunkSize) {
456
- const chunk = promptTokens.slice(pos, pos + prefillChunkSize);
607
+ // Stable-boundary snapshot: prefill up to snapshotAt first (plain
608
+ // chunks, no logits), fire the hook while the caches hold exactly
609
+ // that prefix, then continue with the remainder below. Splitting here
610
+ // changes chunk shapes only for requests that opted in — the same
611
+ // numerics class as a prompt-cache-hit continuation prefill.
612
+ const snapAt = options.onPrefillDone && options.snapshotAt !== undefined
613
+ ? Math.min(Math.max(options.snapshotAt, cachedTokens), promptTokens.length)
614
+ : promptTokens.length;
615
+ if (snapAt > pos && snapAt < promptTokens.length) {
616
+ while (pos < snapAt) {
617
+ const chunk = promptTokens.slice(pos, Math.min(pos + prefillChunkSize, snapAt));
618
+ const ids = ops.fromInt32(chunk, [1, chunk.length]);
619
+ const h = model.forwardHidden(ids, cache);
620
+ ids.dispose();
621
+ h.dispose();
622
+ evalCacheState(cache);
623
+ maybeQuantizeKv(cache, options);
624
+ clearCache();
625
+ pos += chunk.length;
626
+ // Macrotask yield between chunks (runtime-isolation.md Phase 1):
627
+ // each chunk is a synchronous multi-hundred-ms FFI eval; without
628
+ // this the event loop serves no I/O for the WHOLE prefill.
629
+ await new Promise<void>((r) => setImmediate(r));
630
+ }
631
+ options.onPrefillDone?.();
632
+ }
633
+ // Oracle prefill convention (mlx-lm 0.31.3 generate_step,
634
+ // generate.py:430-453): drain the prompt only to len-1 — chunks of
635
+ // min(prefillChunkSize, remaining-1) — then compute step-0 logits from
636
+ // a SEPARATE L=1 forward of the LAST prompt token (its server's
637
+ // batched engine shares the convention: insert_segments' forced final
638
+ // 1-token segment + GenerationBatch._step, generate.py:1645/1327).
639
+ // Forwarding the last token as the tail of an L=n GEMM instead is
640
+ // ulp-different in bf16 (qmm-vs-qmv + L-dependent SDPA dispatch) in
641
+ // BOTH the step-0 logits and that token's stored KV — near-tie greedy
642
+ // streams flip (2026-07-07 12B completion-probe ✗: first token flip at
643
+ // step 24). MLX_BUN_PREFILL_TAIL_SPLIT=0 restores the old
644
+ // full-final-chunk convention (A/B lever + kill switch).
645
+ const tailSplit = flagOn("MLX_BUN_PREFILL_TAIL_SPLIT", true);
646
+ const drainGate = tailSplit ? 1 : prefillChunkSize;
647
+ while (promptTokens.length - pos > drainGate) {
648
+ const n = tailSplit
649
+ ? Math.min(prefillChunkSize, promptTokens.length - pos - 1)
650
+ : prefillChunkSize;
651
+ const chunk = promptTokens.slice(pos, pos + n);
457
652
  const ids = ops.fromInt32(chunk, [1, chunk.length]);
458
653
  const h = model.forwardHidden(ids, cache);
459
654
  ids.dispose();
460
655
  h.dispose(); // logits never computed for non-final chunks
461
- ops.evalAll(cache.flatMap((c) => c.state()));
656
+ evalCacheState(cache);
462
657
  maybeQuantizeKv(cache, options);
463
658
  // mlx-lm _prefill clears the allocator cache after every chunk;
464
659
  // without this, prefill transients pile up in the buffer cache
@@ -467,16 +662,26 @@ async function* generateInner(
467
662
  // measured, scripts/decode-split.ts; the context-scaling decode
468
663
  // gap's main term).
469
664
  clearCache();
470
- pos += prefillChunkSize;
665
+ pos += n;
666
+ // Macrotask yield between chunks (runtime-isolation.md Phase 1).
667
+ await new Promise<void>((r) => setImmediate(r));
471
668
  }
472
669
  if (needsTokenHistory) {
473
670
  history = ops.fromInt32(promptTokens, [promptTokens.length]);
474
671
  }
672
+ // Under tailSplit this is exactly [promptTokens[len-1]] — the L=1
673
+ // step-0 forward (uncompiled L=1 is bit-exact with compiled decode,
674
+ // tests/compiled-decode.test.ts). The last prompt token's KV enters
675
+ // the cache HERE, so downstream bookkeeping (forwarded[], cacheTokens,
676
+ // PromptCache.put alignment) is unchanged: after step 0 the caches
677
+ // cover exactly the prompt, as before.
475
678
  const lastChunk = promptTokens.slice(pos);
476
679
  const ids0 = ops.fromInt32(lastChunk, [1, lastChunk.length]);
477
680
  h0 = model.forwardHidden(ids0, cache);
478
681
  ids0.dispose();
479
682
  }
683
+ if (options.snapshotAt === undefined || options.snapshotAt >= promptTokens.length)
684
+ options.onPrefillDone?.();
480
685
  const [, L0, H] = h0.shape as [number, number, number];
481
686
  const hLast = h0.slice([0, L0 - 1, 0], [1, L0, H]);
482
687
  h0.dispose();
@@ -508,30 +713,111 @@ async function* generateInner(
508
713
  // 0.6.0, and shapeless replay re-infers the whole tape whenever the
509
714
  // growing attention windows change shape (= every step). Remove this
510
715
  // when upstream implements GatherQMM::output_shapes.
511
- // MLX_BUN_FUSED_DECODE=1 (the N-tiled fused decode experiment) cannot
512
- // compose with compiled decode: the tile loop's bounds bake at trace-time
513
- // N, so growing quantized caches silently stop attending their newest
514
- // rows under shapeless replay (kernel-perf-review 2026-07; reproduced on
515
- // e4b). The explicit opt-in wins over the default optimization — same
516
- // policy as LoRA and MoE below. quantizedSdpa also throws on the combo
517
- // as a backstop.
518
716
  let compiled =
519
- process.env.MLX_BUN_COMPILED_DECODE !== "0" &&
520
- process.env.MLX_BUN_FUSED_DECODE !== "1" &&
717
+ flagOn("MLX_BUN_COMPILED_DECODE", true) &&
521
718
  !options.adapters?.length &&
719
+ // Paged caches can't compile (data-dependent block-list length —
720
+ // the shapeless-replay hazard; CompiledDecode.supports() also
721
+ // excludes them per step, this just skips the setup).
722
+ !options.pagedKv &&
522
723
  model.config.modelType.startsWith("gemma4") &&
523
724
  !model.config.text.enableMoeBlock
524
725
  ? CompiledDecode.for(model as Gemma4Model)
525
726
  : null;
526
727
  let stop = false;
728
+ /** Token id read eagerly at the top of the loop for grammar (reused for
729
+ * the yield, avoiding a second readback). -1 when grammar is off — the
730
+ * pipelined path keeps its deferred itemUint32 below. */
731
+ let grammarTok = -1;
732
+ // Jump-forward decoding (opt-in, MLX_BUN_GRAMMAR_JUMP=1; serial lane
733
+ // only — the batch lane's #stepGrammar doesn't jump yet): when the
734
+ // grammar forces a unique continuation, emit its retokenized ids without
735
+ // per-token forwards — ONE multi-token forward carries them into the KV
736
+ // (see GrammarController.jumpForward for the contract + the fidelity
737
+ // note on why this is opt-in). Excluded when logprobs are requested
738
+ // (jumped tokens are never sampled, so they'd have no logprobs rows).
739
+ const grammarJump =
740
+ options.grammar !== undefined &&
741
+ flagOn("MLX_BUN_GRAMMAR_JUMP", false) &&
742
+ !options.logprobs;
527
743
  while (!stop) {
528
744
  const cur = pending!;
529
745
  const curExtras = pendingExtras;
530
746
  pendingExtras = null;
747
+ // Grammar advance (src/grammar.ts): acceptToken needs the token id as a
748
+ // JS number, which the pipelined loop defers (it operates on device
749
+ // arrays). So grammar requests eager-read cur here, advance the matcher,
750
+ // and await the async mask precompute — which overlaps the GPU forward
751
+ // dispatched just below. This trades the readback/forward overlap for
752
+ // correctness (the mask for step n+1 must reflect token n). Non-grammar
753
+ // requests keep the fast pipelined loop untouched.
754
+ //
755
+ // F1 fix (batched-lane plan): always eager-read `grammarTok` when
756
+ // grammar is on, even at the max_tokens boundary — the emitted token
757
+ // reuses it unconditionally. The OLD code gated the READ on
758
+ // generated+1 < maxTokens but emitted grammarTok regardless, so the LAST
759
+ // iteration skipped the refresh and emitted a stale/garbage token (the
760
+ // previous step's token, or -1 when max_tokens=1) → truncated JSON ended
761
+ // on a corrupted token + cacheTokens recorded the wrong id. Now only
762
+ // accept()/ready() (which prepare the NEXT step's mask) are gated.
763
+ /** Forced ids to emit after cur this iteration (jump-forward), else null. */
764
+ let jumpEmit: number[] | null = null;
765
+ if (options.grammar) {
766
+ grammarTok = ops.itemUint32(cur);
767
+ if (generated + 1 < maxTokens) {
768
+ options.grammar.accept(grammarTok);
769
+ await options.grammar.ready();
770
+ if (grammarJump && !options.grammar.isTerminated) {
771
+ jumpEmit = options.grammar.jumpForward(maxTokens - (generated + 1));
772
+ // jumpForward advanced the matcher and fired the post-jump mask
773
+ // fill; it must be ready before this iteration's sampleStep.
774
+ if (jumpEmit) await options.grammar.ready();
775
+ }
776
+ }
777
+ }
531
778
  // build step n+1's graph from the *unread* pending token
532
779
  nextPending = null;
533
780
  nextExtras = null;
534
- if (generated + 1 < maxTokens) {
781
+ // When the grammar has terminated (a complete valid JSON/schema
782
+ // accepted), there are no valid tokens left — skip building the next
783
+ // step so the sampler never sees an all--inf distribution.
784
+ if (jumpEmit) {
785
+ // JUMP iteration: one [1, 1+m] forward carries cur AND the forced ids
786
+ // into the KV (they are all committed content — jumpForward's
787
+ // contract); the next sampled token, if the budget and grammar allow
788
+ // one, comes from its last position. Compiled decode resumes on the
789
+ // following iteration (supports() re-checks the grown caches).
790
+ maybeQuantizeKv(cache, options);
791
+ pushHistory(cur);
792
+ if (needsTokenHistory) {
793
+ const jt = ops.fromInt32(jumpEmit, [jumpEmit.length]);
794
+ const prev = history!;
795
+ history = ops.concatAxis([prev, jt], 0);
796
+ prev.dispose();
797
+ jt.dispose();
798
+ }
799
+ const chunk = [grammarTok, ...jumpEmit];
800
+ const ids = ops.fromInt32(chunk, [1, chunk.length]);
801
+ const h = model.forwardHidden(ids, cache);
802
+ ids.dispose();
803
+ // Every chunk token's KV is in the cache regardless of what follows.
804
+ forwarded.push(...chunk);
805
+ const willGen = generated + 1 + jumpEmit.length;
806
+ if (willGen < maxTokens && !options.grammar!.isTerminated) {
807
+ const [, Lj, Hj] = h.shape as [number, number, number];
808
+ const hLast = h.slice([0, Lj - 1, 0], [1, Lj, Hj]);
809
+ h.dispose();
810
+ const logits = model.logitsFromHidden(hLast);
811
+ hLast.dispose();
812
+ const sn = sampleStep(logits, willGen);
813
+ nextPending = sn.tok;
814
+ nextExtras = sn.extras;
815
+ logits.dispose();
816
+ ops.asyncEvalAll([nextPending, ...extrasArrays(nextExtras)]);
817
+ } else {
818
+ h.dispose(); // burst ends the generation (max_tokens or grammar done)
819
+ }
820
+ } else if (generated + 1 < maxTokens && !options.grammar?.isTerminated) {
535
821
  maybeQuantizeKv(cache, options);
536
822
  pushHistory(cur);
537
823
  let logits: MlxArray | null = null;
@@ -566,7 +852,9 @@ async function* generateInner(
566
852
  }
567
853
 
568
854
  // sync-read step n's token while n+1 computes
569
- const token = ops.itemUint32(cur);
855
+ // sync-read step n's token while n+1 computes (grammar already read it
856
+ // eagerly above — reuse to avoid a second GPU sync)
857
+ const token = options.grammar ? grammarTok : ops.itemUint32(cur);
570
858
  if (generated === 0) {
571
859
  // first token arrived: prompt clock stops, decode clock starts
572
860
  // (mlx-lm stream_generate's n==0 clock swap; the first token is
@@ -578,7 +866,8 @@ async function* generateInner(
578
866
  pending = null;
579
867
  generated++;
580
868
  // if a next-step graph was built, this token's KV entered the cache
581
- if (nextPending !== null) forwarded.push(token);
869
+ // (jump iterations pushed the whole chunk already)
870
+ if (nextPending !== null && !jumpEmit) forwarded.push(token);
582
871
 
583
872
  if (eosTokenIds.includes(token)) {
584
873
  disposeExtras(curExtras);
@@ -595,6 +884,18 @@ async function* generateInner(
595
884
  // mlx-lm generate_step: clear_cache after token 0 (drops the
596
885
  // remaining prefill transients) and every 256 tokens after
597
886
  if ((generated - 1) % 256 === 0) clearCache();
887
+ // Jump-forward burst: the forced ids follow cur, one yield each (the
888
+ // consumer's stop-sequence matcher and detokenizer see the same
889
+ // one-at-a-time stream shape as always). Their KV is already in the
890
+ // cache (the chunk forward above); a consumer break mid-burst is
891
+ // safe — `forwarded` already reflects the cache exactly.
892
+ if (jumpEmit) {
893
+ for (const jt of jumpEmit) {
894
+ generated++;
895
+ yield { token: jt, index: generated - 1 };
896
+ if ((generated - 1) % 256 === 0) clearCache();
897
+ }
898
+ }
598
899
  if (nextPending === null) {
599
900
  stop = true;
600
901
  } else {
@@ -619,6 +920,11 @@ async function* generateInner(
619
920
  disposeExtras(nextExtras);
620
921
  }
621
922
  if (ownsCache) for (const c of cache) c.dispose();
923
+ // The grammar controller owns native WASM state (GrammarMatcher +
924
+ // TokenizerInfo cache) — dispose on every exit path (normal, early break,
925
+ // throw). TokenizerInfo itself is process-cached (vocab-structural), so
926
+ // only the per-request matcher/compiled are freed here.
927
+ options.grammar?.dispose();
622
928
  history?.dispose();
623
929
  if (!finished && !threw) {
624
930
  // forced early return (consumer break at a yield): still report