mlx-bun 0.3.0 → 0.4.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 (150) hide show
  1. package/docs/README.md +4 -1
  2. package/docs/archive/investigations/openjarvis-fit.md +198 -0
  3. package/docs/archive/investigations/qwen38-27b-campaign-log.md +1092 -0
  4. package/docs/design/batching.md +1321 -49
  5. package/docs/design/decode-speed-program.md +331 -1163
  6. package/docs/design/generic-model-support.md +79 -3
  7. package/docs/design/kv-cache.md +198 -54
  8. package/docs/design/speculative-decoding.md +387 -24
  9. package/docs/design/turboquant.md +123 -13
  10. package/docs/design/unified-engine-frontier-plan.md +144 -142
  11. package/docs/planning/PRODUCT_ROADMAP.md +16 -0
  12. package/docs/planning/release-notes-v0.4.0.md +45 -0
  13. package/docs/reference/benchmarks.md +2457 -6
  14. package/docs/reference/cli.md +41 -2
  15. package/docs/reference/distribution.md +16 -9
  16. package/docs/reference/environment.md +55 -5
  17. package/docs/reference/models.md +13 -4
  18. package/docs/reference/server-api.md +79 -25
  19. package/docs/reference/server-config.md +178 -84
  20. package/docs/reference/troubleshooting.md +13 -21
  21. package/package.json +1 -1
  22. package/src/backends/mlx/assistant-target.ts +46 -0
  23. package/src/backends/mlx/autoregressive-draft-rows.ts +120 -0
  24. package/src/backends/mlx/batch-group.ts +368 -224
  25. package/src/backends/mlx/cache-attachment.ts +67 -0
  26. package/src/backends/mlx/cache-layout.ts +31 -0
  27. package/src/backends/mlx/checkpoint-state.ts +53 -0
  28. package/src/backends/mlx/continuation-persistence.ts +106 -0
  29. package/src/backends/mlx/continuation-request.ts +43 -0
  30. package/src/backends/mlx/continuation.ts +77 -0
  31. package/src/backends/mlx/draft-target.ts +7 -22
  32. package/src/backends/mlx/full-kv-row-append.ts +32 -0
  33. package/src/backends/mlx/full-kv-row-donor.ts +54 -0
  34. package/src/backends/mlx/full-transitioning-kv-rows.ts +65 -0
  35. package/src/backends/mlx/gateway-binding.ts +77 -18
  36. package/src/backends/mlx/kv-codec.ts +9 -0
  37. package/src/backends/mlx/kv-maintenance.ts +133 -0
  38. package/src/backends/mlx/kv-tensor-rows.ts +212 -0
  39. package/src/backends/mlx/model-serving.ts +1 -1
  40. package/src/backends/mlx/prefill-cohort.ts +89 -0
  41. package/src/backends/mlx/prefill-rows.ts +200 -0
  42. package/src/backends/mlx/prefill.ts +1 -1
  43. package/src/backends/mlx/projected-context-rows.ts +72 -0
  44. package/src/backends/mlx/request-state-policy.ts +41 -0
  45. package/src/backends/mlx/rollback.ts +41 -3
  46. package/src/backends/mlx/row-sampling.ts +49 -0
  47. package/src/backends/mlx/serial-executor.ts +35 -66
  48. package/src/backends/mlx/speculative-group.ts +359 -0
  49. package/src/backends/mlx/speculative-round.ts +161 -0
  50. package/src/backends/mlx/state-rows.ts +104 -0
  51. package/src/backends/mlx/state-views.ts +21 -1
  52. package/src/backends/mlx/target-layout-capability.ts +35 -0
  53. package/src/backends/mlx/transitioning-kv-rows.ts +165 -0
  54. package/src/cli.ts +11 -36
  55. package/src/contracts/execution.ts +11 -0
  56. package/src/contracts/prefix-cache.ts +23 -0
  57. package/src/contracts/resources.ts +7 -0
  58. package/src/contracts/sampling.ts +23 -0
  59. package/src/contracts/scheduling.ts +18 -0
  60. package/src/engine/execution-plan.ts +18 -16
  61. package/src/engine/resources.ts +12 -1
  62. package/src/engine/scheduler.ts +10 -5
  63. package/src/fit.ts +29 -15
  64. package/src/generate.ts +77 -173
  65. package/src/inference/draft-acceptance.ts +41 -0
  66. package/src/inference/draft-output.ts +49 -0
  67. package/src/inference/rollback.ts +2 -2
  68. package/src/kv-scheme.ts +70 -12
  69. package/src/kv-store.ts +70 -16
  70. package/src/lab/paged-kv/paged-kv-rows.ts +83 -0
  71. package/src/lab/paged-kv/paged-kv.ts +25 -11
  72. package/src/mlx/array.ts +2 -1
  73. package/src/model/artifact-identity.ts +73 -0
  74. package/src/model/batched-kv.ts +235 -0
  75. package/src/model/batched-mask.ts +12 -4
  76. package/src/model/batched-quant.ts +8 -2
  77. package/src/model/batched-quantized-kv.ts +144 -0
  78. package/src/model/batched-rotating-quant.ts +76 -15
  79. package/src/model/batched-rotating-state.ts +76 -0
  80. package/src/model/batched-rotating.ts +64 -22
  81. package/src/model/batched-row-storage.ts +52 -0
  82. package/src/model/batched-ssm.ts +52 -0
  83. package/src/model/batched-turboquant-kv.ts +93 -0
  84. package/src/model/compiled-decode.ts +7 -1
  85. package/src/model/decoded-kv-donor.ts +12 -0
  86. package/src/model/delayed-quantized-kv.ts +72 -0
  87. package/src/model/delayed-rotating-quantized-kv.ts +93 -0
  88. package/src/model/delayed-turboquant-kv.ts +55 -0
  89. package/src/model/full-prefill-padding.ts +56 -0
  90. package/src/model/full-prefill-row.ts +105 -0
  91. package/src/model/gemma4-base.ts +162 -80
  92. package/src/model/gemma4.ts +34 -26
  93. package/src/model/generated/gemma4-12b.ts +4 -1
  94. package/src/model/generated/gemma4-26b.ts +4 -1
  95. package/src/model/generated/gemma4-e4b.ts +4 -1
  96. package/src/model/glm52-cache.ts +80 -4
  97. package/src/model/glm52-mla.ts +12 -6
  98. package/src/model/glm52.ts +19 -40
  99. package/src/model/kv-attention-view.ts +67 -0
  100. package/src/model/minicpm5.ts +11 -7
  101. package/src/model/qwen3-delta-state.ts +74 -0
  102. package/src/model/qwen3-delta.ts +59 -36
  103. package/src/model/qwen3_5.ts +82 -42
  104. package/src/model/rotating-kv-donor.ts +35 -0
  105. package/src/model/rotating-kv-layout.ts +262 -0
  106. package/src/model/rotating-row-transaction.ts +35 -0
  107. package/src/model/speculative-rotating-kv.ts +101 -0
  108. package/src/model/ssm-prefill-padding.ts +39 -0
  109. package/src/model/trellis-linear.ts +2 -2
  110. package/src/model/turboquant-codec.ts +83 -0
  111. package/src/model/universal/dense.ts +14 -13
  112. package/src/preflight.ts +31 -15
  113. package/src/prompt-cache.ts +42 -44
  114. package/src/sampler.ts +36 -25
  115. package/src/serve/chat-stage.ts +2 -2
  116. package/src/serve/generation-gateway.ts +39 -30
  117. package/src/serve/model-host.ts +3 -4
  118. package/src/serve/request-plan.ts +4 -3
  119. package/src/serve/request-prep.ts +2 -4
  120. package/src/serve/text-completion-stage.ts +2 -2
  121. package/src/server.ts +64 -108
  122. package/src/spec/assistant-rows.ts +92 -0
  123. package/src/spec/assistant-source.ts +28 -18
  124. package/src/spec/cache-identity.ts +22 -0
  125. package/src/spec/deepspec-rows.ts +29 -0
  126. package/src/spec/deepspec-source.ts +12 -10
  127. package/src/spec/dflash-source.ts +28 -4
  128. package/src/spec/drafter.ts +153 -142
  129. package/src/spec/dspark/deepspec-module.ts +95 -120
  130. package/src/spec/dspark/module-dflash.ts +126 -170
  131. package/src/spec/glm52-mtp-graph.ts +29 -0
  132. package/src/spec/glm52-mtp-rows.ts +132 -0
  133. package/src/spec/glm52-mtp-source.ts +53 -136
  134. package/src/spec/glm52-mtp-state.ts +33 -0
  135. package/src/spec/ngram-source.ts +92 -20
  136. package/src/spec/projected-draft-rows.ts +73 -0
  137. package/src/spec/qwen-mtp-module.ts +171 -0
  138. package/src/spec/qwen-mtp-rows.ts +219 -0
  139. package/src/spec/qwen-mtp-source.ts +81 -226
  140. package/src/spec/qwen-mtp-state.ts +30 -0
  141. package/src/spec/serve-loop.ts +70 -68
  142. package/src/spec/source.ts +80 -15
  143. package/src/spec/two-model-rows.ts +61 -0
  144. package/src/spec/two-model.ts +43 -101
  145. package/src/ssd-cache.ts +33 -21
  146. package/src/ssd-durability.ts +11 -7
  147. package/src/web/app.js +57 -57
  148. package/src/backends/mlx/memory-guard.ts +0 -40
  149. package/src/spec/prefix-state.ts +0 -45
  150. /package/docs/{planning → archive/planning}/release-notes-v0.3.0.md +0 -0
