mlx-bun 0.4.0 → 0.5.0

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 (158) hide show
  1. package/README.md +57 -32
  2. package/docs/README.md +55 -42
  3. package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
  4. package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
  5. package/docs/design/batching.md +220 -7
  6. package/docs/design/decode-speed-program.md +121 -66
  7. package/docs/design/generic-model-support.md +116 -0
  8. package/docs/design/kv-cache.md +380 -211
  9. package/docs/design/speculative-decoding.md +165 -26
  10. package/docs/design/turboquant.md +20 -7
  11. package/docs/design/unified-engine-frontier-plan.md +120 -13
  12. package/docs/planning/PRODUCT_ROADMAP.md +5 -6
  13. package/docs/planning/public-readiness-handoff.md +244 -0
  14. package/docs/planning/public-readiness.md +149 -0
  15. package/docs/reference/benchmarks.md +3463 -3489
  16. package/docs/reference/cli.md +62 -1
  17. package/docs/reference/distribution.md +29 -4
  18. package/docs/reference/environment.md +104 -8
  19. package/docs/reference/library-api.md +18 -4
  20. package/docs/reference/memory.md +1 -1
  21. package/docs/reference/models.md +17 -5
  22. package/docs/reference/server-api.md +181 -11
  23. package/docs/reference/server-config.md +232 -105
  24. package/package.json +2 -2
  25. package/src/audio/audiotoolbox.ts +112 -0
  26. package/src/audio/conformer.ts +8 -0
  27. package/src/audio/mic-capture.ts +98 -0
  28. package/src/audio/silero-vad.ts +379 -0
  29. package/src/audio/whisper-decode.ts +774 -0
  30. package/src/audio/whisper-fast.ts +732 -0
  31. package/src/audio/whisper-format.ts +73 -0
  32. package/src/audio/whisper-mel.ts +185 -0
  33. package/src/audio/whisper-timing.ts +274 -0
  34. package/src/audio/whisper-tokenizer.ts +239 -0
  35. package/src/audio/whisper-transcribe.ts +410 -0
  36. package/src/backends/mlx/autoregressive.ts +22 -1
  37. package/src/backends/mlx/batch-group.ts +111 -27
  38. package/src/backends/mlx/cache-layout.ts +3 -3
  39. package/src/backends/mlx/continuation-persistence.ts +5 -4
  40. package/src/backends/mlx/encoder-cache.ts +25 -0
  41. package/src/backends/mlx/fill-append.ts +21 -8
  42. package/src/backends/mlx/fill-group.ts +497 -0
  43. package/src/backends/mlx/gateway-binding.ts +56 -12
  44. package/src/backends/mlx/kv-maintenance.ts +30 -2
  45. package/src/backends/mlx/mixed-iteration.ts +62 -0
  46. package/src/backends/mlx/model-prompt.ts +46 -32
  47. package/src/backends/mlx/model-serving.ts +4 -3
  48. package/src/backends/mlx/paged-cache-codec.ts +27 -0
  49. package/src/backends/mlx/prefill-cohort.ts +15 -4
  50. package/src/backends/mlx/prefill-policy.ts +26 -0
  51. package/src/backends/mlx/prefill-rows.ts +57 -13
  52. package/src/backends/mlx/prompt-input.ts +29 -0
  53. package/src/backends/mlx/qwen-prompt-input.ts +40 -0
  54. package/src/backends/mlx/request-state-policy.ts +18 -5
  55. package/src/backends/mlx/rollback.ts +6 -3
  56. package/src/backends/mlx/serial-executor.ts +16 -7
  57. package/src/backends/mlx/speculative-group.ts +105 -26
  58. package/src/backends/mlx/speculative.ts +24 -7
  59. package/src/backends/mlx/target-layout-capability.ts +1 -1
  60. package/src/backends/mlx/transitioning-kv-rows.ts +8 -0
  61. package/src/cli.ts +322 -0
  62. package/src/contracts/execution.ts +10 -0
  63. package/src/contracts/object-cache.ts +8 -0
  64. package/src/contracts/pi-provider.ts +1 -1
  65. package/src/contracts/pi.ts +3 -1
  66. package/src/contracts/prefix-cache.ts +13 -3
  67. package/src/contracts/scheduling.ts +9 -0
  68. package/src/engine/execution-plan.ts +23 -12
  69. package/src/engine/execution-tasks.ts +44 -0
  70. package/src/engine/namespaced-cache.ts +13 -0
  71. package/src/engine/scheduler.ts +22 -2
  72. package/src/fill/fill-session.ts +14 -3
  73. package/src/generate.ts +28 -22
  74. package/src/grammar.ts +13 -0
  75. package/src/inference/prefill.ts +6 -0
  76. package/src/kv-scheme.ts +22 -0
  77. package/src/kv-store.ts +115 -79
  78. package/src/lab/README.md +5 -1
  79. package/src/lab/paged-kv/paged-attention.ts +117 -0
  80. package/src/lab/paged-kv/paged-kv-rows.ts +33 -5
  81. package/src/lab/paged-kv/paged-kv.ts +108 -42
  82. package/src/md-modules.d.ts +5 -0
  83. package/src/mlx/array.ts +37 -0
  84. package/src/mlx/ffi.ts +19 -0
  85. package/src/mlx/normalized-argmax.ts +116 -0
  86. package/src/mlx/ops.ts +97 -10
  87. package/src/model/batched-turboquant-kv.ts +6 -5
  88. package/src/model/delayed-quantized-kv.ts +3 -3
  89. package/src/model/delayed-rotating-quantized-kv.ts +1 -1
  90. package/src/model/delayed-turboquant-kv.ts +6 -4
  91. package/src/model/factory.ts +26 -0
  92. package/src/model/fingerprint.ts +12 -0
  93. package/src/model/full-prefill-row.ts +1 -1
  94. package/src/model/gemma4-base.ts +25 -6
  95. package/src/model/gemma4.ts +82 -2
  96. package/src/model/kv-attention-view.ts +4 -1
  97. package/src/model/profile.ts +29 -4
  98. package/src/model/quantized-append-attention.ts +31 -0
  99. package/src/model/qwen3-mrope.ts +7 -9
  100. package/src/model/qwen3_5.ts +99 -37
  101. package/src/model/support.ts +8 -0
  102. package/src/model/token-groups.ts +66 -0
  103. package/src/model/trellis-linear.ts +2 -2
  104. package/src/model/turboquant-codec.ts +6 -0
  105. package/src/model/whisper.ts +435 -0
  106. package/src/native/mic_capture.swift +92 -0
  107. package/src/native-pack.ts +6 -5
  108. package/src/pi-web.ts +9 -1
  109. package/src/prompt-cache.ts +265 -43
  110. package/src/runtime-config.ts +1 -5
  111. package/src/safetensors.ts +9 -2
  112. package/src/sampler.ts +5 -4
  113. package/src/serve/audio-routes.ts +323 -0
  114. package/src/serve/chat-request.ts +2 -0
  115. package/src/serve/chat-stage.ts +17 -12
  116. package/src/serve/completion-executor.ts +9 -1
  117. package/src/serve/discovery-routes.ts +17 -1
  118. package/src/serve/generated-token-history.ts +57 -0
  119. package/src/serve/generation-gateway.ts +52 -11
  120. package/src/serve/model-binding.ts +7 -1
  121. package/src/serve/preparation.ts +3 -3
  122. package/src/serve/prompt-response-trace.ts +14 -0
  123. package/src/serve/request-plan.ts +2 -0
  124. package/src/serve/request-prep.ts +38 -21
  125. package/src/serve/transcription-server.ts +64 -0
  126. package/src/serve/transcription-service.ts +502 -0
  127. package/src/server.ts +132 -164
  128. package/src/spec/assistant-rows.ts +1 -0
  129. package/src/spec/deepspec-rows.ts +1 -1
  130. package/src/spec/dflash-source.ts +1 -1
  131. package/src/spec/glm52-mtp-source.ts +1 -0
  132. package/src/spec/ngram-source.ts +27 -5
  133. package/src/spec/projected-draft-rows.ts +2 -2
  134. package/src/spec/qwen-mtp-rows.ts +11 -0
  135. package/src/spec/qwen-mtp-source.ts +3 -0
  136. package/src/spec/serve-loop.ts +5 -3
  137. package/src/spec/source.ts +14 -1
  138. package/src/spec/two-model-rows.ts +1 -0
  139. package/src/spec/two-model.ts +2 -2
  140. package/src/ssd-cache.ts +76 -17
  141. package/src/ssd-durability.ts +20 -22
  142. package/src/storage/host-buffer.ts +24 -0
  143. package/src/storage/kv-writer.ts +83 -0
  144. package/src/storage/kv-writer.worker.js +264 -0
  145. package/src/storage/retention-policy.ts +37 -0
  146. package/src/tiered-prompt-cache.ts +45 -0
  147. package/src/vision/embedder.ts +8 -0
  148. package/src/vision/prompt.ts +101 -65
  149. package/src/vision/qwen3vl-prompt.ts +101 -64
  150. package/src/vision/qwen3vl-tower.ts +11 -0
  151. package/src/vision/siglip.ts +8 -0
  152. package/src/web/app.html +12 -0
  153. package/src/web/app.js +160 -0
  154. package/src/web/src/chat.ts +4 -1
  155. package/src/web/src/composer.ts +3 -0
  156. package/src/web/src/voice.ts +141 -0
  157. package/src/web/sw.js +1 -1
  158. /package/docs/{planning → archive/planning}/release-notes-v0.4.0.md +0 -0
