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,200 @@
1
+ import type { BatchableCache, Cache } from "../../model/gemma4-base";
2
+ import type { MlxArray } from "../../mlx/array";
3
+ import * as ops from "../../mlx/ops";
4
+ import { clearCache } from "../../mlx/ffi";
5
+ import type { PrefillStep } from "../../inference/prefill";
6
+ import { cloneKvCaches, type CacheCodecProvider } from "../../kv-store";
7
+ import { disposeResources, cleanupFailure } from "../../engine/resources";
8
+ import type { MlxGroupPreparation, Row } from "./batch-group";
9
+ import { evalCacheState } from "./prefill";
10
+ import { prefillCacheLayout } from "./cache-layout";
11
+ import { MlxStateRows } from "./state-rows";
12
+ import type { KvMaintenance } from "./kv-maintenance";
13
+
14
+ export interface MlxPrefillStep extends PrefillStep {
15
+ /** Some methods maintain their final target chunk before sampling. */
16
+ readonly maintain?: boolean;
17
+ }
18
+
19
+ export interface MlxPrefillState {
20
+ row: Row;
21
+ solo: Cache[];
22
+ pos: number;
23
+ retain?: () => void;
24
+ planned?: MlxPrefillStep;
25
+ }
26
+
27
+ /** The method owns initialization, planning, companion state, snapshots and
28
+ * completion. This driver owns common target batching and work boundaries.
29
+ * Checkpoint factories return owned cache state; close/dispose are idempotent. */
30
+ export interface MlxPrefillRowsHost<State extends MlxPrefillState> {
31
+ stateCodecs?: CacheCodecProvider;
32
+ maintain?: KvMaintenance;
33
+ open(row: Row): State;
34
+ plan(state: State): MlxPrefillStep;
35
+ /** Already prepared state can complete without any target forward. */
36
+ ready?(state: State): boolean;
37
+ forward(ids: MlxArray, caches: Cache[], states: readonly State[]): Promise<MlxArray>;
38
+ project(hidden: MlxArray, caches: Cache[], completed: readonly State[]): MlxArray | null;
39
+ /** Consume method context after target maintenance, before snapshots. */
40
+ afterForward?(ids: MlxArray, caches: Cache[], states: readonly State[], hidden: MlxArray): void | Promise<void>;
41
+ checkpoint(state: State, capture: () => Cache[], row: number): void;
42
+ complete(state: State, logits: MlxArray | null, row: number): Promise<void>;
43
+ reject(row: Row, error: unknown): void;
44
+ close(state: State): void;
45
+ filterRows?(keep: readonly number[]): void;
46
+ dispose?(): void;
47
+ }
48
+
49
+ /** One target preparation lifecycle at B1/B>1. Late joins retain each row's
50
+ * planned chunk endpoint, precision transition and checkpoint boundary. */
51
+ export class MlxPrefillRows<State extends MlxPrefillState> implements MlxGroupPreparation {
52
+ #states: State[] = [];
53
+ #rows: Row[] = [];
54
+ #stateRows?: MlxStateRows;
55
+ constructor(readonly operations: MlxPrefillRowsHost<State>) {}
56
+ get rows(): readonly Row[] { return this.#rows; }
57
+ get canAdmit(): boolean { return this.#rows.length > 0; }
58
+ /** Keep a request's prefill weight until retirement so a nearly completed
59
+ * request does not repeatedly attract another long prefill into its tail. */
60
+ get tokenWeight(): number { return this.#states.reduce((sum, state) => sum + state.row.promptTokens - state.row.cachedTokens, 0); }
61
+
62
+ admit(row: Row): void {
63
+ this.#states.push(this.operations.open(row));
64
+ this.#rows.push(row);
65
+ }
66
+
67
+ #release(state: State): void {
68
+ const caches = state.solo; state.solo = [];
69
+ const retain = state.retain; state.retain = undefined;
70
+ disposeResources([...caches, { dispose: () => retain?.() }, { dispose: () => this.operations.close(state) }]);
71
+ }
72
+ #filter(keep: number[]): void {
73
+ if (keep.length) this.#stateRows?.filterRows(keep);
74
+ else { const rows = this.#stateRows; this.#stateRows = undefined; rows?.dispose(); }
75
+ this.operations.filterRows?.(keep);
76
+ this.#states = keep.map(row => this.#states[row]!);
77
+ this.#rows = keep.map(row => this.#rows[row]!);
78
+ }
79
+ /** Admission changes membership only between forwards. Existing planned
80
+ * endpoints survive a join, including partially consumed drain chunks. */
81
+ #synchronizeRows(): void {
82
+ if (!this.#stateRows && this.#states.length === 1) return;
83
+ if (!this.#stateRows) {
84
+ for (const state of this.#states) this.operations.maintain?.preparePrefill?.(state.solo);
85
+ const layouts: BatchableCache[] = [];
86
+ try { for (const cache of this.#states[0]!.solo) layouts.push(prefillCacheLayout(cache)); }
87
+ catch (error) { return cleanupFailure(error, () => disposeResources(layouts)); }
88
+ this.#stateRows = new MlxStateRows(layouts);
89
+ this.#stateRows.mergeRows(this.#states.map(state => state.solo));
90
+ } else {
91
+ for (const state of this.#states.slice(this.#stateRows.rowCount)) {
92
+ this.operations.maintain?.preparePrefill?.(state.solo);
93
+ this.#stateRows.append(state.solo);
94
+ }
95
+ }
96
+ // Resolve input-backed copies before releasing SSD restore leases.
97
+ if (this.#states.some(state => state.solo.length)) {
98
+ evalCacheState(this.#stateRows.caches);
99
+ for (const state of this.#states) {
100
+ const caches = state.solo; state.solo = [];
101
+ const retain = state.retain; state.retain = undefined;
102
+ disposeResources([...caches, { dispose: () => retain?.() }]);
103
+ }
104
+ }
105
+ for (const cache of this.#stateRows.caches) cache.prefillMaintenance?.beginPrefill();
106
+ }
107
+
108
+ async advance(): Promise<boolean> {
109
+ // Complete restored inputs before merging: their cache coverage must not
110
+ // introduce padding or a zero-width forward into unrelated cold rows.
111
+ if (this.operations.ready && this.#states.some(state => this.operations.ready!(state))) {
112
+ const remaining: State[] = [];
113
+ for (const state of this.#states) {
114
+ if (!this.operations.ready(state)) { remaining.push(state); continue; }
115
+ try {
116
+ state.row.req.signal?.throwIfAborted();
117
+ await this.operations.complete(state, null, -1);
118
+ state.solo = []; state.retain = undefined;
119
+ } catch (error) { this.operations.reject(state.row, error); this.#release(state); }
120
+ finally { this.operations.close(state); }
121
+ }
122
+ this.#states = remaining; this.#rows = remaining.map(state => state.row);
123
+ if (!remaining.length) { this.dispose(); return true; }
124
+ }
125
+ this.#synchronizeRows();
126
+ const keep: number[] = [];
127
+ for (const [index, state] of this.#states.entries()) {
128
+ if (!state.row.req.signal?.aborted) { keep.push(index); continue; }
129
+ this.#release(state); this.operations.reject(state.row, state.row.req.signal.reason);
130
+ }
131
+ if (keep.length !== this.#states.length) this.#filter(keep);
132
+ if (!this.#states.length) { this.dispose(); return true; }
133
+ while (this.#states.length) {
134
+ for (const state of this.#states) state.planned ??= this.operations.plan(state);
135
+ const count = Math.min(...this.#states.map(state => state.planned!.end - state.pos));
136
+ const closes = this.#states.map(state => state.row.req.trace?.begin("prefill.chunk", {
137
+ mechanism: "continuous", startToken: state.pos, batchSize: this.#states.length,
138
+ }));
139
+ const caches = this.#stateRows?.caches ?? this.#states[0]!.solo;
140
+ try {
141
+ using ids = count ? ops.fromInt32(this.#states.flatMap(state => state.row.req.promptIds.slice(state.pos, state.pos + count)),
142
+ [this.#states.length, count]) : null;
143
+ using hidden = ids ? await this.operations.forward(ids, caches, this.#states) : null;
144
+ const drains: number[] = [], finals: number[] = [];
145
+ let batchYield = false;
146
+ for (const [row, state] of this.#states.entries()) {
147
+ state.pos += count;
148
+ if (state.pos !== state.planned!.end) continue;
149
+ if (state.planned!.kind === "final") finals.push(row);
150
+ else drains.push(row);
151
+ batchYield ||= state.planned!.batchYield;
152
+ }
153
+ const maintained = [...drains, ...finals.filter(row => this.#states[row]!.planned!.maintain)];
154
+ if (count && (maintained.length || !finals.length)) {
155
+ evalCacheState(caches);
156
+ if (this.#stateRows) {
157
+ for (const cache of caches) cache.prefillMaintenance?.commitPrefill(maintained);
158
+ } else if (maintained.length) this.operations.maintain?.(this.#states[0]!.solo);
159
+ clearCache();
160
+ }
161
+ if (ids) {
162
+ const companion = this.operations.afterForward?.(ids, caches, this.#states, hidden!);
163
+ if (companion) await companion;
164
+ }
165
+ for (const row of [...drains, ...finals]) {
166
+ const state = this.#states[row]!;
167
+ if (state.planned!.snapshot) this.operations.checkpoint(state, () =>
168
+ this.#stateRows ? this.#stateRows.extractRow(row) : cloneKvCaches(state.solo, this.operations.stateCodecs), row);
169
+ if (!finals.includes(row)) state.planned = undefined;
170
+ }
171
+ using logits = hidden && finals.length ? this.operations.project(hidden, caches, finals.map(row => this.#states[row]!)) : null;
172
+ for (const row of finals) {
173
+ const state = this.#states[row]!;
174
+ if (this.#stateRows) state.solo = this.#stateRows.extractRow(row);
175
+ using part = logits ? logits.slice([row, logits.shape[1]! - 1, 0], [row + 1, logits.shape[1]!, logits.shape[2]!]) : null;
176
+ try {
177
+ state.row.req.signal?.throwIfAborted();
178
+ await this.operations.complete(state, part, row);
179
+ state.solo = []; state.retain = undefined;
180
+ } catch (error) {
181
+ this.operations.reject(state.row, error);
182
+ this.#release(state);
183
+ } finally { this.operations.close(state); }
184
+ }
185
+ for (const cache of caches) (cache as { releaseRopeArr?: () => void }).releaseRopeArr?.();
186
+ if (finals.length) this.#filter(this.#states.flatMap((_, row) => finals.includes(row) ? [] : [row]));
187
+ if (!this.#states.length) { this.dispose(); return true; }
188
+ if (batchYield || finals.length) return false;
189
+ } finally { for (const close of closes) close?.(); }
190
+ }
191
+ return true;
192
+ }
193
+
194
+ dispose(): void {
195
+ const states = this.#states; this.#states = []; this.#rows = [];
196
+ const rows = this.#stateRows; this.#stateRows = undefined;
197
+ disposeResources([...(rows ? [rows] : []), ...states.map(state => ({ dispose: () => this.#release(state) })),
198
+ { dispose: () => this.operations.dispose?.() }]);
199
+ }
200
+ }
@@ -9,7 +9,7 @@ import { withResource } from "../../engine/resources";
9
9
  /** State views with explicit ownership are released even on deferred errors.
10
10
  * Owned views precede borrowed state, preserving the serial eval ordering. */
11
11
  export function evalCacheState(cache: Cache[]): void {
12
- withResource(leaseCacheStates(cache), (state) => ops.evalAll([...state]));
12
+ withResource(leaseCacheStates(cache), (state) => { if (state.length) ops.evalAll([...state]); });
13
13
  }
14
14
 
15
15
  /** Forward once. Drains evaluate state, apply KV maintenance, then clear the
@@ -0,0 +1,72 @@
1
+ import type { MlxArray } from "../../mlx/array";
2
+ import { Dtype } from "../../mlx/ffi";
3
+ import * as ops from "../../mlx/ops";
4
+ import { BatchedKVCache } from "../../model/batched-kv";
5
+ import type { Cache } from "../../model/gemma4-base";
6
+ import { MlxStateRows } from "./state-rows";
7
+ import { disposeResources } from "../../engine/resources";
8
+
9
+ export interface ProjectedContext { k: MlxArray; v: MlxArray; }
10
+ export interface ContextProjection {
11
+ project(hidden: MlxArray, positions: number | MlxArray): ProjectedContext[];
12
+ }
13
+
14
+ /** Incremental projected context reuses ordinary full-attention row storage.
15
+ * The projection graph owns numerics; this object owns positions and validity. */
16
+ export class MlxProjectedContextRows {
17
+ readonly state: MlxStateRows<BatchedKVCache>;
18
+ constructor(layers: number, readonly graph: ContextProjection) {
19
+ this.state = new MlxStateRows(Array.from({ length: layers }, () => new BatchedKVCache()));
20
+ }
21
+ get positions(): readonly number[] { return this.state.caches[0]!.rowOffsets; }
22
+ prepareAppend(rows: readonly (readonly Cache[])[]) { return this.state.prepareAppendMany(rows); }
23
+ append(hidden: MlxArray, lengths: readonly number[]): void {
24
+ const width = Math.max(...lengths), B = lengths.length, positions = this.positions;
25
+ using context = hidden.slice([0, 0, 0], [B, width, hidden.shape[2]!]);
26
+ using offsets = positions.every(position => position === positions[0]) ? null : ops.fromInt32([...positions], [B]);
27
+ const projected = this.graph.project(context, offsets ?? positions[0]!);
28
+ try {
29
+ for (const [layer, cache] of this.state.caches.entries()) {
30
+ cache.specRoundBegin();
31
+ for (const array of cache.updateAndFetch(projected[layer]!.k, projected[layer]!.v)) array.dispose();
32
+ if (lengths.every(length => length === width)) cache.specRoundCommit();
33
+ else cache.specRoundRollback(lengths);
34
+ }
35
+ } finally { disposeResources(projected.flatMap(pair => [pair.k, pair.v])); }
36
+ }
37
+ materialize(): void { ops.evalAll(this.state.caches.flatMap(cache => cache.state())); }
38
+ readAttention() {
39
+ const views: import("../../model/gemma4-base").KvDonorRows[] = [];
40
+ try { for (const cache of this.state.caches) views.push(cache.captureDonorRows()); }
41
+ catch (error) { disposeResources(views.flatMap(view => [view.keys, view.values])); throw error; }
42
+ const masks = new Map<string, MlxArray | null>();
43
+ return {
44
+ attend(layer: number, query: MlxArray, blockKeys: MlxArray, blockValues: MlxArray, scale = 1): MlxArray {
45
+ const view = views[layer]!, width = view.keys.shape[2]!, block = blockKeys.shape[2]!, B = view.offsets.length;
46
+ // Membership and coverage advance together in every context layer.
47
+ const key = `${width}:${block}:${view.keys.dtype}`;
48
+ if (!masks.has(key)) {
49
+ let mask: MlxArray | null = null;
50
+ if (view.starts.some(start => start !== 0) || view.ends.some(end => end !== width)) {
51
+ using columns = ops.arange(0, width, 1, Dtype.int32);
52
+ using lower = ops.fromInt32([...view.starts], [B, 1, 1, 1]);
53
+ using upper = ops.fromInt32([...view.ends], [B, 1, 1, 1]);
54
+ using before = ops.less(columns, lower), after = ops.greaterEqual(columns, upper);
55
+ using invalid = ops.logicalOr(before, after);
56
+ using floats = invalid.astype(view.keys.dtype);
57
+ using prefix = ops.mulScalar(floats, -1e9);
58
+ using suffix = ops.zeros([B, 1, 1, block], view.keys.dtype);
59
+ mask = ops.concatAxis([prefix, suffix], 3);
60
+ }
61
+ masks.set(key, mask);
62
+ }
63
+ using keys = ops.concatAxis([view.keys, blockKeys], 2);
64
+ using values = ops.concatAxis([view.values, blockValues], 2);
65
+ const mask = masks.get(key)!;
66
+ return ops.sdpa(query, keys, values, scale, mask ? "array" : "", mask);
67
+ },
68
+ dispose() { disposeResources([...views.flatMap(view => [view.keys, view.values]), ...[...masks.values()].filter(value => value !== null)]); },
69
+ };
70
+ }
71
+ dispose(): void { this.state.dispose(); }
72
+ }
@@ -0,0 +1,41 @@
1
+ import type { GenerateOptions } from "../../generate";
2
+ import type { RuntimeModel } from "../../model/factory";
3
+ import { KVCache, type Cache } from "../../model/gemma4-base";
4
+ import { PagedKVCache } from "../../lab/paged-kv/paged-kv";
5
+ import type { RowPromptCache } from "./batch-group";
6
+ import { disposeResources } from "../../engine/resources";
7
+
8
+ /** Request state construction and reusable-prefix policy. Equal keys declare
9
+ * compatible state layouts; the executor need not inspect their settings. */
10
+ export interface MlxRequestStatePolicy {
11
+ readonly key: string;
12
+ readonly promptCache?: RowPromptCache;
13
+ create(): Cache[];
14
+ }
15
+
16
+ /** Replace fresh full-attention storage before prefill. Sliding layers retain
17
+ * their layout; pre-warmed library caches retain their existing storage. */
18
+ export function maybePageKv(
19
+ cache: Cache[], options: GenerateOptions, capacityTokens: number,
20
+ ): void {
21
+ if (!options.pagedKv) return;
22
+ if (cache.some((c) => c.offset > 0)) return;
23
+ const blockSize = options.pagedKv.blockSize ?? PagedKVCache.DEFAULT_BLOCK_SIZE;
24
+ for (let i = 0; i < cache.length; i++) {
25
+ if (cache[i] instanceof KVCache) {
26
+ cache[i]!.dispose(); // fresh (offset 0) — nothing stored yet
27
+ cache[i] = new PagedKVCache(capacityTokens, blockSize);
28
+ }
29
+ }
30
+ }
31
+
32
+ export function bindPagedRequestState(model: RuntimeModel, options: GenerateOptions,
33
+ capacityTokens: number): MlxRequestStatePolicy | undefined {
34
+ if (!options.pagedKv) return undefined;
35
+ const blockSize = options.pagedKv.blockSize ?? PagedKVCache.DEFAULT_BLOCK_SIZE;
36
+ return { key: `paged:${blockSize}`, create() {
37
+ const caches = model.makeCache();
38
+ try { maybePageKv(caches, { pagedKv: { blockSize } }, capacityTokens); return caches; }
39
+ catch (error) { disposeResources(caches); throw error; }
40
+ } };
41
+ }
@@ -1,7 +1,36 @@
1
1
  import type { SpeculativeTransaction } from "../../inference/rollback";
2
2
  import type { Cache } from "../../model/gemma4-base";
3
3
 
4
- /** Bind the full legacy recurrent transaction or the trim implementation once.
4
+ interface TransactionLayer<Acceptance> {
5
+ fits(drafts: number): boolean;
6
+ begin(): void;
7
+ resolve(accepted: Acceptance, drafts: number): void;
8
+ }
9
+
10
+ /** State storage that can retain a different verified prefix for each row.
11
+ * Both attention and recurrent layouts implement this same transaction. */
12
+ export interface RowSpeculativeState {
13
+ specRoundBegin(): void;
14
+ specRoundCommit(): void;
15
+ specRoundRollback(keep: number | readonly number[]): void;
16
+ }
17
+
18
+ export function bindRowCacheRollback(
19
+ caches: readonly RowSpeculativeState[], rows: number,
20
+ ): SpeculativeTransaction<readonly number[]> {
21
+ return bindTransaction(caches.map(cache => ({
22
+ fits: () => true,
23
+ begin: () => cache.specRoundBegin(),
24
+ resolve(accepted: readonly number[], drafts: number) {
25
+ if (accepted.every(count => count === drafts)) cache.specRoundCommit();
26
+ else cache.specRoundRollback(accepted.every(count => count === accepted[0])
27
+ ? accepted[0]! + 1 : accepted.map(count => count + 1));
28
+ },
29
+ })), (accepted, drafts) => accepted.length === rows &&
30
+ accepted.every(count => Number.isSafeInteger(count) && count >= 0 && count <= drafts));
31
+ }
32
+
33
+ /** Bind the recurrent transaction or the trim implementation once.
5
34
  * Ring-capacity checks still happen at each safe boundary before any writes. */
6
35
  export function bindCacheRollback(caches: readonly Cache[]): SpeculativeTransaction {
7
36
  const layers = caches.map((cache) => {
@@ -24,6 +53,15 @@ export function bindCacheRollback(caches: readonly Cache[]): SpeculativeTransact
24
53
  },
25
54
  };
26
55
  });
56
+ return bindTransaction(layers, (accepted, drafts) =>
57
+ Number.isSafeInteger(accepted) && accepted >= 0 && accepted <= drafts);
58
+ }
59
+
60
+ /** One transaction lifecycle for scalar and row-wise state bindings. */
61
+ function bindTransaction<Acceptance>(
62
+ layers: readonly TransactionLayer<Acceptance>[],
63
+ accepts: (accepted: Acceptance, drafts: number) => boolean,
64
+ ): SpeculativeTransaction<Acceptance> {
27
65
  let active: number | undefined;
28
66
  let invalid = false;
29
67
  const validate = (drafts: number) => {
@@ -41,8 +79,8 @@ export function bindCacheRollback(caches: readonly Cache[]): SpeculativeTransact
41
79
  catch (error) { invalid = true; throw error; }
42
80
  },
43
81
  resolve(accepted) {
44
- validate(accepted);
45
- if (active === undefined || accepted > active) throw new Error("invalid speculative acceptance count");
82
+ if (invalid) throw new Error("speculative state is invalid and must be discarded");
83
+ if (active === undefined || !accepts(accepted, active)) throw new Error("invalid speculative acceptance count");
46
84
  try { for (const layer of layers) layer.resolve(accepted, active); }
47
85
  catch (error) { invalid = true; throw error; }
48
86
  active = undefined;
@@ -0,0 +1,49 @@
1
+ import type { MlxArray } from "../../mlx/array";
2
+ import * as ops from "../../mlx/ops";
3
+ import type { TokenLogprobs } from "../../contracts/generation";
4
+ import { readStepExtras, stepExtrasArrays, disposeStepExtras,
5
+ type DeviceStepSampler, type StepExtras } from "../../sampler";
6
+ import { disposeResources, cleanupFailure } from "../../engine/resources";
7
+
8
+ type TokenSink = (token: number, logprobs?: TokenLogprobs) => void | boolean | Promise<void | boolean>;
9
+
10
+ /** Request-owned sampling and output metadata. Execution groups receive token
11
+ * tensors and an output callback; they need no logprob or retention policy. */
12
+ export function createRowSampling(
13
+ sampler: DeviceStepSampler, onToken: TokenSink,
14
+ ) {
15
+ if (!sampler.capturesLogprobs) return {
16
+ sample: (logits: MlxArray, step: number) => sampler.sample(logits, step).token,
17
+ plainGreedy: sampler.isPlainGreedy, onToken, dispose: () => sampler.dispose(),
18
+ };
19
+
20
+ // A pipelined group can sample the next token before emitting the previous
21
+ // one. Keep captures by request-local step, independently of row position.
22
+ const pending = new Map<number, StepExtras>();
23
+ let emitted = 0;
24
+ return {
25
+ sample(logits: MlxArray, step: number): MlxArray {
26
+ const result = sampler.sample(logits, step);
27
+ try {
28
+ ops.asyncEvalAll([result.token, ...stepExtrasArrays(result.extras)]);
29
+ if (result.extras) pending.set(step, result.extras);
30
+ return result.token;
31
+ } catch (error) {
32
+ return cleanupFailure(error, () => disposeResources([result.token,
33
+ { dispose: () => disposeStepExtras(result.extras) }]));
34
+ }
35
+ },
36
+ // The vectorized token-only shortcut bypasses the per-request sampler.
37
+ plainGreedy: false,
38
+ onToken(token: number) {
39
+ const extras = pending.get(emitted) ?? null;
40
+ pending.delete(emitted++);
41
+ return onToken(token, readStepExtras(extras));
42
+ },
43
+ dispose() {
44
+ const extras = [...pending.values()];
45
+ pending.clear();
46
+ disposeResources([sampler, ...extras.map(value => ({ dispose: () => disposeStepExtras(value) }))]);
47
+ },
48
+ };
49
+ }
@@ -1,32 +1,30 @@
1
+ import type { SpecRunServices } from "../../spec/serve-loop";
1
2
  import { cleanupFailure, disposeResources, ownResource } from "../../engine/resources";
2
3
  import { bindGeneration } from "../../generate";
3
4
  import type { RuntimeModel } from "../../model/factory";
4
5
  import { Qwen35Model } from "../../model/qwen3_5";
5
6
  import type { Cache } from "../../model/gemma4";
6
- import type { PromptCache } from "../../prompt-cache";
7
+ import type { MlxPrefixCache } from "./checkpoint-state";
7
8
  import type { SsdCacheStore } from "../../ssd-cache";
8
9
  import type { DraftProvider } from "../../spec/source";
9
10
  import type { SerialRun, Vision } from "../../serve/generation-gateway";
10
- import { generationCheckpointKey } from "../../serve/checkpoint-identity";
11
+ import { bindContinuationPolicy } from "./continuation";
11
12
  import { runtimeConfig, withRuntimeConfig, type RuntimeConfig } from "../../runtime-config";
12
13
  import { bindLegacySpeculativeModel } from "./speculative";
13
- import { enterMlxMemoryGuard, type MlxMemoryBudget } from "./memory-guard";
14
14
 
15
15
  /** Native serial execution depends on this bound port, never a model union.
16
16
  * Weights remain borrowed. The gateway supplies the exclusive runtime lease. */
17
17
  export interface MlxSerialBinding {
18
18
  readonly runtime: RuntimeConfig;
19
19
  readonly generate: ReturnType<typeof bindGeneration>;
20
- readonly speculate?: SerialRun;
20
+ readonly speculate?: (
21
+ promptIds: Parameters<SerialRun>[0], options: Parameters<SerialRun>[1],
22
+ onToken: Parameters<SerialRun>[2], services?: SpecRunServices,
23
+ ) => ReturnType<SerialRun>;
21
24
  makeCache(): Cache[];
22
25
  enterMedia?(vision?: Vision): () => void;
23
- enterMemoryGuard?(budget: SerialMemoryBudget, promptTokens: number,
24
- prefillChunkSize?: number): { check(): void; close(): void };
25
26
  }
26
27
 
27
- /** Admission's ceiling and the cache that can release memory under the GPU lease. */
28
- export type SerialMemoryBudget = MlxMemoryBudget;
29
-
30
28
  /** Family-specific context is bound once at the compatibility boundary. */
31
29
  export function bindLegacySerialModel(
32
30
  model: RuntimeModel,
@@ -37,13 +35,10 @@ export function bindLegacySerialModel(
37
35
  runtime: runtimeConfig(),
38
36
  generate: bindGeneration(model),
39
37
  makeCache: model.makeCache.bind(model),
40
- enterMemoryGuard(budget, promptTokens, prefillChunkSize) {
41
- return enterMlxMemoryGuard(model, budget, promptTokens, prefillChunkSize);
42
- },
43
- ...(speculative && draft ? { speculate: (async (prompt, options, onToken) => {
38
+ ...(speculative && draft ? { speculate: (async (prompt, options, onToken, services) => {
44
39
  const { specRun } = await import("../../spec/serve-loop");
45
- return specRun(speculative, draft.numDraftTokens, prompt, options, onToken);
46
- }) satisfies SerialRun } : {}),
40
+ return specRun(speculative, draft.numDraftTokens, prompt, options, onToken, services);
41
+ }) satisfies NonNullable<MlxSerialBinding["speculate"]> } : {}),
47
42
  ...(model instanceof Qwen35Model ? { enterMedia(vision?: Vision) {
48
43
  const previous = model.mrope;
49
44
  if (vision?.mrope) model.mrope = vision.mrope;
@@ -53,11 +48,11 @@ export function bindLegacySerialModel(
53
48
  }
54
49
 
55
50
  export interface MlxSerialServices {
56
- readonly promptCache: Pick<PromptCache, "take" | "put"> & Partial<Pick<PromptCache, "maxBytes">>;
51
+ readonly promptCache: MlxPrefixCache & { readonly maxBytes?: number };
57
52
  readonly checkpoints: Pick<SsdCacheStore, "findGenerationCheckpoint" | "restore" |
58
53
  "storeGenerationCheckpoint" | "removeGenerationCheckpoints"> | null;
59
54
  readonly checkpointEveryTokens?: number;
60
- readonly memoryBudget?: SerialMemoryBudget;
55
+ readonly checkpointPersistence?: import("./continuation-persistence").ContinuationPersistence;
61
56
  /** Artifact, implementation, state ABI and codec identity captured at load. */
62
57
  readonly identity: unknown;
63
58
  adapterNamespace(adapters: string[]): string;
@@ -71,17 +66,17 @@ export interface MlxSerialServices {
71
66
  export function createMlxSerialExecutor(binding: MlxSerialBinding, services: MlxSerialServices): SerialRun {
72
67
  const { promptCache, checkpoints: ssdStore } = services;
73
68
  return (promptIds, options, onToken, vision, trace, execution) => withRuntimeConfig(binding.runtime, async () => {
69
+ let releaseContinuation: (() => void) | undefined;
74
70
  let caches: Cache[] = [];
75
71
  let retain: (() => void) | undefined;
76
72
  let closeMedia: (() => void) | undefined;
77
- let memoryGuard: ReturnType<NonNullable<MlxSerialBinding["enterMemoryGuard"]>> | undefined;
78
73
  const cleanup = ownResource(null, () => disposeResources([
79
74
  { dispose() {
80
75
  disposeResources(caches);
81
76
  retain?.(); // backing release requires successful cache disposal
82
77
  } },
83
78
  { dispose: () => closeMedia?.() },
84
- { dispose: () => memoryGuard?.close() },
79
+ { dispose: () => releaseContinuation?.() },
85
80
  ...[options.grammar, vision?.embeddings, vision?.imageMask,
86
81
  vision?.multimodalMask, options.visionPixels].filter((value) => value != null),
87
82
  ]));
@@ -90,11 +85,12 @@ export function createMlxSerialExecutor(binding: MlxSerialBinding, services: Mlx
90
85
  if (!execution) throw new Error("serial execution requires a resolved plan");
91
86
  if (execution.method === "speculative") {
92
87
  if (!binding.speculate) throw new Error("resolved speculation requires a bound verifier");
93
- return await binding.speculate(promptIds, {
94
- ...options,
95
- speculativeCacheBytes: binding.runtime.flag("MLX_BUN_MTP_PROMPT_CACHE", false)
96
- ? services.promptCache.maxBytes ?? 0 : 0,
97
- }, onToken);
88
+ return await binding.speculate(promptIds, options, onToken, {
89
+ ...(binding.runtime.flag("MLX_BUN_MTP_PROMPT_CACHE", true) && services.promptCache.maxBytes !== 0
90
+ ? { prefixCache: services.promptCache } : {}),
91
+ cloneState: services.cloneState,
92
+ cacheNamespace: options.adapters?.length ? services.adapterNamespace(options.adapters) : "",
93
+ });
98
94
  }
99
95
  // Cache entries are adapter-specific: KV computed under one adapter
100
96
  // must never seed another's (or the base's) prefill.
@@ -111,32 +107,26 @@ export function createMlxSerialExecutor(binding: MlxSerialBinding, services: Mlx
111
107
  const skipPromptCache = !execution.promptCache;
112
108
  const checkpointEvery = services.checkpointEveryTokens;
113
109
  const checkpointEligible = execution.checkpoint;
114
- const checkpointKey = checkpointEligible
115
- ? generationCheckpointKey(promptIds, options, cacheNs, execution, services.identity)
116
- : null;
110
+ const continuation = checkpointEligible ? bindContinuationPolicy({ store: ssdStore!,
111
+ restore: entry => ssdStore!.restore(entry, binding), prompt: promptIds,
112
+ options, execution, identity: services.identity, namespace: cacheNs, persistence: services.checkpointPersistence }) : null;
113
+ releaseContinuation = continuation?.release;
114
+ const checkpointKey = continuation?.key;
117
115
  // Both tiers in one call (Layer 0): take() prefers a strictly-longer
118
116
  // SSD prefix, restores it zero-copy, and trims — see PromptCache.take.
119
117
  const closeCacheLookup = trace?.begin("cache.lookup_restore", {
120
118
  mechanism: "serial",
121
119
  bypassed: skipPromptCache,
122
120
  });
123
- const checkpointEntry = checkpointKey
124
- ? ssdStore!.findGenerationCheckpoint(promptIds, checkpointKey, cacheNs)
125
- : null;
126
- const restoredCheckpoint = checkpointEntry
127
- ? ssdStore!.restore(checkpointEntry, binding)
128
- : null;
129
- caches = restoredCheckpoint?.caches ?? [];
130
- const checkpoint = restoredCheckpoint?.header.generationCheckpoint;
131
- if (restoredCheckpoint && !checkpoint)
132
- throw new Error("restored generation checkpoint has no continuation metadata");
133
- const resuming = Boolean(restoredCheckpoint && checkpoint);
134
- const generationPromptIds = resuming ? restoredCheckpoint!.tokens : promptIds;
121
+ const checkpoint = continuation?.restore();
122
+ caches = checkpoint?.caches ?? [];
123
+ const resuming = Boolean(checkpoint);
124
+ const generationPromptIds = checkpoint?.cacheTokens ?? promptIds;
135
125
  const entry = skipPromptCache || resuming
136
126
  ? null
137
127
  : promptCache.take(promptIds, cacheNs);
138
128
  if (entry) { caches = entry.caches; retain = entry.retain; }
139
- if (!restoredCheckpoint && !entry) caches = binding.makeCache();
129
+ if (!checkpoint && !entry) caches = binding.makeCache();
140
130
  closeCacheLookup?.();
141
131
  // Prompt-boundary snapshot (the multi-turn agent fix, 2026-07-04): the
142
132
  // prompt+gen entry put() below is UNTRIMMABLE at context > sliding
@@ -160,12 +150,6 @@ export function createMlxSerialExecutor(binding: MlxSerialBinding, services: Mlx
160
150
  const snapshotBoundary =
161
151
  !skipPromptCache && !resuming && boundary >= 256 &&
162
152
  boundary > (entry?.tokens.length ?? 0);
163
- if (services.memoryBudget) {
164
- memoryGuard = binding.enterMemoryGuard?.(
165
- services.memoryBudget, generationPromptIds.length, options.prefillChunkSize,
166
- );
167
- memoryGuard?.check();
168
- }
169
153
  closeMedia = binding.enterMedia?.(vision);
170
154
  if (resuming) {
171
155
  const replay = generationPromptIds.slice(promptIds.length);
@@ -190,7 +174,7 @@ export function createMlxSerialExecutor(binding: MlxSerialBinding, services: Mlx
190
174
  ? {
191
175
  initialPendingToken: checkpoint!.pendingToken,
192
176
  initialGeneratedTokens: checkpoint!.generatedTokens,
193
- originalPromptTokens: checkpoint!.originalPromptTokens,
177
+ originalPromptTokens: promptIds.length,
194
178
  }
195
179
  : {}),
196
180
  ...(checkpointEligible
@@ -202,23 +186,9 @@ export function createMlxSerialExecutor(binding: MlxSerialBinding, services: Mlx
202
186
  generatedTokens: number;
203
187
  pendingToken: number;
204
188
  }) => {
205
- const stored = await ssdStore!.storeGenerationCheckpoint(
206
- state.cacheTokens,
207
- state.caches,
208
- {
209
- key: checkpointKey!,
210
- cacheNs,
211
- originalPromptTokens: promptIds.length,
212
- generatedTokens: state.generatedTokens,
213
- pendingToken: state.pendingToken,
214
- seed: options.seed ?? 0,
215
- seedWasExplicit: options.seedWasExplicit === true,
216
- },
217
- );
218
- if (stored)
219
- console.log(
220
- `[generation-checkpoint] saved ${state.generatedTokens} emitted tokens`,
221
- );
189
+ if (services.checkpointPersistence)
190
+ continuation!.captureOwned({ ...state, caches: services.cloneState(state.caches) });
191
+ else await continuation!.capture(state);
222
192
  },
223
193
  }
224
194
  : {}),
@@ -252,11 +222,10 @@ export function createMlxSerialExecutor(binding: MlxSerialBinding, services: Mlx
252
222
  : {}),
253
223
  }, { trace, mechanism: "serial" });
254
224
  for await (const t of gen) {
255
- if (t.index % 256 === 0) memoryGuard?.check();
256
225
  if ((await onToken(t.token, t.logprobs)) === false) break;
257
226
  }
258
227
  const s = gen.stats!; // set on completion AND on early break
259
- if (checkpointKey) ssdStore!.removeGenerationCheckpoints(checkpointKey);
228
+ continuation?.complete();
260
229
  if (!skipPromptCache) {
261
230
  // put() fires onPut → the debounced write-behind SSD snapshot
262
231
  // (wired below), covering the batch lane's puts too.