@@ -0,0 +1,359 @@
1
+ import {captureSpeculativeOptions, speculativePrefixNamespace} from "../../spec/cache-identity";
2
+ import type { RuntimeModel } from "../../model/factory";
3
+ import type { GenerateOptions } from "../../generate";
4
+ import type { DraftProvider, DraftRowGroup, DraftPrefillGroup, DraftRowCheckpoint } from "../../spec/source";
5
+ import { makeSampler, makeStepSampler, readStepExtras, type NumberStepSampler, type Sampler } from "../../sampler";
6
+ import { MlxArray } from "../../mlx/array";
7
+ import * as ops from "../../mlx/ops";
8
+ import { clearCache } from "../../mlx/ffi";
9
+ import { applyStateChanges, cleanupFailure, disposeResources } from "../../engine/resources";
10
+ import { cloneKvCaches } from "../../kv-store";
11
+ import type { Cache } from "../../model/gemma4-base";
12
+ import { targetCacheLayout } from "./cache-layout";
13
+ import { MlxStateRows } from "./state-rows";
14
+ import { bindLegacySpeculativeModel, type MlxSpeculativeBinding } from "./speculative";
15
+ import { bindLegacyDraftTarget } from "./draft-target";
16
+ import { bindRowCacheRollback } from "./rollback";
17
+ import { createKvMaintenance } from "./kv-maintenance";
18
+ import { disposeAttachments, type CheckpointAttachment } from "./checkpoint-state";
19
+ import { advanceSpeculativeOutputs } from "./speculative-round";
20
+ import { MlxPrefillRows, type MlxPrefillState } from "./prefill-rows";
21
+ import type { MlxGroupMethodHost, MlxGroupMethodRequest, MlxGroupPreparation, MlxGroupedMethod, Row } from "./batch-group";
22
+
23
+ interface RequestState {
24
+ sampling: NumberStepSampler;
25
+ draftSampling: Sampler;
26
+ retain?: () => void;
27
+ namespace: string;
28
+ prefixLength: number;
29
+ /** Generated inputs retained by committed target/draft rounds. The pending
30
+ * correction is excluded until a later round actually processes it. */
31
+ processed?: number[];
32
+ }
33
+
34
+ /** Binding owns graph/layout selection. The executor receives only the method
35
+ * key and lifecycle; sampling, checkpoints and numerical state remain ports. */
36
+ export function bindSpeculativeGroupRequests(model: RuntimeModel, provider: DraftProvider, depth: number) {
37
+ const binding = bindLegacySpeculativeModel(model, provider);
38
+ return (input: GenerateOptions): MlxGroupMethodRequest => {
39
+ const options = captureSpeculativeOptions(input);
40
+ return {
41
+ key: JSON.stringify(["speculative", provider.id, depth, options.kvBits ?? null,
42
+ options.kvGroupSize ?? 64, options.quantizedKvStart ?? null, options.turboQuant ?? null,
43
+ ...(options.kvConfig?.length ? [options.kvConfig] : [])]),
44
+ data: options,
45
+ open: host => new SpeculativeGroup(host, model, provider, binding, depth),
46
+ };
47
+ };
48
+ }
49
+
50
+ class SpeculativeGroup implements MlxGroupedMethod {
51
+ #target: MlxStateRows<ReturnType<typeof targetCacheLayout>> | null = null;
52
+ #draft: DraftRowGroup | null = null;
53
+ readonly #requests = new Map<Row, RequestState>();
54
+ #steps = 0;
55
+
56
+ constructor(readonly host: MlxGroupMethodHost, readonly model: RuntimeModel,
57
+ readonly provider: DraftProvider, readonly binding: MlxSpeculativeBinding, readonly depth: number) {}
58
+
59
+ prepare(first: Row): MlxGroupPreparation {
60
+ const method = this;
61
+ const options = first.req.method!.data as GenerateOptions;
62
+ const maintain = createKvMaintenance(options);
63
+ interface Preparation extends MlxPrefillState {
64
+ request: RequestState;
65
+ end: number;
66
+ chunkSize: number;
67
+ pendingPrompt: boolean;
68
+ boundary: number;
69
+ transferred: boolean;
70
+ closed: boolean;
71
+ }
72
+ interface Ready {
73
+ row: Row;
74
+ request: RequestState;
75
+ caches: Cache[];
76
+ draft: DraftRowCheckpoint;
77
+ retain?: () => void;
78
+ transferred: boolean;
79
+ }
80
+ let prefix: DraftPrefillGroup | undefined;
81
+ let context: MlxArray | null = null;
82
+ const ready: Ready[] = [];
83
+ const closeReady = (state: Ready) => {
84
+ const caches = state.caches.splice(0), attachments = [state.draft.attachment];
85
+ const release = state.retain; state.retain = undefined;
86
+ disposeResources([...caches, { dispose: () => disposeAttachments(attachments) },
87
+ { dispose: () => release?.() }, ...(!state.transferred ? [state.request.sampling] : [])]);
88
+ };
89
+ const capture = (state: Preparation, snapshot: () => Cache[], index: number) => {
90
+ let caches: Cache[] = [], attachments: CheckpointAttachment[] = [];
91
+ try {
92
+ caches = snapshot(); attachments.push(prefix!.capture(index).attachment);
93
+ method.host.promptCache!.put(state.row.req.promptIds.slice(0, state.pos), caches,
94
+ state.request.namespace, undefined, attachments);
95
+ caches = []; attachments = [];
96
+ } finally { disposeResources([...caches, { dispose: () => disposeAttachments(attachments) }]); }
97
+ };
98
+ const target = new MlxPrefillRows<Preparation>({
99
+ maintain,
100
+ open(row) {
101
+ const options = row.req.method!.data as GenerateOptions;
102
+ row.spec = { drafted: 0, accepted: 0, targetCalls: 0, rejected: 0, rounds: 0,
103
+ acceptanceLengths: [], tokensPerForward: 0, forwardsSaved: 0, draftedByPos: [], acceptedByPos: [] };
104
+ const sampler = makeSampler(options);
105
+ const request: RequestState = { draftSampling: sampler, namespace: "", prefixLength: 0,
106
+ processed: method.host.promptCache ? [] : undefined,
107
+ sampling: makeStepSampler(options, { tokenRepresentation: "number", grammarWait: "before-sample",
108
+ historyUpdate: "after-sample", initialHistory: row.req.promptIds, acceptGrammar: true,
109
+ eosTokenIds: row.req.eosTokenIds, captureSelectedLogprob: options.logprobs === true,
110
+ captureTopLogprobs: options.topLogprobs }) };
111
+ let caches: Cache[] = [], retained: (() => void) | undefined;
112
+ let appended = false;
113
+ const previous = prefix?.rowCount ?? 0;
114
+ try {
115
+ caches = method.binding.makeCache();
116
+ prefix ??= method.provider.grouped!.openPrefill({ target: bindLegacyDraftTarget(method.model, caches), checkpoints: [] });
117
+ const prompt = row.req.promptIds;
118
+ const pendingPrompt = prefix.prefillMode !== "full" && method.binding.prefillTailSplit && prompt.length > 1;
119
+ request.prefixLength = prompt.length - Number(pendingPrompt);
120
+ request.namespace = speculativePrefixNamespace(prefix.namespace, row.cacheNamespace ?? "", options);
121
+ const hit = method.host.promptCache?.take(prompt, request.namespace);
122
+ if (hit) {
123
+ try {
124
+ disposeResources(caches); caches = hit.caches.splice(0);
125
+ retained = hit.retain; hit.retain = undefined;
126
+ prefix.append([{ processedTokens: hit.tokens.length, attachment: hit.attachments![0]! }]);
127
+ appended = true;
128
+ // Target membership later releases backing leases after its own
129
+ // copies resolve. Settle companion copies under the same lease.
130
+ if (retained) prefix.materialize();
131
+ row.cachedTokens = hit.tokens.length;
132
+ } finally { disposeResources([...hit.caches, { dispose: () => disposeAttachments(hit.attachments) },
133
+ { dispose: () => hit.retain?.() }]); }
134
+ } else { prefix.append([null]); appended = true; }
135
+ return { row, request, solo: caches, retain: retained, pos: row.cachedTokens,
136
+ end: request.prefixLength, pendingPrompt, transferred: false, closed: false,
137
+ chunkSize: options.prefillChunkSize ?? method.host.prefillChunkSize,
138
+ boundary: method.host.promptCache ? Math.min(row.req.snapshotAt ?? prompt.length, prompt.length - 1) : -1 };
139
+ } catch (error) {
140
+ return cleanupFailure(error, () => disposeResources([
141
+ { dispose: () => { if (appended) prefix!.filterRows(Array.from({ length: previous }, (_, i) => i)); } },
142
+ ...caches, request.sampling, { dispose: () => retained?.() },
143
+ ]));
144
+ }
145
+ },
146
+ plan(state) {
147
+ const end = Math.min(state.pos + state.chunkSize, state.end, state.boundary > state.pos ? state.boundary : Infinity);
148
+ return { start: state.pos, end, kind: end === state.end ? "final" : "drain",
149
+ snapshot: end === state.boundary && end > state.row.cachedTokens, batchYield: true, maintain: true };
150
+ },
151
+ async forward(ids, caches) {
152
+ const taps = prefix!.tapLayers;
153
+ const output = await method.binding.forward(ids, caches, taps.length ? [...taps] : undefined);
154
+ context = output.ctxML;
155
+ return output.hidden;
156
+ },
157
+ afterForward(ids, _caches, _states, hidden) {
158
+ // Tapped providers consume their captured layers; assistant drafting
159
+ // consumes the true target hidden. Other providers ignore context.
160
+ const captured = context; context = null;
161
+ try {
162
+ const pending = prefix!.prefill(ids, captured ?? hidden);
163
+ if (pending) return pending.finally(() => captured?.dispose());
164
+ } catch (error) { captured?.dispose(); throw error; }
165
+ captured?.dispose();
166
+ },
167
+ project(hidden, _caches, completed) {
168
+ if (completed.every(state => state.pendingPrompt)) return null;
169
+ using last = hidden.slice([0, hidden.shape[1]! - 1, 0], [hidden.shape[0]!, hidden.shape[1]!, hidden.shape[2]!]);
170
+ return method.binding.projectLogits(last);
171
+ },
172
+ checkpoint: capture,
173
+ async complete(state, logits, index) {
174
+ const row = state.row;
175
+ row.spec!.targetCalls++;
176
+ if (state.pendingPrompt) { row.current = row.req.promptIds[state.end]!; row.generated = row.sampled = 0; }
177
+ else {
178
+ using scores = ops.reshape(logits!, [1, logits!.shape.at(-1)!]);
179
+ const sampled = await state.request.sampling.sample(scores, 0);
180
+ row.current = sampled.token; row.generated = row.sampled = 1;
181
+ if (row.req.eosTokenIds.includes(row.current)) {
182
+ if (method.host.promptCache) capture(state, () => cloneKvCaches(state.solo), index);
183
+ disposeResources(state.solo.splice(0)); state.retain?.(); state.retain = undefined;
184
+ method.host.finish(row, "stop"); return;
185
+ }
186
+ const more = await method.host.publish(row, row.current, readStepExtras(sampled.extras));
187
+ if (more === false || row.req.grammar?.isTerminated || row.generated >= row.req.maxTokens) {
188
+ if (method.host.promptCache && !row.req.signal?.aborted) capture(state, () => cloneKvCaches(state.solo), index);
189
+ disposeResources(state.solo.splice(0)); state.retain?.(); state.retain = undefined;
190
+ method.host.finish(row, more === false || row.req.grammar?.isTerminated ? "stop" : "length"); return;
191
+ }
192
+ }
193
+ ready.push({ row, request: state.request, caches: state.solo, retain: state.retain,
194
+ draft: prefix!.capture(index), transferred: false });
195
+ state.transferred = true;
196
+ },
197
+ reject(row, error) { row.reject(error); },
198
+ close(state) {
199
+ if (state.closed) return;
200
+ state.closed = true;
201
+ if (!state.transferred) state.request.sampling.dispose();
202
+ },
203
+ filterRows(keep) { prefix!.filterRows(keep); },
204
+ dispose() {
205
+ const held = [prefix, context].filter(value => value != null);
206
+ prefix = undefined; context = null; disposeResources(held);
207
+ },
208
+ });
209
+ const preparation: MlxGroupPreparation = {
210
+ get rows() { return [...target.rows, ...ready.map(state => state.row)]; },
211
+ get canAdmit() { return target.canAdmit; },
212
+ get tokenWeight() { return target.tokenWeight + ready.reduce((sum, state) => sum + state.row.promptTokens - state.row.cachedTokens, 0); },
213
+ admit: row => target.admit(row),
214
+ advance: async () => {
215
+ // Publication returns to scheduling before prepared state joins active
216
+ // decode. This preserves the first-output flush boundary at every B.
217
+ if (ready.length) {
218
+ while (ready.length) {
219
+ const state = ready[0]!;
220
+ try {
221
+ if (state.row.req.signal?.aborted) {
222
+ state.row.reject(state.row.req.signal.reason);
223
+ continue;
224
+ }
225
+ maintain.prepareBatch?.(state.caches);
226
+ this.#target ??= new MlxStateRows(state.caches.map(targetCacheLayout));
227
+ this.#draft ??= this.provider.grouped!.open({ target: bindLegacyDraftTarget(this.model, this.#target.caches),
228
+ checkpoints: [], sampling: { sample: (lp, steps) => this.#sampleDraftRows(lp, steps) } });
229
+ applyStateChanges([() => this.#target!.prepareAppend(state.caches),
230
+ () => this.#draft!.prepareAppend([state.draft]), () => ({ commit: () => {
231
+ state.request.retain = state.retain; state.retain = undefined;
232
+ this.#requests.set(state.row, state.request); state.transferred = true;
233
+ this.host.join(state.row);
234
+ }, dispose() {} })]);
235
+ } finally { ready.shift(); closeReady(state); }
236
+ }
237
+ return target.rows.length === 0;
238
+ }
239
+ const done = await target.advance();
240
+ return done && ready.length === 0;
241
+ },
242
+ dispose() {
243
+ const held = ready.splice(0);
244
+ disposeResources([target, ...held.map(state => ({ dispose: () => closeReady(state) }))]);
245
+ },
246
+ };
247
+ try { target.admit(first); return preparation; }
248
+ catch (error) { return cleanupFailure(error, () => preparation.dispose()); }
249
+ }
250
+
251
+ #sampleDraftRows(logprobs: MlxArray, steps: readonly number[]): MlxArray {
252
+ const tokens: MlxArray[] = [];
253
+ try {
254
+ for (let row = 0; row < this.host.rows.length; row++) {
255
+ using scores = logprobs.slice([row, 0], [row + 1, logprobs.shape[1]!]);
256
+ tokens.push(this.#requests.get(this.host.rows[row]!)!.draftSampling(scores, steps[row]!));
257
+ }
258
+ return ops.concatAxis(tokens, 0);
259
+ } finally { disposeResources(tokens); }
260
+ }
261
+
262
+ async advance(): Promise<void> {
263
+ const active = [...this.host.rows];
264
+ const live = active.flatMap((row, index) => row.req.signal?.aborted ? [] : [index]);
265
+ if (live.length !== active.length) {
266
+ for (const row of active) if (row.req.signal?.aborted) row.reject(row.req.signal.reason);
267
+ this.host.filterRows(live);
268
+ }
269
+ const rows = [...this.host.rows];
270
+ if (!rows.length) return;
271
+ const depth = Math.min(this.depth, Math.max(...rows.map(row => row.req.maxTokens - row.generated)));
272
+ const halted = new Set<Row>();
273
+ const pin = this.binding.pinVerify?.();
274
+ try {
275
+ const completed = await advanceSpeculativeOutputs(rows.map(row => ({ pending: row.current,
276
+ step: row.sampled, remaining: row.req.maxTokens - row.generated, eosTokenIds: row.req.eosTokenIds,
277
+ sampling: this.#requests.get(row)!.sampling, grammarDone: () => row.req.grammar?.isTerminated === true,
278
+ output: { commit: async (ids, metadata) => {
279
+ row.generated++;
280
+ const more = await this.host.publish(row, ids[0]!, metadata?.[0]);
281
+ if (more === false) { halted.add(row); return false; }
282
+ } },
283
+ })), depth, this.#draft!, {
284
+ transaction: bindRowCacheRollback(this.#target!.caches, rows.length),
285
+ forward: async ids => {
286
+ const taps = this.#draft!.tapLayers;
287
+ const result = await this.binding.forward(ids, this.#target!.caches, taps.length ? [...taps] : undefined);
288
+ const context = result.ctxML ?? result.hidden;
289
+ try { return { logits: this.binding.projectLogits(result.hidden), context }; }
290
+ catch (error) { context.dispose(); throw error; }
291
+ finally { if (result.ctxML) result.hidden.dispose(); }
292
+ },
293
+ });
294
+ const keep: number[] = [];
295
+ for (let index = 0; index < rows.length; index++) {
296
+ const row = rows[index]!, output = completed.outputs[index]!, round = completed.rounds[index]!;
297
+ const request = this.#requests.get(row)!;
298
+ if (output.kind !== "failed" && output.kind !== "cancelled")
299
+ request.processed?.push(row.current, ...round.drafts.slice(0, output.accepted));
300
+ const stats = row.spec!;
301
+ stats.drafted += round.drafts.length;
302
+ stats.accepted += round.acceptance.accepted;
303
+ stats.rejected = stats.drafted - stats.accepted;
304
+ stats.rounds!++; stats.targetCalls++;
305
+ stats.acceptanceLengths!.push(round.acceptance.accepted);
306
+ for (let p = 0; p < round.drafts.length; p++) stats.draftedByPos![p] = (stats.draftedByPos![p] ?? 0) + 1;
307
+ for (let p = 0; p < round.acceptance.accepted; p++) stats.acceptedByPos![p] = (stats.acceptedByPos![p] ?? 0) + 1;
308
+ row.sampled += round.acceptance.emitted.length + Number(round.acceptance.sawEos);
309
+ // A consumer may stop inside a verified burst before its terminal EOS.
310
+ if (output.kind === "stop" && !halted.has(row) && round.acceptance.sawEos &&
311
+ output.generated === round.acceptance.emitted.length && row.generated < row.req.maxTokens)
312
+ row.generated++;
313
+ stats.tokensPerForward = row.generated / stats.rounds!;
314
+ stats.forwardsSaved = Math.max(0, row.generated - 1 - stats.rounds!);
315
+ if (output.kind === "continue") { row.current = output.pending; keep.push(index); }
316
+ else if (output.kind === "failed") row.reject(output.error);
317
+ else if (output.kind === "cancelled") row.reject(row.req.signal?.reason ?? new Error(output.reason));
318
+ else {
319
+ if (!row.req.signal?.aborted) this.#publishCheckpoint(row, index, request);
320
+ this.host.finish(row, output.kind);
321
+ }
322
+ }
323
+ if (keep.length !== rows.length) this.host.filterRows(keep);
324
+ if (++this.#steps % 256 === 0) clearCache();
325
+ } finally { pin?.close(); }
326
+ }
327
+
328
+ /** The method aligns token coverage and captures its state. The cache owns
329
+ * RAM retention and eventual SSD persistence; no storage work is awaited. */
330
+ #publishCheckpoint(row: Row, index: number, request: RequestState): void {
331
+ if (!this.host.promptCache || !request.processed) return;
332
+ let target: Cache[] = [], attachments: CheckpointAttachment[] = [];
333
+ try {
334
+ target = this.#target!.extractRow(index);
335
+ attachments.push(this.#draft!.capture(index).attachment);
336
+ this.host.promptCache.put([...row.req.promptIds.slice(0, request.prefixLength), ...request.processed], target,
337
+ request.namespace, undefined, attachments);
338
+ target = []; attachments = [];
339
+ } finally { disposeResources([...target, { dispose: () => disposeAttachments(attachments) }]); }
340
+ }
341
+
342
+ filterRows(keep: readonly number[], _discard: boolean): void {
343
+ this.#target?.filterRows(keep); this.#draft?.filterRows(keep);
344
+ const retained = new Set(keep.map(index => this.host.rows[index]!));
345
+ const releases: { dispose(): void }[] = [];
346
+ for (const [row, request] of this.#requests) if (!retained.has(row)) {
347
+ this.#requests.delete(row);
348
+ releases.push(request.sampling, { dispose: () => request.retain?.() });
349
+ }
350
+ disposeResources(releases);
351
+ }
352
+
353
+ dispose(): void {
354
+ const resources = [...(this.#target ? [this.#target] : []), ...(this.#draft ? [this.#draft] : []),
355
+ ...[...this.#requests.values()].flatMap(request => [request.sampling, { dispose: () => request.retain?.() }])];
356
+ this.#target = null; this.#draft = null; this.#requests.clear();
357
+ disposeResources(resources);
358
+ }
359
+ }
@@ -0,0 +1,161 @@
1
+ import type { MlxArray } from "../../mlx/array";
2
+ import * as ops from "../../mlx/ops";
3
+ import type { Cancellation, GenerationOutput, TokenLogprobs } from "../../contracts/generation";
4
+ import type { DisposableResource } from "../../contracts/resources";
5
+ import { cleanupFailure, disposeResources } from "../../engine/resources";
6
+ import type { SpeculativeTransaction } from "../../inference/rollback";
7
+ import { DraftAcceptance } from "../../inference/draft-acceptance";
8
+ import { deliverDraftOutputs, type DraftOutputResult } from "../../inference/draft-output";
9
+ import { readStepExtras, type NumberStepSampler } from "../../sampler";
10
+
11
+ /** Draft state owns proposal generation and its committed hidden/KV state.
12
+ * It borrows verification context and knows nothing about target storage. */
13
+ export interface MlxDraftRows<Context = MlxArray> {
14
+ /** Each row returns 0..depth proposals. The method verifies the returned
15
+ * lengths; an empty row still consumes its pending token. */
16
+ draft(pending: readonly number[], depth: number, steps: readonly number[]): number[][] | Promise<number[][]>;
17
+ commit(accepted: readonly number[], context: Context): void | Promise<void>;
18
+ }
19
+
20
+ /** A bound graph transfers both tensors on success and releases partial
21
+ * allocations on failure. State retention is a separate injected interface. */
22
+ export interface MlxRowVerification<Context extends DisposableResource = MlxArray> {
23
+ readonly transaction: SpeculativeTransaction<readonly number[]>;
24
+ forward(ids: MlxArray): Promise<{ logits: MlxArray; context: Context }>;
25
+ }
26
+
27
+ export interface MlxSpeculativeRow {
28
+ readonly pending: number;
29
+ readonly step: number;
30
+ readonly remaining: number;
31
+ readonly eosTokenIds: readonly number[];
32
+ readonly sampling: NumberStepSampler;
33
+ readonly grammarDone?: () => boolean;
34
+ }
35
+
36
+ export interface MlxSpeculativeRowResult {
37
+ readonly drafts: readonly number[];
38
+ readonly acceptance: DraftAcceptance;
39
+ /** One entry per content token; stopping EOS is excluded. */
40
+ readonly logprobs: readonly (TokenLogprobs | undefined)[];
41
+ }
42
+
43
+ export interface MlxSpeculativeOutputRow extends MlxSpeculativeRow {
44
+ readonly output: Pick<GenerationOutput, "commit">;
45
+ readonly cancellation?: Cancellation;
46
+ }
47
+
48
+ /** Execute one method step through output delivery and aligned state commit.
49
+ * The caller retires terminal rows and publishes eligible completed state
50
+ * before changing membership. Consumer failures are returned per request;
51
+ * a graph/state failure throws and requires discarding the whole group. */
52
+ export async function advanceSpeculativeOutputs<Context extends DisposableResource = MlxArray>(
53
+ rows: readonly MlxSpeculativeOutputRow[], depth: number,
54
+ draft: MlxDraftRows<NoInfer<Context>>, target: MlxRowVerification<Context>,
55
+ ): Promise<{ rounds: readonly MlxSpeculativeRowResult[]; outputs: readonly DraftOutputResult[] }> {
56
+ const round = await prepareSpeculativeRows(rows, depth, draft, target);
57
+ let outputs: DraftOutputResult[];
58
+ try {
59
+ outputs = await deliverDraftOutputs(round.results.map((result, row) => ({
60
+ acceptance: result.acceptance, logprobs: result.logprobs,
61
+ output: rows[row]!.output, cancellation: rows[row]!.cancellation,
62
+ })));
63
+ await round.commit(outputs.map(output => output.accepted));
64
+ } catch (error) { return cleanupFailure(error, () => round.dispose()); }
65
+ round.dispose();
66
+ return { rounds: round.results, outputs };
67
+ }
68
+
69
+ /** Retention stays unresolved while the method delivers output. A terminal
70
+ * row may keep fewer accepted inputs for an output-aligned checkpoint;
71
+ * continuing rows retain their complete accepted prefix. Commit exactly once,
72
+ * then dispose; disposing an uncommitted round requires discarding its state. */
73
+ export interface MlxPreparedSpeculativeRound extends DisposableResource {
74
+ readonly results: readonly MlxSpeculativeRowResult[];
75
+ commit(keepAccepted?: readonly number[]): Promise<void>;
76
+ }
77
+
78
+ /** One committed method step at B=1 or B>1. The caller supplies live rows with
79
+ * a positive token budget and a common nonnegative draft depth. Admission,
80
+ * cancellation boundaries, output delivery and cache publication stay outside
81
+ * this step. On failure the caller discards the participating method state. */
82
+ export async function advanceSpeculativeRows<Context extends DisposableResource = MlxArray>(
83
+ rows: readonly MlxSpeculativeRow[], depth: number,
84
+ draft: MlxDraftRows<NoInfer<Context>>, target: MlxRowVerification<Context>,
85
+ ): Promise<readonly MlxSpeculativeRowResult[]> {
86
+ const round = await prepareSpeculativeRows(rows, depth, draft, target);
87
+ try { await round.commit(); }
88
+ catch (error) { return cleanupFailure(error, () => round.dispose()); }
89
+ round.dispose();
90
+ return round.results;
91
+ }
92
+
93
+ /** Draft, verify and sample without deciding which output-aligned state to
94
+ * retain. Graph-specific context is opaque to this operation. */
95
+ export async function prepareSpeculativeRows<Context extends DisposableResource = MlxArray>(
96
+ rows: readonly MlxSpeculativeRow[], depth: number,
97
+ draft: MlxDraftRows<NoInfer<Context>>, target: MlxRowVerification<Context>,
98
+ ): Promise<MlxPreparedSpeculativeRound> {
99
+ const proposals = await draft.draft(rows.map(row => row.pending), depth, rows.map(row => row.step));
100
+ const B = rows.length;
101
+ const verifyDepth = proposals.reduce((max, tokens) => Math.max(max, tokens.length), 0);
102
+ using ids = ops.fromInt32(proposals.flatMap((tokens, row) => {
103
+ const window = [rows[row]!.pending, ...tokens];
104
+ // Causal right padding only fills the rectangular graph. Sampling stops
105
+ // at this row's own bonus position; commit discards every padded input.
106
+ while (window.length <= verifyDepth) window.push(rows[row]!.pending);
107
+ return window;
108
+ }), [B, verifyDepth + 1]);
109
+ target.transaction.begin(verifyDepth);
110
+ const verification = await target.forward(ids);
111
+ const dispose = () => disposeResources([verification.context, verification.logits]);
112
+ try {
113
+ const results = await sampleSpeculativeRows(rows, proposals, verification.logits);
114
+ return {
115
+ results,
116
+ async commit(accepted = results.map(result => result.acceptance.accepted)) {
117
+ target.transaction.resolve(accepted);
118
+ await draft.commit(accepted, verification.context);
119
+ },
120
+ dispose,
121
+ };
122
+ } catch (error) { return cleanupFailure(error, dispose); }
123
+ }
124
+
125
+ /** Sample a borrowed verify window. Each request owns its processor history,
126
+ * grammar and RNG stream; completing one walk never samples its unused suffix.
127
+ * The existing request adapter and grouped method use this same operation. */
128
+ export async function sampleSpeculativeRows(
129
+ rows: readonly MlxSpeculativeRow[], proposals: readonly (readonly number[])[], logits: MlxArray,
130
+ ): Promise<MlxSpeculativeRowResult[]> {
131
+ const V = logits.shape[2]!, width = logits.shape[1]!;
132
+ const independent = rows[0]?.sampling.independent;
133
+ let tokens: number[] | undefined;
134
+ if (independent && rows.every(row => row.sampling.independent === independent)) {
135
+ using flat = ops.reshape(logits, [rows.length * width, V]);
136
+ using sampled = independent.sample(flat);
137
+ tokens = sampled.toIntTokens();
138
+ }
139
+ const results: MlxSpeculativeRowResult[] = [];
140
+ for (let row = 0; row < rows.length; row++) {
141
+ const request = rows[row]!;
142
+ const acceptance = new DraftAcceptance(proposals[row]!, request.remaining, request.eosTokenIds);
143
+ const logprobs: (TokenLogprobs | undefined)[] = [];
144
+ while (!acceptance.done) {
145
+ const position = acceptance.position;
146
+ let token: number, metadata: TokenLogprobs | undefined;
147
+ if (tokens) token = tokens[row * width + position]!;
148
+ else {
149
+ using slice = logits.slice([row, position, 0], [row + 1, position + 1, V]);
150
+ using scores = ops.reshape(slice, [1, V]);
151
+ const sampled = await request.sampling.sample(scores, request.step + acceptance.emitted.length);
152
+ token = sampled.token;
153
+ metadata = readStepExtras(sampled.extras);
154
+ }
155
+ acceptance.accept(token, request.grammarDone?.() ?? false);
156
+ if (!acceptance.sawEos) logprobs.push(metadata);
157
+ }
158
+ results.push({ drafts: proposals[row]!, acceptance, logprobs });
159
+ }
160
+ return results;
161
+ }
@@ -0,0 +1,104 @@
1
+ import type { BatchableCache, Cache } from "../../model/gemma4-base";
2
+ import { applyStateChanges, cleanupFailure, disposeResources } from "../../engine/resources";
3
+ import type { PreparedStateChange } from "../../contracts/resources";
4
+
5
+ /** Preserve the graph's layout capabilities when an empty sibling is made. */
6
+ export interface MlxRowLayout<Layout extends BatchableCache> extends BatchableCache {
7
+ makeEmptyBatch(): Layout;
8
+ }
9
+
10
+ /** Layer layouts own tensor geometry. This collection only applies the same
11
+ * row membership operation to every layer. It has no scheduling, sampling,
12
+ * checkpoint-key or persistence policy. */
13
+ export class MlxStateRows<Layout extends MlxRowLayout<Layout> = BatchableCache> {
14
+ /** Borrowed by the bound target graph; collection ownership stays here. */
15
+ readonly caches: Layout[];
16
+ #rows = 0;
17
+
18
+ /** Takes ownership of empty, compatible layer layouts. */
19
+ constructor(layouts: readonly Layout[]) { this.caches = [...layouts]; }
20
+ get rowCount(): number { return this.#rows; }
21
+ /** Retain an immutable batch boundary without extracting or materializing
22
+ * individual rows. Layer layouts decide how to share their backing arrays. */
23
+ clone(): MlxStateRows<Layout> {
24
+ const layouts: Layout[] = [];
25
+ try {
26
+ for (const source of this.caches) {
27
+ const layout = source.makeEmptyBatch(); layouts.push(layout);
28
+ layout.mergeRows([source]);
29
+ }
30
+ const result = new MlxStateRows(layouts); result.#rows = this.#rows;
31
+ return result;
32
+ } catch (error) { return cleanupFailure(error, () => disposeResources(layouts)); }
33
+ }
34
+ /** Prepare an initial cohort with one merge per layer. */
35
+ mergeRows(rows: readonly (readonly Cache[])[]): void {
36
+ const next: Layout[] = [];
37
+ try {
38
+ for (let layer = 0; layer < this.caches.length; layer++) {
39
+ const layout = this.caches[layer]!.makeEmptyBatch(); next.push(layout);
40
+ layout.mergeRows(rows.map(row => row[layer]!));
41
+ }
42
+ } catch (error) { return cleanupFailure(error, () => disposeResources(next)); }
43
+ const previous = this.caches.splice(0, this.caches.length, ...next);
44
+ this.#rows = rows.length; disposeResources(previous);
45
+ }
46
+
47
+ /** Borrow a prepared request's state. Build every replacement before
48
+ * releasing the active group, so a failed admission leaves it intact. */
49
+ append(row: readonly Cache[]): void {
50
+ applyStateChanges([() => this.prepareAppend(row)]);
51
+ }
52
+
53
+ /** Stage membership so a method can compose it with companion-state admission. */
54
+ prepareAppend(row: readonly Cache[]): PreparedStateChange {
55
+ return this.prepareAppendMany([row]);
56
+ }
57
+
58
+ /** Stage several admissions with one membership operation per layer. */
59
+ prepareAppendMany(rows: readonly (readonly Cache[])[]): PreparedStateChange {
60
+ const next: Layout[] = [];
61
+ try {
62
+ for (let layer = 0; layer < this.caches.length; layer++) {
63
+ const layout = this.caches[layer]!;
64
+ const merged = layout.makeEmptyBatch();
65
+ next.push(merged);
66
+ merged.mergeRows([...(this.#rows ? [layout] : []), ...rows.map(row => row[layer]!)]);
67
+ }
68
+ } catch (error) { return cleanupFailure(error, () => disposeResources(next)); }
69
+ return {
70
+ commit: () => {
71
+ const previous = this.caches.splice(0, this.caches.length, ...next.splice(0));
72
+ this.#rows += rows.length;
73
+ next.push(...previous);
74
+ },
75
+ dispose: () => disposeResources(next.splice(0)),
76
+ };
77
+ }
78
+
79
+ /** The method calls this only between committed steps. A layer failure
80
+ * invalidates the group; its caller discards the state. */
81
+ filterRows(keep: readonly number[]): void {
82
+ if (!keep.length) { this.clear(); return; }
83
+ for (const layout of this.caches) layout.filterRows(keep);
84
+ this.#rows = keep.length;
85
+ }
86
+
87
+ /** Returns independent request state for a cache publisher or a new group. */
88
+ extractRow(row: number): Cache[] {
89
+ const state: Cache[] = [];
90
+ try { for (const layout of this.caches) state.push(layout.extractRow(row)); return state; }
91
+ catch (error) { return cleanupFailure(error, () => disposeResources(state)); }
92
+ }
93
+
94
+ clear(): void {
95
+ const next: Layout[] = [];
96
+ try { for (const layout of this.caches) next.push(layout.makeEmptyBatch()); }
97
+ catch (error) { return cleanupFailure(error, () => disposeResources(next)); }
98
+ const previous = this.caches.splice(0, this.caches.length, ...next);
99
+ this.#rows = 0;
100
+ disposeResources(previous);
101
+ }
102
+
103
+ dispose(): void { this.#rows = 0; disposeResources(this.caches); }
104
+ }
@@ -1,7 +1,8 @@
1
1
  import type { StateView } from "../../contracts/resources";
2
2
  import { disposeResources, ownResource } from "../../engine/resources";
3
3
  import type { MlxArray } from "../../mlx/array";
4
- import type { Cache } from "../../model/gemma4-base";
4
+ import { isBatchableCache, type Cache } from "../../model/gemma4-base";
5
+ import { cloneKvCaches, type CacheCodecProvider } from "../../kv-store";
5
6
 
6
7
  /** Compatibility boundary for legacy state(). Numerical code consumes a lease,
7
8
  * never the legacy ownership marker. No copy, evaluation or fence is added. */
@@ -24,3 +25,22 @@ export function leaseCacheStates(caches: readonly Cache[]): StateView<MlxArray>
24
25
  throw error;
25
26
  }
26
27
  }
28
+
29
+ /** Precision/storage transitions constrain reuse independently of scheduling. */
30
+ export function minimumReusableOffset(caches: readonly Pick<Cache, "minimumReusableOffset">[]): number {
31
+ return Math.max(0, ...caches.map(cache => cache.minimumReusableOffset ?? 0));
32
+ }
33
+
34
+ /** Snapshot an adopted single-row state into persistence-compatible caches.
35
+ * Row layouts own compact extraction; ordinary serial caches use their codec. */
36
+ export function cloneSingleRowState(caches: readonly Cache[], codecs?: CacheCodecProvider): Cache[] {
37
+ const owned: Cache[] = [];
38
+ try {
39
+ for (const cache of caches) owned.push(...(isBatchableCache(cache)
40
+ ? [cache.extractRow(0)] : cloneKvCaches([cache], codecs)));
41
+ return owned;
42
+ } catch (error) {
43
+ try { disposeResources(owned); } catch (cleanupError) { throw new AggregateError([error, cleanupError], "single-row snapshot failed"); }
44
+ throw error;
45
+ }
46
+ }