@@ -0,0 +1,497 @@
1
+ import type { GenerateOptions } from "../../generate";
2
+ import type { RuntimeModel } from "../../model/factory";
3
+ import type { Cache } from "../../model/gemma4-base";
4
+ import type { MlxArray } from "../../mlx/array";
5
+ import * as ops from "../../mlx/ops";
6
+ import { clearCache } from "../../mlx/ffi";
7
+ import { makeStepSampler, type DeviceStepSampler, type NumberStepSampler } from "../../sampler";
8
+ import type { FillSession, Proposal } from "../../fill/fill-session";
9
+ import { disposeResources, cleanupFailure, withResource } from "../../engine/resources";
10
+ import { snapshotGenerationPolicy } from "./request-policy";
11
+ import { MlxPrefillCohort } from "./prefill-cohort";
12
+ import { MlxStateRows } from "./state-rows";
13
+ import { targetCacheLayout } from "./cache-layout";
14
+ import { leaseCacheStates } from "./state-views";
15
+ import { createKvMaintenance, type KvMaintenance } from "./kv-maintenance";
16
+ import { bindLegacyAutoregressiveModel, supportsCommittedAppendCache, type MlxAutoregressiveBinding, type MlxTokenAppend } from "./autoregressive";
17
+ import { appendHiddenRows } from "./fill-append";
18
+ import { advanceSpeculativeOutputs } from "./speculative-round";
19
+ import { bindRowCacheRollback } from "./rollback";
20
+ import { bindSpeculativeTargetModel, type MlxSpeculativeTargetBinding } from "./speculative";
21
+ import type { MlxForwardWork } from "./mixed-iteration";
22
+ import type { MlxGroupedMethod, MlxGroupMethodHost, MlxGroupMethodRequest,
23
+ MlxGroupPreparation, Row } from "./batch-group";
24
+
25
+ interface RequestState {
26
+ sampling: DeviceStepSampler;
27
+ fill: FillSession;
28
+ proposal?: { value: Proposal; emitted: number };
29
+ }
30
+
31
+ /** Strict continuations share preparation, row layouts and queueing with other
32
+ * methods. Sampled tokens remain pipelined; committed tokens bypass sampling.
33
+ * This binding is qualified independently before serving advertises it. */
34
+ export function bindFillGroupRequests(model: RuntimeModel) {
35
+ return (input: GenerateOptions): MlxGroupMethodRequest => {
36
+ const options = snapshotGenerationPolicy(input);
37
+ return {
38
+ key: JSON.stringify(["fill", options.kvBits ?? null, options.kvGroupSize ?? 64,
39
+ options.quantizedKvStart ?? null, options.turboQuant ?? null, options.kvConfig ?? null]),
40
+ data: options,
41
+ open: host => new FillGroup(host, model, options),
42
+ };
43
+ };
44
+ }
45
+
46
+ class FillGroup implements MlxGroupedMethod {
47
+ readonly #binding: MlxAutoregressiveBinding;
48
+ readonly #maintain: KvMaintenance;
49
+ readonly #append: MlxTokenAppend | null | undefined;
50
+ readonly #verify: MlxSpeculativeTargetBinding;
51
+ readonly #requests = new Map<Row, RequestState>();
52
+ #target: MlxStateRows<ReturnType<typeof targetCacheLayout>> | null = null;
53
+ #pending: MlxArray | null = null;
54
+ #known: boolean[] = [];
55
+ #real: boolean[] = [];
56
+ #steps = 0;
57
+
58
+ constructor(readonly host: MlxGroupMethodHost, readonly model: RuntimeModel, options: GenerateOptions) {
59
+ this.#binding = bindLegacyAutoregressiveModel(model);
60
+ this.#verify = bindSpeculativeTargetModel(model);
61
+ this.#maintain = createKvMaintenance(options);
62
+ const append = this.#binding.createAppend?.({ hasAdapters: !!this.#binding.adapters?.active.length, pagedKv: false });
63
+ this.#append = supportsCommittedAppendCache(append, options) ? append : null;
64
+ }
65
+
66
+ get runningTokens(): number { return this.host.rows.length * Math.max(1, this.#burstLength(), this.#verifyLength()); }
67
+
68
+ #verifyLength(): number {
69
+ if (!this.#pending || !this.host.rows.length ||
70
+ this.host.rows.some(row => this.#requests.get(row)!.proposal?.value.policy === "assert")) return 0;
71
+ return Math.max(...this.host.rows.map(row => {
72
+ const proposal = this.#requests.get(row)!.proposal;
73
+ return proposal ? proposal.value.ids.length - proposal.emitted : 0;
74
+ }));
75
+ }
76
+
77
+ #burstLength(): number {
78
+ const rows = this.host.rows;
79
+ if (!rows.length || !this.#pending || this.#known.some(known => !known) ||
80
+ (this.#append?.maxChunkSize(this.#target!.caches, rows.length) ?? 1) <= 1) return 0;
81
+ return Math.min(...rows.map(row => {
82
+ const proposal = this.#requests.get(row)!.proposal;
83
+ return proposal?.value.policy === "assert" ? proposal.value.ids.length - proposal.emitted : 0;
84
+ }));
85
+ }
86
+
87
+ prepare(first: Row): MlxGroupPreparation {
88
+ const ready: { row: Row; caches: Cache[]; retain?: () => void }[] = [];
89
+ const target = new MlxPrefillCohort({
90
+ model: this.model, chunkSize: this.host.prefillChunkSize,
91
+ tailSplit: this.host.runtime.flag("MLX_BUN_PREFILL_TAIL_SPLIT", true),
92
+ promptCache: this.host.promptCache, maintain: this.#maintain,
93
+ forward: async (ids, caches) => this.#binding.graph.forwardHidden(ids, caches),
94
+ project: hidden => this.#binding.graph.projectLogits(hidden, { type: "all" }),
95
+ complete: async (state, logits) => {
96
+ const row = state.row;
97
+ const request = this.#requests.get(row)!;
98
+ using scores = ops.reshape(logits, [1, logits.shape.at(-1)!]);
99
+ using token = request.sampling.sample(scores, 0).token;
100
+ row.generated = row.sampled = 1;
101
+ try {
102
+ const reason = await this.#emit(row, token.toIntTokens()[0]!, false);
103
+ this.#maintain(state.solo);
104
+ if (reason) {
105
+ this.#put(row, state.solo, state.retain);
106
+ state.solo = []; state.retain = undefined;
107
+ this.#closeRequest(row); this.host.finish(row, reason);
108
+ } else {
109
+ ready.push({ row, caches: state.solo, retain: state.retain });
110
+ }
111
+ } catch (error) { this.#closeRequest(row); throw error; }
112
+ },
113
+ reject: (row, error) => { this.#closeRequest(row); row.reject(error); },
114
+ });
115
+ const admit = (row: Row) => {
116
+ const options = row.req.method!.data as GenerateOptions;
117
+ const request: RequestState = { fill: options.fill!,
118
+ sampling: makeStepSampler(options, { tokenRepresentation: "device", grammarWait: "external",
119
+ historyUpdate: "manual", initialHistory: row.req.promptIds }) };
120
+ row.fill = request.fill.stats;
121
+ this.#requests.set(row, request);
122
+ try { target.admit(row); }
123
+ catch (error) { this.#closeRequest(row); throw error; }
124
+ };
125
+ const preparation: MlxGroupPreparation = {
126
+ get rows() { return [...target.rows, ...ready.map(state => state.row)]; },
127
+ get canAdmit() { return target.canAdmit; },
128
+ get tokenWeight() { return target.tokenWeight + ready.reduce((sum, state) => sum + state.row.promptTokens - state.row.cachedTokens, 0); },
129
+ admit,
130
+ advance: async work => {
131
+ if (!ready.length) {
132
+ const done = await target.advance(work);
133
+ return done && !ready.length;
134
+ }
135
+ // Flush before membership changes. Prepared output was already
136
+ // published on the preceding scheduler iteration.
137
+ await this.#flush();
138
+ while (ready.length) {
139
+ const state = ready.shift()!;
140
+ try {
141
+ if (state.row.req.signal?.aborted) {
142
+ this.#closeRequest(state.row); state.row.reject(state.row.req.signal.reason); continue;
143
+ }
144
+ this.#maintain.prepareBatch?.(state.caches);
145
+ this.#target ??= new MlxStateRows(state.caches.map(targetCacheLayout));
146
+ this.#target.append(state.caches);
147
+ // Copies must settle before releasing a restored SSD lease.
148
+ if (state.retain) withResource(leaseCacheStates(this.#target.caches), arrays => ops.evalAll([...arrays]));
149
+ this.host.join(state.row);
150
+ } finally { disposeResources([...state.caches, { dispose: () => state.retain?.() }]); }
151
+ }
152
+ return !target.rows.length;
153
+ },
154
+ dispose: () => {
155
+ const rows = preparation.rows;
156
+ disposeResources([target, ...ready.splice(0).flatMap(state => [...state.caches,
157
+ { dispose: () => state.retain?.() }]), { dispose: () => {
158
+ for (const row of rows) if (!this.host.rows.includes(row)) this.#closeRequest(row);
159
+ } }]);
160
+ },
161
+ };
162
+ try { admit(first); return preparation; }
163
+ catch (error) { return cleanupFailure(error, () => preparation.dispose()); }
164
+ }
165
+
166
+ async advance(work?: MlxForwardWork): Promise<void> {
167
+ const live = this.host.rows.flatMap((row, index) => row.req.signal?.aborted ? [] : [index]);
168
+ if (live.length !== this.host.rows.length) {
169
+ for (const row of this.host.rows) if (row.req.signal?.aborted) row.reject(row.req.signal.reason);
170
+ this.host.filterRows(live);
171
+ }
172
+ const rows = [...this.host.rows], B = rows.length;
173
+ if (!B) return;
174
+ const verifyLength = this.#verifyLength();
175
+ if (!work && verifyLength > 1) { await this.#verifyKnown(verifyLength); return; }
176
+ const burst = this.#burstLength();
177
+ if (!work && burst > 1) { await this.#appendKnown(burst); return; }
178
+ const previous = this.#pending, known = this.#known, real = this.#real;
179
+ const anyLive = rows.some(row => row.sampled < row.req.maxTokens);
180
+ let next: MlxArray | null = null;
181
+ const nextKnown: boolean[] = [], nextReal: boolean[] = [];
182
+ if (anyLive) {
183
+ using input = previous ? ops.reshape(previous, [B, 1]) : ops.fromInt32(rows.map(row => row.current), [B, 1]);
184
+ using hidden = await (work ? work(input, this.#target!.caches) : this.#binding.graph.forwardHidden(input, this.#target!.caches));
185
+ const selected = rows.map((row, index) => {
186
+ const request = this.#requests.get(row)!;
187
+ // The next selected token is not committed until it becomes input.
188
+ // Discarding a speculative sample for an assert cannot pollute history.
189
+ if (request.sampling.needsHistory) {
190
+ using current = input.slice([index, 0], [index + 1, 1]);
191
+ request.sampling.commitDevice(current);
192
+ }
193
+ const value = request.proposal?.value.policy === "assert"
194
+ ? request.proposal.value.ids[request.proposal.emitted + Number(!!previous && known[index])] : undefined;
195
+ nextKnown.push(value !== undefined); nextReal.push(row.sampled < row.req.maxTokens);
196
+ return value;
197
+ });
198
+ const needScores = rows.some((_row, index) => nextReal[index] && !nextKnown[index]);
199
+ using logits = needScores ? this.#binding.graph.projectLogits(hidden, { type: "last" }) : null;
200
+ const tokens: MlxArray[] = [];
201
+ try {
202
+ for (let index = 0; index < B; index++) {
203
+ const row = rows[index]!;
204
+ if (!nextReal[index]) { tokens.push(ops.fromInt32([0], [1])); continue; }
205
+ if (nextKnown[index]) tokens.push(ops.fromInt32([selected[index]!], [1]));
206
+ else {
207
+ using slice = logits!.slice([index, 0, 0], [index + 1, 1, logits!.shape[2]!]);
208
+ using scores = ops.reshape(slice, [1, slice.shape[2]!]);
209
+ tokens.push(this.#requests.get(row)!.sampling.sample(scores, row.sampled).token);
210
+ }
211
+ row.sampled++;
212
+ }
213
+ next = ops.concatAxis(tokens, 0);
214
+ // Constant known tokens have no dependency on the model graph.
215
+ // Submit state explicitly when the vocabulary projection is omitted.
216
+ if (needScores) ops.asyncEvalAll([next]);
217
+ else withResource(leaseCacheStates(this.#target!.caches), state => ops.asyncEvalAll([next!, hidden, ...state]));
218
+ } catch (error) { next?.dispose(); next = null; throw error; }
219
+ finally { disposeResources(tokens); }
220
+ }
221
+ this.#pending = next; this.#known = nextKnown; this.#real = nextReal;
222
+ if (previous) {
223
+ let values: number[];
224
+ try { values = previous.toIntTokens(); } finally { previous.dispose(); }
225
+ if (anyLive) for (let index = 0; index < B; index++) {
226
+ if (real[index]) rows[index]!.fed.push(values[index]!);
227
+ else rows[index]!.fedTainted = true;
228
+ }
229
+ await this.#emitRows(values, known);
230
+ } else if (anyLive) for (const row of rows) row.fed.push(row.current);
231
+ this.#checkPendingProposals();
232
+ if (++this.#steps % 256 === 0) clearCache();
233
+ }
234
+
235
+ /** The pending sample already verifies an echo's first token. Asserted
236
+ * continuations keep their append policy; other rows can join a verified
237
+ * span with an empty proposal through the existing row verifier. */
238
+ #checkPendingProposals(): void {
239
+ if (!this.#pending) return;
240
+ const rows = this.host.rows;
241
+ if (!rows.some(row => this.#requests.get(row)!.proposal?.value.policy === "verify")) return;
242
+ const tokens = this.#pending.toIntTokens();
243
+ for (const [index, row] of rows.entries()) {
244
+ const request = this.#requests.get(row)!, proposal = request.proposal;
245
+ if (proposal?.value.policy !== "verify") continue;
246
+ if (tokens[index] === proposal.value.ids[proposal.emitted]) this.#known[index] = true;
247
+ else {
248
+ request.fill.commit(proposal.value, proposal.emitted);
249
+ request.proposal = undefined;
250
+ }
251
+ }
252
+ }
253
+
254
+ async #verifyKnown(width: number): Promise<void> {
255
+ // Publish the already verified pending token before changing geometry.
256
+ await this.#flush();
257
+ const rows = [...this.host.rows];
258
+ if (!rows.length) return;
259
+ // Publishing an ordinary row can discover a strict template span. Let
260
+ // its append policy choose the next operation before verifying echoes.
261
+ if (rows.some(row => this.#requests.get(row)!.proposal?.value.policy === "assert")) return;
262
+ const pending = rows.map(row => row.current);
263
+ const proposals = rows.map(row => {
264
+ const proposal = this.#requests.get(row)!.proposal;
265
+ // A newly discovered proposal cannot exceed this iteration's declared
266
+ // token work. Its remaining continuation stays request-owned.
267
+ return proposal?.value.policy === "verify"
268
+ ? proposal.value.ids.slice(proposal.emitted, proposal.emitted + width - 1) : [];
269
+ });
270
+ const depth = Math.max(...proposals.map(ids => ids.length));
271
+ const samplers = rows.map(row => {
272
+ const sampling = this.#requests.get(row)!.sampling;
273
+ sampling.commitNumbers([row.current]);
274
+ let previous: number | undefined;
275
+ // A second sample is requested only after the preceding candidate was
276
+ // accepted. Leave the final correction out of manual processor history
277
+ // until the next forward actually consumes it.
278
+ return { ...sampling,
279
+ async sample(scores, step) {
280
+ if (previous !== undefined) sampling.commitNumbers([previous]);
281
+ const result = sampling.sample(scores, step);
282
+ try { previous = result.token.toIntTokens()[0]!; return { token: previous, extras: result.extras }; }
283
+ finally { result.token.dispose(); }
284
+ },
285
+ } satisfies NumberStepSampler;
286
+ });
287
+ const halted = new Set<Row>();
288
+ const rollback = bindRowCacheRollback(this.#target!.caches, rows.length);
289
+ const verifying = rows.filter(row => this.#requests.get(row)!.proposal?.value.policy === "verify")
290
+ .map(row => this.#requests.get(row)!.fill);
291
+ const pin = this.#verify.pinVerify?.();
292
+ try {
293
+ const completed = await advanceSpeculativeOutputs(rows.map((row, index) => ({
294
+ pending: pending[index]!, step: row.sampled, remaining: row.req.maxTokens - row.generated,
295
+ eosTokenIds: row.req.eosTokenIds, sampling: samplers[index]!,
296
+ output: { commit: async ids => {
297
+ const proposal = this.#requests.get(row)!.proposal;
298
+ const known = proposal?.value.policy === "verify" && ids[0] === proposal.value.ids[proposal.emitted];
299
+ if (!known) this.#settleProposal(row);
300
+ row.generated++;
301
+ const reason = await this.#emit(row, ids[0]!, known);
302
+ if (reason === "stop") { halted.add(row); return false; }
303
+ } },
304
+ })), depth, { draft: () => proposals, commit: () => {} }, {
305
+ transaction: { ...rollback,
306
+ begin(depth) {
307
+ const start = performance.now(); rollback.begin(depth);
308
+ const elapsed = performance.now() - start;
309
+ for (const fill of verifying) fill.noteVerifyEvent(elapsed);
310
+ },
311
+ resolve(accepted) {
312
+ const start = performance.now(); rollback.resolve(accepted);
313
+ const elapsed = performance.now() - start;
314
+ for (const fill of verifying) fill.stats.checkpointMs += elapsed;
315
+ },
316
+ },
317
+ forward: async ids => {
318
+ const result = await this.#verify.forward(ids, this.#target!.caches);
319
+ const context = result.ctxML ?? result.hidden;
320
+ try { return { logits: this.#verify.projectLogits(result.hidden), context }; }
321
+ catch (error) { context.dispose(); throw error; }
322
+ finally { if (result.ctxML) result.hidden.dispose(); }
323
+ },
324
+ });
325
+ const keep: number[] = [];
326
+ for (const [index, row] of rows.entries()) {
327
+ const output = completed.outputs[index]!, round = completed.rounds[index]!;
328
+ if (output.kind !== "failed" && output.kind !== "cancelled")
329
+ row.fed.push(pending[index]!, ...round.drafts.slice(0, output.accepted));
330
+ row.sampled += round.acceptance.emitted.length + Number(round.acceptance.sawEos);
331
+ if (output.kind === "continue") { row.current = output.pending; keep.push(index); }
332
+ else if (output.kind === "failed") row.reject(output.error);
333
+ else if (output.kind === "cancelled") row.reject(row.req.signal?.reason ?? new Error(output.reason));
334
+ else {
335
+ if (!halted.has(row) && round.acceptance.sawEos && row.generated < row.req.maxTokens) row.generated++;
336
+ this.#put(row, this.#target!.extractRow(index));
337
+ this.#settleProposal(row); this.host.finish(row, output.kind);
338
+ }
339
+ }
340
+ if (keep.length !== rows.length) this.host.filterRows(keep);
341
+ if (++this.#steps % 256 === 0) clearCache();
342
+ } finally { pin?.close(); }
343
+ }
344
+
345
+ /** Multi-position arithmetic is model-owned. Wider cohorts whose model
346
+ * declares one position keep the pipelined single-position implementation. */
347
+ async #appendKnown(length: number): Promise<void> {
348
+ const rows = [...this.host.rows], B = rows.length;
349
+ const spans = new Map(rows.map(row => {
350
+ const proposal = this.#requests.get(row)!.proposal!;
351
+ return [row, proposal.value.ids.slice(proposal.emitted, proposal.emitted + length)] as const;
352
+ }));
353
+ using input = ops.fromInt32(rows.flatMap(row => spans.get(row)!), [B, length]);
354
+ using hidden = await appendHiddenRows(this.#append!.forwardHidden.bind(this.#append), this.#target!.caches, input,
355
+ (caches, count) => Math.min(this.#append!.maxChunkSize(caches, count),
356
+ this.#maintain.maxAppendTokens?.(caches) ?? Infinity,
357
+ ...rows.map(row => this.#requests.get(row)!.fill.appendChunkSize || Infinity)));
358
+ const nextKnown: boolean[] = [], nextReal: boolean[] = [];
359
+ const selected = rows.map(row => {
360
+ const request = this.#requests.get(row)!, proposal = request.proposal!;
361
+ request.sampling.commitNumbers(spans.get(row)!);
362
+ row.fed.push(...spans.get(row)!);
363
+ const next = proposal.value.ids[proposal.emitted + length];
364
+ const real = row.generated + length < row.req.maxTokens;
365
+ nextKnown.push(next !== undefined); nextReal.push(real);
366
+ row.sampled = row.generated + length + Number(real);
367
+ return next;
368
+ });
369
+ const needScores = rows.some((_row, index) => nextReal[index] && !nextKnown[index]);
370
+ using logits = needScores ? this.#binding.graph.projectLogits(hidden, { type: "last" }) : null;
371
+ const tokens: MlxArray[] = [];
372
+ let next: MlxArray | null = null;
373
+ try {
374
+ for (const [index, row] of rows.entries()) {
375
+ if (!nextReal[index] || nextKnown[index]) tokens.push(ops.fromInt32([selected[index] ?? 0], [1]));
376
+ else {
377
+ using slice = logits!.slice([index, 0, 0], [index + 1, 1, logits!.shape[2]!]);
378
+ using scores = ops.reshape(slice, [1, slice.shape[2]!]);
379
+ tokens.push(this.#requests.get(row)!.sampling.sample(scores, row.generated + length).token);
380
+ }
381
+ }
382
+ next = ops.concatAxis(tokens, 0);
383
+ if (needScores) ops.asyncEvalAll([next]);
384
+ else withResource(leaseCacheStates(this.#target!.caches), state => ops.asyncEvalAll([hidden, ...state]));
385
+ this.#pending!.dispose(); this.#pending = next; next = null;
386
+ this.#known = nextKnown; this.#real = nextReal;
387
+ } finally { next?.dispose(); disposeResources(tokens); }
388
+ for (let column = 0; column < length && this.host.rows.length; column++) {
389
+ const active = [...this.host.rows];
390
+ await this.#emitRows(active.map(row => spans.get(row)![column]!), active.map(() => true));
391
+ }
392
+ if (++this.#steps % 256 === 0) clearCache();
393
+ }
394
+
395
+ async #emit(row: Row, token: number, known: boolean): Promise<"stop" | "length" | undefined> {
396
+ row.req.signal?.throwIfAborted();
397
+ const request = this.#requests.get(row)!;
398
+ if (row.req.eosTokenIds.includes(token)) return "stop";
399
+ // Account a known token even when the consumer stops on this token.
400
+ if (known) {
401
+ const proposal = request.proposal!;
402
+ if (++proposal.emitted === proposal.value.ids.length) {
403
+ request.fill.commit(proposal.value, proposal.emitted); request.proposal = undefined;
404
+ }
405
+ }
406
+ if (!known) {
407
+ const proposal = request.fill.push(token, row.req.maxTokens - row.generated);
408
+ if (proposal) {
409
+ request.proposal = { value: proposal, emitted: 0 };
410
+ }
411
+ }
412
+ const more = await this.host.publish(row, token);
413
+ if (more === false) return "stop";
414
+ if (row.generated >= row.req.maxTokens) return "length";
415
+ row.current = token;
416
+ }
417
+
418
+ async #emitRows(values: number[], known: readonly boolean[]): Promise<void> {
419
+ const rows = [...this.host.rows], keep: number[] = [];
420
+ const replacement = new Map<number, number>();
421
+ for (let index = 0; index < rows.length; index++) {
422
+ const row = rows[index]!;
423
+ row.generated++;
424
+ try {
425
+ const reason = await this.#emit(row, values[index]!, known[index] ?? false);
426
+ if (reason) {
427
+ this.#put(row, this.#target!.extractRow(index));
428
+ this.#settleProposal(row); this.host.finish(row, reason);
429
+ } else {
430
+ keep.push(index);
431
+ const request = this.#requests.get(row)!;
432
+ if (!known[index] && request.proposal?.value.policy === "assert" && this.#pending) {
433
+ replacement.set(index, request.proposal.value.ids[0]!);
434
+ request.fill.noteWastedSample(); this.#known[index] = true;
435
+ }
436
+ }
437
+ } catch (error) { row.reject(error); }
438
+ }
439
+ if (replacement.size) {
440
+ const parts: MlxArray[] = [];
441
+ try {
442
+ for (let index = 0; index < rows.length; index++) parts.push(replacement.has(index)
443
+ ? ops.fromInt32([replacement.get(index)!], [1]) : this.#pending!.slice([index], [index + 1]));
444
+ const next = ops.concatAxis(parts, 0); this.#pending!.dispose(); this.#pending = next;
445
+ } finally { disposeResources(parts); }
446
+ }
447
+ if (keep.length !== rows.length) this.host.filterRows(keep);
448
+ }
449
+
450
+ async #flush(): Promise<void> {
451
+ const pending = this.#pending, known = this.#known;
452
+ if (!pending) return;
453
+ this.#pending = null; this.#known = []; this.#real = [];
454
+ let values: number[];
455
+ try { values = pending.toIntTokens(); } finally { pending.dispose(); }
456
+ await this.#emitRows(values, known);
457
+ }
458
+
459
+ #put(row: Row, caches: Cache[], retain?: () => void): void {
460
+ try {
461
+ if (this.host.promptCache && !row.fedTainted && !row.req.signal?.aborted) {
462
+ this.host.promptCache.put([...row.req.promptIds, ...row.fed], caches,
463
+ row.cacheNamespace, retain, undefined, row.req.cacheSessionId);
464
+ caches = []; retain = undefined;
465
+ }
466
+ } finally { disposeResources([...caches, { dispose: () => retain?.() }]); }
467
+ }
468
+
469
+ #settleProposal(row: Row): void {
470
+ const request = this.#requests.get(row);
471
+ if (request?.proposal) {
472
+ const proposal = request.proposal; request.proposal = undefined;
473
+ request.fill.commit(proposal.value, proposal.emitted);
474
+ }
475
+ }
476
+ #closeRequest(row: Row): void {
477
+ this.#settleProposal(row);
478
+ const request = this.#requests.get(row); this.#requests.delete(row); request?.sampling.dispose();
479
+ }
480
+ filterRows(keep: readonly number[], _discard: boolean): void {
481
+ if (this.#pending) {
482
+ using indices = ops.fromInt32([...keep], [keep.length]);
483
+ const next = keep.length ? ops.takeAxis(this.#pending, indices, 0) : null;
484
+ this.#pending.dispose(); this.#pending = next;
485
+ }
486
+ this.#known = keep.map(index => this.#known[index]!); this.#real = keep.map(index => this.#real[index]!);
487
+ this.#target?.filterRows(keep);
488
+ const retained = new Set(keep.map(index => this.host.rows[index]!));
489
+ for (const row of this.host.rows) if (!retained.has(row)) this.#closeRequest(row);
490
+ }
491
+ dispose(): void {
492
+ const pending = this.#pending, target = this.#target;
493
+ this.#pending = null; this.#target = null;
494
+ disposeResources([...(pending ? [pending] : []), ...(target ? [target] : []),
495
+ { dispose: () => { for (const row of this.#requests.keys()) this.#closeRequest(row); } }]);
496
+ }
497
+ }
@@ -1,6 +1,7 @@
1
+ import { speculativePrefixNamespace, captureSpeculativeOptions } from "../../spec/cache-identity";
1
2
  import { createOrdinaryContinuationRequest } from "./continuation-request";
2
3
  import type { MlxSerialServices } from "./serial-executor";
3
- import { bindPagedRequestState, type MlxRequestStatePolicy } from "./request-state-policy";
4
+ import { bindPagedRequestState, pagedPrefixNamespace, type MlxRequestStatePolicy } from "./request-state-policy";
4
5
  import type { ExecutionContext } from "../../contracts/scheduling";
5
6
  import type { ModelConfig } from "../../config";
6
7
  import type { KvScheme } from "../../kv-scheme";
@@ -16,18 +17,26 @@ import { disposeResources } from "../../engine/resources";
16
17
  import { legacyCompiledDecodeAvailable } from "./autoregressive";
17
18
  import { MlxBatchExecutionGroup, type MlxBatchExecutionGroupOptions, type MlxGroupMethodRequest } from "./batch-group";
18
19
  import type { DraftProvider } from "../../spec/source";
20
+ import { constraintDraftProvider } from "../../spec/ngram-source";
19
21
  import { targetRowLayoutFactory } from "./target-layout-capability";
20
22
  import { bindSpeculativeGroupRequests } from "./speculative-group";
23
+ import { bindFillGroupRequests } from "./fill-group";
21
24
  import type { GenerateOptions } from "../../generate";
22
25
  import type { ExecutionRequirements, ResolvedExecution } from "../../contracts/execution";
23
26
  import { resolveExecution } from "../../engine/execution-plan";
27
+ import { bindEmbeddingsInput, type MlxPromptInput } from "./prompt-input";
28
+ import { bindQwenMediaInput } from "./qwen-prompt-input";
29
+ import type { Vision } from "../../serve/generation-gateway";
24
30
 
25
31
  export interface MlxBatchGroup extends Pick<MlxBatchExecutionGroup,
26
- "activeRows" | "pendingRows" | "projectedKvBytes" | "kvBudgetBytes" | "submit" | "kick" | "close"> {}
32
+ "activeRows" | "pendingRows" | "projectedKvBytes" | "kvBudgetBytes" | "submit" | "kick" | "close"> {
33
+ runPreparation?<T>(work: () => Promise<T>, signal?: AbortSignal): Promise<T>;
34
+ }
27
35
 
28
36
  /** A model implementation owns capability checks and the execution group.
29
37
  * Scheduling never inspects concrete model/cache classes. */
30
38
  export interface MlxGatewayBinding {
39
+ mediaInput?(input: Vision): MlxPromptInput;
31
40
  configureContinuation?(services: MlxSerialServices): void;
32
41
  continuationRequest?(execution: ResolvedExecution | undefined, options: GenerateOptions, prompt: number[],
33
42
  onToken: Parameters<typeof createOrdinaryContinuationRequest>[0]["onToken"]): ReturnType<typeof createOrdinaryContinuationRequest> | undefined;
@@ -39,6 +48,7 @@ export interface MlxGatewayBinding {
39
48
  cachesBatchable(): boolean;
40
49
  kvBatchable(scheme: KvScheme): boolean;
41
50
  statePolicy?(execution: ResolvedExecution | undefined, options: GenerateOptions, capacityTokens: number): MlxRequestStatePolicy | undefined;
51
+ prefixNamespace?(execution: ResolvedExecution | undefined, options: GenerateOptions, adapters: string): string | null;
42
52
  methodRequest?(execution: ResolvedExecution | undefined, options: GenerateOptions): MlxGroupMethodRequest | undefined;
43
53
  createBatchGroup(options: MlxBatchExecutionGroupOptions): MlxBatchGroup;
44
54
  }
@@ -64,8 +74,20 @@ export function bindMlxGateway(model: RuntimeModel, draft?: { provider: DraftPro
64
74
  };
65
75
  const speculative = draft?.provider.grouped && cachesBatchable() && supportsTargetRows()
66
76
  ? bindSpeculativeGroupRequests(model, draft.provider, draft.numDraftTokens) : undefined;
77
+ const grammarProvider = runtime.flag("MLX_BUN_GRAMMAR_JUMP", false) && cachesBatchable() && supportsTargetRows()
78
+ ? constraintDraftProvider() : undefined;
79
+ const grammarProposals = grammarProvider ? bindSpeculativeGroupRequests(model, grammarProvider,
80
+ Math.max(1, Math.trunc(runtime.number("MLX_BUN_GRAMMAR_DRAFT_TOKENS", 3)))) : undefined;
67
81
  const adapterState = "loraState" in model ? model.loraState : undefined;
82
+ const fillRequests = supportsTargetRows() ? bindFillGroupRequests(model) : undefined;
83
+ const mediaInput = model instanceof Gemma4Model ? (input: Vision) =>
84
+ bindEmbeddingsInput((ids, caches, start) => start > 0 ? model.forwardHidden(ids, caches)
85
+ : model.forwardEmbeddings(input.embeddings,
86
+ caches, input.imageMask ?? null, ids, input.multimodalMask ?? null))
87
+ : model instanceof Qwen35Model ? (input: Vision) => bindQwenMediaInput(model, input.embeddings, input.mrope!)
88
+ : undefined;
68
89
  return {
90
+ mediaInput,
69
91
  config: model.config, runtime,
70
92
  configureContinuation: services => { continuationServices = services; },
71
93
  continuationRequest(execution, options, prompt, onToken) {
@@ -78,8 +100,23 @@ export function bindMlxGateway(model: RuntimeModel, draft?: { provider: DraftPro
78
100
  restore: entry => services.checkpoints!.restore(entry, model),
79
101
  interval: services.checkpointEveryTokens!, identity: services.identity });
80
102
  },
81
- statePolicy: (execution, options, capacity) => execution?.pagedKv ? bindPagedRequestState(model, options, capacity) : undefined,
82
- methodRequest: (execution, options) => execution?.method === "speculative" ? speculative?.(options) : undefined,
103
+ statePolicy: (execution, options, capacity) => {
104
+ // Media token IDs alone do not identify the prepared embeddings.
105
+ // Preserve the existing uncached media policy through the state port.
106
+ if (execution?.method === "autoregressive" && !execution.promptCache)
107
+ return { key: "uncached-prepared-input", create: () => model.makeCache() };
108
+ return execution?.pagedKv ? bindPagedRequestState(model, options, capacity, continuationServices?.promptCache, runtime) : undefined;
109
+ },
110
+ prefixNamespace: (execution, options, adapters) => {
111
+ if (execution?.pagedKv) return pagedPrefixNamespace(options, adapters, runtime.flag("MLX_BUN_PAGED_ATTN", false));
112
+ if (execution?.method !== "speculative") return adapters;
113
+ const namespace = (execution.grammarJump ? grammarProvider : draft?.provider)?.grouped?.checkpointNamespace?.();
114
+ return namespace === undefined ? null : speculativePrefixNamespace(namespace, adapters, captureSpeculativeOptions(options));
115
+ },
116
+ methodRequest: (execution, options) => execution?.method === "speculative"
117
+ ? (execution.grammarJump ? grammarProposals : speculative)?.(
118
+ options.fill && !execution.fill ? { ...options, fill: undefined } : options)
119
+ : execution?.fill ? fillRequests?.(options) : undefined,
83
120
  ...(adapterState ? { bindAdapterContext(adapters: string[], key: string): ExecutionContext {
84
121
  const selected = [...adapters];
85
122
  return { key, enter() {
@@ -89,26 +126,33 @@ export function bindMlxGateway(model: RuntimeModel, draft?: { provider: DraftPro
89
126
  } };
90
127
  } } : {}),
91
128
  plan(request, options, scheduling) {
129
+ const sharedMethod = request.hasDraft ? speculative : grammarProposals;
130
+ const provider = request.hasDraft ? draft?.provider : grammarProvider;
92
131
  return resolveExecution(request, {
93
132
  ...scheduling,
94
133
  sharedCheckpoints: !!continuationServices?.checkpointPersistence &&
95
134
  !request.hasDraft && !request.hasVision && !request.hasGrammar &&
96
135
  !request.wantsLogprobs && !options.fill && !options.pagedKv,
97
136
  adapterBatch: !!adapterState, pagedBatch: model instanceof Gemma4Model,
98
- groupedMethods: speculative ? ["autoregressive", "speculative"] : ["autoregressive"],
99
- speculativeLogprobs: scheduling.continuous && !!speculative,
100
- sharedSpeculativeAdapters: scheduling.continuous && !!speculative && !!adapterState &&
101
- draft?.provider.grouped?.supportsTargetAdapters === true,
137
+ mediaBatch: !!mediaInput,
138
+ mediaPrefixCache: runtime.flag("MLX_BUN_MEDIA_PREFIX_CACHE", true),
139
+ groupedMethods: sharedMethod ? ["autoregressive", "speculative"] : ["autoregressive"],
140
+ sharedGrammarProposals: !!grammarProposals,
141
+ sharedFill: !!fillRequests && !!options.fill,
142
+ sharedSpeculativeEcho: !!options.fill?.plan.echo && provider?.grouped?.supportsExternalTokens === true,
143
+ speculativeLogprobs: scheduling.continuous && !!sharedMethod,
144
+ sharedSpeculativeAdapters: scheduling.continuous && !!sharedMethod && !!adapterState &&
145
+ provider?.grouped?.supportsTargetAdapters === true,
102
146
  turboQuantBatch: scheduling.quantizedBatch,
103
- speculativeTurboQuant: scheduling.continuous && !!speculative && !!options.turboQuant,
147
+ speculativeTurboQuant: scheduling.continuous && !!sharedMethod && !!options.turboQuant,
104
148
  method: model instanceof DiffusionGemmaModel ? "denoising" : "autoregressive",
105
149
  compiledDecode: legacyCompiledDecodeAvailable(model),
106
150
  grammarBatch: runtime.value("MLX_BUN_GRAMMAR_BATCH") !== "0",
107
151
  speculativeKvQuant: (!(model instanceof Qwen35Model) || runtime.flag("MLX_BUN_QWEN_SPEC_KV4", true)) && (
108
- (scheduling.continuous && !!speculative && (options.kvBits === 4 || options.kvBits === 8 || !!options.kvConfig?.length)) ||
152
+ (scheduling.continuous && !!sharedMethod && (options.kvBits === 4 || options.kvBits === 8 || !!options.kvConfig?.length)) ||
109
153
  (!options.kvConfig?.length && model instanceof Qwen35Model &&
110
- (options.kvBits === 4 || (scheduling.continuous && !!speculative && options.kvBits === 8)) &&
111
- (options.quantizedKvStart === 0 || (scheduling.continuous && !!speculative)))
154
+ (options.kvBits === 4 || (scheduling.continuous && !!sharedMethod && options.kvBits === 8)) &&
155
+ (options.quantizedKvStart === 0 || (scheduling.continuous && !!sharedMethod)))
112
156
  ),
113
157
  }, {
114
158
  pagedKv: !!options.pagedKv, fill: !!options.fill,