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,262 @@
1
+ import { MlxArray } from "../mlx/array";
2
+ import * as ops from "../mlx/ops";
3
+ import { cloneKvCaches } from "../kv-store";
4
+ import { type BatchableCache, type Cache, type Mask, type PaddedPrefillCache, type PrefillPadding, RotatingKVCache, RotatingQuantizedKVCache } from "./gemma4-base";
5
+ import { BatchedRotatingCache, buildBatchedRotatingMask } from "./batched-rotating";
6
+ import { BatchedRotatingQuantCache } from "./batched-rotating-quant";
7
+ import { BatchedRotatingState, type RotatingPositionSnapshot } from "./batched-rotating-state";
8
+ import { plainRowStorage, quantizedRowStorage, temporalStorageView } from "./batched-row-storage";
9
+ import type { TransitioningKvPositions, TransitionedKvLayout } from "../backends/mlx/transitioning-kv-rows";
10
+ import { captureRotatingDonorAttention } from "./rotating-kv-donor";
11
+
12
+ import { rollbackRotatingRing } from "./rotating-row-transaction";
13
+
14
+ const fields = ["packed", "scales", "biases"] as const;
15
+ type Ring = BatchedRotatingCache | BatchedRotatingQuantCache;
16
+
17
+ function combinedPosition(rings: readonly Ring[]): RotatingPositionSnapshot {
18
+ const states = rings.map(ring => ring.positionSnapshot);
19
+ return { ...states[0]!, offsets: states.map(state => state.offsets[0]!),
20
+ leftPad: states.map(state => state.leftPad[0]!),
21
+ prefillEnds: states[0]!.prefillEnds
22
+ ? states.map(state => state.prefillEnds![0]!) : undefined };
23
+ }
24
+
25
+ /** One logical request with the group's physical ring geometry. This keeps
26
+ * masks stable while different requests cross their precision boundary. */
27
+ export class AlignedRotatingCache implements Cache {
28
+ minimumReusableOffset = 0;
29
+ constructor(public inner: Ring) {}
30
+ get offset(): number { return this.inner.validOffset(0); }
31
+ get affineConversion(): AlignedRotatingCache | undefined { return this.inner instanceof BatchedRotatingCache ? this : undefined; }
32
+ get quantizedAttention() { return this.inner instanceof BatchedRotatingQuantCache ? this.inner : undefined; }
33
+ signature(): string { return this.inner.signature(); }
34
+ captureDonorAttention() { return captureRotatingDonorAttention(this.inner); }
35
+ updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] { return this.inner.updateAndFetch(k, v); }
36
+ makeMask(tokens: number, window: number | null): Mask { return this.inner.makeMask(tokens, window); }
37
+ isTrimmable(): boolean { return this.inner.positionSnapshot.totalOffset < this.inner.maxSize; }
38
+ trim(count: number): void { this.inner.trim(count); }
39
+ rollback(before: number, keep: number, preserveColumns: boolean): void {
40
+ this.inner = rollbackRotatingRing(this.inner, [before], keep, preserveColumns);
41
+ }
42
+ state(): MlxArray[] { return this.inner.state(); }
43
+ bytesPerToken(): number {
44
+ const state = this.state(), tokens = state[0]?.shape[2] ?? 0;
45
+ return tokens ? state.reduce((n, a) => n + a.nbytes, 0) / tokens : 0;
46
+ }
47
+ dispose(): void { this.inner.dispose(); }
48
+ extract(limit?: number): Cache {
49
+ const row = this.inner.extractRow(0, limit) ?? (this.inner instanceof BatchedRotatingQuantCache
50
+ ? new RotatingQuantizedKVCache(this.inner.maxSize, this.inner.groupSize, this.inner.bits)
51
+ : new RotatingKVCache(this.inner.maxSize));
52
+ row.minimumReusableOffset = this.minimumReusableOffset;
53
+ return row;
54
+ }
55
+ toQuantized(groupSize: number, bits: number): AlignedRotatingCache {
56
+ const plain = this.inner as BatchedRotatingCache;
57
+ const keys = ops.quantize(plain.keys!, groupSize, bits);
58
+ let values: ops.QuantizedTensor;
59
+ try { values = ops.quantize(plain.values!, groupSize, bits); }
60
+ catch (error) { quantizedRowStorage.dispose(keys); throw error; }
61
+ const next = new AlignedRotatingCache(BatchedRotatingQuantCache.adoptPhysical(keys, values, groupSize, bits, plain.positionSnapshot));
62
+ next.minimumReusableOffset = this.minimumReusableOffset;
63
+ this.dispose();
64
+ return next;
65
+ }
66
+ }
67
+
68
+ /** Borrow the newest valid window from a solo cache, including a temporary
69
+ * oversized block. No dequantization or physical-ring normalization in place. */
70
+ export function rotatingSourcePosition(row: RotatingKVCache | RotatingQuantizedKVCache): BatchedRotatingState {
71
+ const state = new BatchedRotatingState(row.maxSize, [0], [row.offset]);
72
+ const length = row instanceof RotatingKVCache ? row.keys?.shape[2] ?? 0 : row.keys?.packed.shape[2] ?? 0;
73
+ state.restore({ maxSize: row.maxSize, offsets: [row.offset], leftPad: [0], totalOffset: row.offset,
74
+ ringIndex: row.ringIdx, rotated: row.ringIdx < row.offset && row.ringIdx < length });
75
+ return state;
76
+ }
77
+
78
+ /** Take ownership of solo rows and align them once at membership changes. */
79
+ export function alignRotatingRows(rows: Cache[]): AlignedRotatingCache[] {
80
+ const width = Math.max(0, ...rows.map(row => Math.min(row.offset, (row as RotatingKVCache).maxSize)));
81
+ const prototype = rows.find(row => row.state().length > 0) as RotatingKVCache | RotatingQuantizedKVCache | undefined;
82
+ const emptyPlane = (field: "keys" | "values"): MlxArray | null => {
83
+ if (!width || !prototype) return null;
84
+ const tensor = prototype[field]!;
85
+ if (tensor instanceof MlxArray) return ops.zeros([1, tensor.shape[1]!, width, tensor.shape[3]!], tensor.dtype);
86
+ return ops.zeros([1, tensor.packed.shape[1]!, width, tensor.packed.shape[3]! * 32 / (prototype as RotatingQuantizedKVCache).bits], tensor.scales.dtype);
87
+ };
88
+ const result: AlignedRotatingCache[] = [];
89
+ try {
90
+ for (const source of rows) {
91
+ if (!(source instanceof RotatingKVCache || source instanceof RotatingQuantizedKVCache))
92
+ throw new Error(`rotating layout cannot adopt ${source.signature()}`);
93
+ const length = Math.min(source.offset, source.maxSize), pad = width - length;
94
+ const position: RotatingPositionSnapshot = { maxSize: source.maxSize, offsets: [source.offset], leftPad: [pad],
95
+ totalOffset: width, ringIndex: width, rotated: false };
96
+ const state = rotatingSourcePosition(source);
97
+ const options = { from: Math.max(0, state.activeLength - length), to: state.activeLength };
98
+ let inner: Ring;
99
+ if (source instanceof RotatingKVCache) {
100
+ using k = source.keys ? temporalStorageView(plainRowStorage, source.keys, state, options) : null;
101
+ using v = source.values ? temporalStorageView(plainRowStorage, source.values, state, options) : null;
102
+ const keys = k ? plainRowStorage.padLeft(k, pad) : emptyPlane("keys");
103
+ let values: MlxArray | null;
104
+ try { values = v ? plainRowStorage.padLeft(v, pad) : emptyPlane("values"); }
105
+ catch (error) { keys?.dispose(); throw error; }
106
+ inner = BatchedRotatingCache.adoptPhysical(keys, values, position);
107
+ } else {
108
+ const k = source.keys ? temporalStorageView(quantizedRowStorage, source.keys, state, options) : null;
109
+ const v = source.values ? temporalStorageView(quantizedRowStorage, source.values, state, options) : null;
110
+ const empty = (field: "keys" | "values") => {
111
+ using tensor = emptyPlane(field);
112
+ return tensor ? ops.quantize(tensor, source.groupSize, source.bits) : null;
113
+ };
114
+ try {
115
+ const keys = k ? quantizedRowStorage.padLeft(k, pad) : empty("keys");
116
+ let values: ops.QuantizedTensor | null;
117
+ try { values = v ? quantizedRowStorage.padLeft(v, pad) : empty("values"); }
118
+ catch (error) { if (keys) quantizedRowStorage.dispose(keys); throw error; }
119
+ inner = BatchedRotatingQuantCache.adoptPhysical(keys, values, source.groupSize, source.bits, position);
120
+ } finally { if (k) quantizedRowStorage.dispose(k); if (v) quantizedRowStorage.dispose(v); }
121
+ }
122
+ const row = new AlignedRotatingCache(inner); row.minimumReusableOffset = source.minimumReusableOffset ?? 0;
123
+ result.push(row);
124
+ }
125
+ } catch (error) { for (const row of result) row.dispose(); throw error; }
126
+ for (const row of rows) row.dispose();
127
+ return result;
128
+ }
129
+
130
+ /** Position state has no codec or attention arithmetic. */
131
+ export class RotatingKvPositions implements TransitioningKvPositions {
132
+ #state: BatchedRotatingState;
133
+ #rope?: MlxArray;
134
+ constructor(maxSize: number) { this.#state = new BatchedRotatingState(maxSize, []); }
135
+ get rowOffsets(): readonly number[] { return this.#state.offsets; }
136
+ get leftPad(): readonly number[] { return this.#state.leftPad; }
137
+ get offset(): number { return this.#state.totalOffset; }
138
+ get batchSize(): number | null { return this.#state.batchSize || null; }
139
+ get ropeOffsetArr(): MlxArray | undefined {
140
+ if (!this.batchSize) return undefined;
141
+ return this.#rope ??= ops.fromInt32([...this.rowOffsets], [this.batchSize]);
142
+ }
143
+ sync(rows: readonly Cache[]): void {
144
+ this.#rope?.dispose(); this.#rope = undefined;
145
+ if (!rows.length) { this.#state = new BatchedRotatingState(this.#state.maxSize, []); return; }
146
+ this.#state.restore(combinedPosition(rows.map(row => (row as AlignedRotatingCache).inner)));
147
+ }
148
+ filterRows(keep: readonly number[]): void { this.#state.filter(keep); this.#rope?.dispose(); this.#rope = undefined; }
149
+ makeMask(tokens: number, window: number | null): Mask {
150
+ return { mode: "array", arr: buildBatchedRotatingMask(this.#state.batchSize, tokens, this.#state.leftPad,
151
+ this.#state.maxSize, window ?? this.#state.maxSize, this.#state.ringIndex, this.#state.totalOffset, this.#state.rotated,
152
+ tokens > 1 || this.#state.hasPendingPadding) };
153
+ }
154
+ dispose(): void { this.#rope?.dispose(); this.#rope = undefined; this.#state = new BatchedRotatingState(this.#state.maxSize, []); }
155
+ }
156
+
157
+ /** Once all rows use affine storage, concatenate their already-aligned planes
158
+ * without changing ring columns or rebuilding any attention mask. */
159
+ export class RotatingAffineLayout implements TransitionedKvLayout, PaddedPrefillCache {
160
+ #inner?: BatchedRotatingQuantCache;
161
+ #before?: number[];
162
+ #minimum: number[] = [];
163
+ constructor(readonly maxSize: number, readonly groupSize: number, readonly bits: number) {}
164
+ get minimumReusableOffset(): number { return Math.max(0, ...this.#minimum); }
165
+ get quantizedAttention() { return this.#inner; }
166
+ captureDonorAttention(): import("./gemma4-base").KvDonorAttention {
167
+ return captureRotatingDonorAttention(this.#inner!);
168
+ }
169
+ get rowOffsets(): readonly number[] { return this.#inner?.offsetArr ?? []; }
170
+ get leftPad(): readonly number[] { return this.#inner?.leftPad ?? []; }
171
+ get offset(): number { return this.#inner?.offset ?? 0; }
172
+ get batchSize(): number | null { return this.#inner?.batchSize || null; }
173
+ get ropeOffsetArr(): MlxArray | undefined { return this.#inner?.ropeOffsetArr; }
174
+ get maxTokens(): number { return this.maxSize; }
175
+ signature(): string { return `kv:rotating-affine:${this.maxSize}:${this.groupSize}:${this.bits}`; }
176
+ makeEmptyBatch(): RotatingAffineLayout { return new RotatingAffineLayout(this.maxSize, this.groupSize, this.bits); }
177
+ makeMask(tokens: number, window: number | null): Mask { return this.#inner!.makeMask(tokens, window); }
178
+ preparePrefill(padding: PrefillPadding): void { this.#inner!.preparePrefill(padding); }
179
+ finalizePrefill(): void { this.#inner!.finalizePrefill(); }
180
+ bytesPerToken(): number {
181
+ const tokens = this.#inner?.keys?.packed.shape[2] ?? 0;
182
+ return tokens ? this.state().reduce((n, a) => n + a.nbytes, 0) / (tokens * this.#inner!.batchSize) : 0;
183
+ }
184
+ projectedBytes(tokens: number): number { return this.bytesPerToken() * Math.min(tokens, this.maxSize); }
185
+ isTrimmable(): boolean { return this.#inner!.positionSnapshot.totalOffset < this.maxSize; }
186
+ trim(count: number): void { this.#inner!.trim(count); }
187
+ specRoundBegin(): void { this.#before = [...this.rowOffsets]; }
188
+ specRoundCommit(): void { this.#before = undefined; }
189
+ specRoundRollback(keep: number | readonly number[]): void {
190
+ this.#inner = rollbackRotatingRing(this.#inner!, this.#before!, keep);
191
+ this.#before = undefined;
192
+ }
193
+ updateAndFetch(): [MlxArray, MlxArray] { return this.#inner!.updateAndFetch(); }
194
+ releaseRopeArr(): void { this.#inner?.releaseRopeArr(); }
195
+ mergeRows(rows: readonly Cache[]): void {
196
+ const owned: Cache[] = [];
197
+ try {
198
+ for (const row of rows) {
199
+ if (row instanceof RotatingAffineLayout) {
200
+ for (let i = 0; i < (row.batchSize ?? 0); i++) owned.push(row.extractRow(i));
201
+ } else if (row instanceof AlignedRotatingCache) owned.push(row.extract());
202
+ else owned.push(...cloneKvCaches([row]));
203
+ }
204
+ if (!owned.length) { this.dispose(); return; }
205
+ const aligned = alignRotatingRows(owned); owned.length = 0;
206
+ try { this.adoptAlignedRows(aligned); } finally { for (const row of aligned) row.dispose(); }
207
+ } finally { for (const row of owned) row.dispose(); }
208
+ }
209
+ adoptAlignedRows(rows: readonly AlignedRotatingCache[]): void {
210
+ const rings = rows.map(row => (row as AlignedRotatingCache).inner as BatchedRotatingQuantCache);
211
+ const position = combinedPosition(rings);
212
+ if (rings.every(row => !row.keys)) {
213
+ const empty = BatchedRotatingQuantCache.adoptPhysical(null, null, this.groupSize, this.bits,
214
+ position);
215
+ this.#inner?.dispose(); this.#inner = empty; this.#minimum = rows.map(row => row.minimumReusableOffset ?? 0);
216
+ return;
217
+ }
218
+ const merge = (field: "keys" | "values") => {
219
+ const planes: MlxArray[] = [];
220
+ try {
221
+ for (const part of fields) planes.push(ops.concatAxis(rings.map(row => row[field]![part]), 0));
222
+ return { packed: planes[0]!, scales: planes[1]!, biases: planes[2]! };
223
+ } catch (error) { for (const plane of planes) plane.dispose(); throw error; }
224
+ };
225
+ const keys = merge("keys"); let values: ops.QuantizedTensor;
226
+ try { values = merge("values"); } catch (error) { quantizedRowStorage.dispose(keys); throw error; }
227
+ const inner = BatchedRotatingQuantCache.adoptPhysical(keys, values, this.groupSize, this.bits,
228
+ position);
229
+ this.#inner?.dispose(); this.#inner = inner; this.#minimum = rows.map(row => row.minimumReusableOffset ?? 0);
230
+ }
231
+ filterRows(keep: readonly number[]): void { this.#inner!.filterRows(keep); this.#minimum = keep.map(row => this.#minimum[row]!); }
232
+ extractRow(row: number): Cache {
233
+ return this.extract(row);
234
+ }
235
+ protected extract(row: number, limit?: number): Cache {
236
+ const result = this.#inner!.extractRow(row, limit) ?? new RotatingQuantizedKVCache(this.maxSize, this.groupSize, this.bits);
237
+ result.minimumReusableOffset = this.#minimum[row] ?? 0; return result;
238
+ }
239
+ state(): MlxArray[] { return this.#inner?.state() ?? []; }
240
+ dispose(): void { this.#inner?.dispose(); this.#inner = undefined; this.#minimum = []; this.#before = undefined; }
241
+ }
242
+
243
+ /** A committed speculative checkpoint contains only the newest valid window. */
244
+ export class SpeculativeRotatingAffineLayout extends RotatingAffineLayout {
245
+ #ropeForOffset = -1;
246
+ override get ropeOffsetArr(): MlxArray | undefined {
247
+ if (this.#ropeForOffset !== this.offset) {
248
+ this.releaseRopeArr(); this.#ropeForOffset = this.offset;
249
+ }
250
+ return super.ropeOffsetArr;
251
+ }
252
+ override makeEmptyBatch(): SpeculativeRotatingAffineLayout {
253
+ return new SpeculativeRotatingAffineLayout(this.maxSize, this.groupSize, this.bits);
254
+ }
255
+ override makeMask(tokens: number, window: number | null): Mask {
256
+ if (this.leftPad.every(pad => pad === 0) &&
257
+ this.offset + tokens <= Math.min(this.maxSize, window ?? this.maxSize))
258
+ return { mode: tokens === 1 ? "" : "causal", arr: null };
259
+ return super.makeMask(tokens, window);
260
+ }
261
+ override extractRow(row: number): Cache { return this.extract(row, this.maxSize); }
262
+ }
@@ -0,0 +1,35 @@
1
+ import * as ops from "../mlx/ops";
2
+ import { BatchedRotatingCache } from "./batched-rotating";
3
+ import { BatchedRotatingQuantCache } from "./batched-rotating-quant";
4
+ import { rotatingRollbackPlan } from "./batched-rotating-state";
5
+ import { plainRowStorage, quantizedRowStorage, rowRollIndices } from "./batched-row-storage";
6
+
7
+ export type RotatingRing = BatchedRotatingCache | BatchedRotatingQuantCache;
8
+
9
+ /** Retain accepted columns without decoding or recomputing the ring. Ownership
10
+ * moves to the returned ring; aligned members preserve their shared rectangle. */
11
+ export function rollbackRotatingRing(ring: BatchedRotatingCache, before: readonly number[], keep: number | readonly number[], preserveColumns?: boolean): BatchedRotatingCache;
12
+ export function rollbackRotatingRing(ring: BatchedRotatingQuantCache, before: readonly number[], keep: number | readonly number[], preserveColumns?: boolean): BatchedRotatingQuantCache;
13
+ export function rollbackRotatingRing(ring: RotatingRing, before: readonly number[], keep: number | readonly number[], preserveColumns?: boolean): RotatingRing;
14
+ export function rollbackRotatingRing(ring: RotatingRing, before: readonly number[],
15
+ keep: number | readonly number[], preserveColumns = false): RotatingRing {
16
+ const plan = rotatingRollbackPlan(ring.positionSnapshot, before, keep, preserveColumns);
17
+ if ("trim" in plan) { ring.trim(plan.trim); ring.releaseRopeArr(); return ring; }
18
+ let next: RotatingRing;
19
+ if (ring instanceof BatchedRotatingQuantCache) {
20
+ using indices = rowRollIndices(ring.keys!.packed.shape[2]!, plan.shifts);
21
+ const keys = quantizedRowStorage.rollRows(ring.keys!, indices);
22
+ let values: ops.QuantizedTensor;
23
+ try { values = quantizedRowStorage.rollRows(ring.values!, indices); }
24
+ catch (error) { quantizedRowStorage.dispose(keys); throw error; }
25
+ next = BatchedRotatingQuantCache.adoptPhysical(keys, values, ring.groupSize, ring.bits, plan.position);
26
+ } else {
27
+ using indices = rowRollIndices(ring.keys!.shape[2]!, plan.shifts);
28
+ const keys = plainRowStorage.rollRows(ring.keys!, indices);
29
+ let values: import("../mlx/array").MlxArray;
30
+ try { values = plainRowStorage.rollRows(ring.values!, indices); }
31
+ catch (error) { keys.dispose(); throw error; }
32
+ next = BatchedRotatingCache.adoptPhysical(keys, values, plan.position);
33
+ }
34
+ ring.dispose(); return next;
35
+ }
@@ -0,0 +1,101 @@
1
+ import { rotatingSourcePosition } from "./rotating-kv-layout";
2
+ import { MlxArray } from "../mlx/array";
3
+ import * as ops from "../mlx/ops";
4
+ import { type BatchableCache, type Cache, type Mask, type PrefillPadding, RotatingKVCache } from "./gemma4-base";
5
+ import { BatchedRotatingCache } from "./batched-rotating";
6
+ import { rollbackRotatingRing } from "./rotating-row-transaction";
7
+ import { BatchedRotatingState } from "./batched-rotating-state";
8
+ import { plainRowStorage, temporalStorageView } from "./batched-row-storage";
9
+ import { disposeResources } from "../engine/resources";
10
+
11
+ /** Ring transactions retain accepted KV columns, including rounds that cross
12
+ * the window boundary. The verify block already retains the complete history
13
+ * needed by its earliest query; no target recomputation or scheduler policy. */
14
+ export class SpeculativeRotatingKVCache implements BatchableCache {
15
+ #inner: BatchedRotatingCache;
16
+ #before?: number[];
17
+ constructor(readonly maxSize: number) { this.#inner = new BatchedRotatingCache(maxSize, []); }
18
+ signature(): string { return "kv:rotating-plain"; }
19
+ get offset(): number { return this.#inner.offset; }
20
+ get rowOffsets(): readonly number[] { return this.#inner.offsetArr; }
21
+ get leftPad(): readonly number[] { return this.#inner.leftPad; }
22
+ get batchSize(): number { return this.#inner.batchSize; }
23
+ get ropeOffsetArr(): MlxArray { return this.#inner.ropeOffsetArr; }
24
+ makeEmptyBatch(): SpeculativeRotatingKVCache { return new SpeculativeRotatingKVCache(this.maxSize); }
25
+ makeMask(tokens: number, window: number | null): Mask { return this.#inner.makeMask(tokens, window); }
26
+ updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] { return this.#inner.updateAndFetch(k, v); }
27
+ preparePrefill(padding: PrefillPadding): void { this.#inner.preparePrefill(padding); }
28
+ finalizePrefill(): void { this.#inner.finalizePrefill(); }
29
+ state(): MlxArray[] { return this.#inner.state(); }
30
+ isTrimmable(): boolean { return this.#inner.isTrimmable(); }
31
+ trim(count: number): void { this.#inner.trim(count); }
32
+ bytesPerToken(): number {
33
+ const state = this.state();
34
+ return state.length ? state.reduce((n, a) => n + a.nbytes, 0) / (this.batchSize * state[0]!.shape[2]!) : 0;
35
+ }
36
+ projectedBytes(tokens: number): number { return this.bytesPerToken() * Math.min(tokens, this.maxSize); }
37
+ extractRow(row: number): Cache {
38
+ const result = new RotatingKVCache(this.maxSize);
39
+ if (!this.rowOffsets[row] || !this.#inner.keys || !this.#inner.values) return result;
40
+ const position = new BatchedRotatingState(this.maxSize, []); position.restore(this.#inner.positionSnapshot);
41
+ const range = { row, from: Math.max(0, position.leftPad[row]!, position.activeLength - this.maxSize),
42
+ to: position.activeLength, copy: true };
43
+ const keys = temporalStorageView(plainRowStorage, this.#inner.keys, position, range);
44
+ let values: MlxArray;
45
+ try { values = temporalStorageView(plainRowStorage, this.#inner.values, position, range); }
46
+ catch (error) { keys.dispose(); throw error; }
47
+ result.restoreState(keys, values, this.rowOffsets[row]!, keys.shape[2]!); return result;
48
+ }
49
+ captureDonorRows(): import("./gemma4-base").KvDonorRows {
50
+ const position = new BatchedRotatingState(this.maxSize, []); position.restore(this.#inner.positionSnapshot);
51
+ const from = Math.max(0, position.activeLength - this.maxSize);
52
+ const range = { from, to: position.activeLength };
53
+ const keys = temporalStorageView(plainRowStorage, this.#inner.keys!, position, range); let values: MlxArray;
54
+ try { values = temporalStorageView(plainRowStorage, this.#inner.values!, position, range); }
55
+ catch (error) { keys.dispose(); throw error; }
56
+ return { keys, values, offsets: [...this.rowOffsets], starts: this.leftPad.map(pad => Math.max(0, pad - from)),
57
+ ends: this.rowOffsets.map(() => position.activeLength - from) };
58
+ }
59
+
60
+ filterRows(keep: readonly number[]): void { this.#inner.filterRows(keep); }
61
+ mergeRows(sources: readonly Cache[]): void {
62
+ const only = sources.length === 1 ? sources[0] : undefined;
63
+ if (only instanceof SpeculativeRotatingKVCache) {
64
+ const keys = only.#inner.keys ? ops.copyOf(only.#inner.keys) : null;
65
+ let values: MlxArray | null;
66
+ try { values = only.#inner.values ? ops.copyOf(only.#inner.values) : null; }
67
+ catch (error) { keys?.dispose(); throw error; }
68
+ const next = BatchedRotatingCache.adoptPhysical(keys, values, only.#inner.positionSnapshot);
69
+ this.#inner.dispose(); this.#inner = next; return;
70
+ }
71
+ const held: Cache[] = [], planes: { keys: MlxArray; values: MlxArray }[] = [], offsets: number[] = [];
72
+ try {
73
+ const rows = sources.flatMap(source => source instanceof SpeculativeRotatingKVCache
74
+ ? Array.from({ length: source.batchSize }, (_, row) => { const cache = source.extractRow(row); held.push(cache); return cache; })
75
+ : [source]) as RotatingKVCache[];
76
+ const prototype = rows.find(row => row.keys !== null);
77
+ if (!prototype) {
78
+ this.#inner.dispose(); this.#inner = new BatchedRotatingCache(this.maxSize, rows.map(() => 0)); return;
79
+ }
80
+ for (const cache of rows) {
81
+ const state = rotatingSourcePosition(cache);
82
+ const range = { from: Math.max(0, state.activeLength - this.maxSize), to: state.activeLength };
83
+ const plane = (field: "keys" | "values") => cache[field]
84
+ ? temporalStorageView(plainRowStorage, cache[field]!, state, range)
85
+ : ops.zeros([1, prototype[field]!.shape[1]!, 0, prototype[field]!.shape[3]!], prototype[field]!.dtype);
86
+ const keys = plane("keys"); let values: MlxArray;
87
+ try { values = plane("values"); } catch (error) { keys.dispose(); throw error; }
88
+ planes.push({ keys, values }); offsets.push(cache.offset);
89
+ }
90
+ const next = BatchedRotatingCache.merge(planes, offsets, this.maxSize);
91
+ this.#inner.dispose(); this.#inner = next;
92
+ } finally { disposeResources([...held, ...planes.flatMap(row => [row.keys, row.values])]); }
93
+ }
94
+ specRoundBegin(): void { this.#before = [...this.rowOffsets]; }
95
+ specRoundCommit(): void { this.#before = undefined; }
96
+ specRoundRollback(keep: number | readonly number[]): void {
97
+ this.#inner = rollbackRotatingRing(this.#inner, this.#before!, keep);
98
+ this.#before = undefined;
99
+ }
100
+ dispose(): void { this.#before = undefined; this.#inner.dispose(); }
101
+ }
@@ -0,0 +1,39 @@
1
+ import type { PrefillPadding } from "./gemma4-base";
2
+ import type { MlxArray } from "../mlx/array";
3
+ import { Dtype } from "../mlx/ffi";
4
+ import * as ops from "../mlx/ops";
5
+
6
+ /** ArraysCache's remaining lengths and padding from mlx-lm (MIT).
7
+ * Recurrent state has no token axis to roll: masked tokens must not update it. */
8
+ export class SsmPrefillPadding {
9
+ #left: number[];
10
+ #lengths: number[];
11
+ constructor(padding: PrefillPadding) {
12
+ this.#left = padding.leftPadding ? [...padding.leftPadding] : padding.lengths.map(() => 0);
13
+ this.#lengths = [...padding.lengths];
14
+ }
15
+ convolutionLengths(tokens: number): number[] {
16
+ return this.#lengths.map(length => Math.max(0, Math.min(tokens, length)));
17
+ }
18
+ makeMask(tokens: number): MlxArray {
19
+ const B = this.#lengths.length;
20
+ using arange = ops.arange(0, tokens, 1, Dtype.int32);
21
+ using positions = ops.reshape(arange, [1, tokens]);
22
+ using left = ops.fromInt32(this.#left, [B, 1]);
23
+ using ends = ops.fromInt32(this.#lengths, [B, 1]);
24
+ using after = ops.greaterEqual(positions, left);
25
+ using before = ops.less(positions, ends);
26
+ return ops.logicalAnd(after, before);
27
+ }
28
+ advance(tokens: number): number[] {
29
+ const counts = this.#lengths.map((length, row) => Math.max(0,
30
+ Math.min(tokens, length) - Math.max(0, Math.min(tokens, this.#left[row]!))));
31
+ this.#lengths = this.#lengths.map(length => length - tokens);
32
+ this.#left = this.#left.map(left => left - tokens);
33
+ return counts;
34
+ }
35
+ filter(keep: readonly number[]): void {
36
+ this.#left = keep.map(row => this.#left[row]!);
37
+ this.#lengths = keep.map(row => this.#lengths[row]!);
38
+ }
39
+ }
@@ -403,7 +403,7 @@ function lutFor(L: number): MlxArray {
403
403
  return a;
404
404
  }
405
405
 
406
- /** Decode variant (see HEADER). Default is the fastest measured on M1 Max;
406
+ /** Decode variant (see HEADER). Default passed the M4 Pro closeout matrix;
407
407
  * `MLX_BUN_TRELLIS_VARIANT` overrides for experiments. */
408
408
  let variantOverride: number | null = null;
409
409
  /** Explicit override (benches); otherwise the runtime flag — read per call so
@@ -411,7 +411,7 @@ let variantOverride: number | null = null;
411
411
  * can A/B two decodes on one loaded model. */
412
412
  export function setTrellisVariant(v: number | null): void { variantOverride = v; }
413
413
  function variant(): number {
414
- return variantOverride ?? runtimeNumber("MLX_BUN_TRELLIS_VARIANT", 6);
414
+ return variantOverride ?? runtimeNumber("MLX_BUN_TRELLIS_VARIANT", 13);
415
415
  }
416
416
  // Variants 7..13 change work assignment/scheduling, not decoded weight values.
417
417
  function decoderVariant(selected = variant()): number { return selected >= 7 && selected <= 13 ? 6 : selected; }
@@ -0,0 +1,83 @@
1
+ import type { MlxArray } from "../mlx/array";
2
+ import * as tq from "../mlx/turboquant-ops";
3
+ import type { KvCodec } from "../backends/mlx/kv-codec";
4
+
5
+ /** One TurboQuant-encoded (K, V) storage tuple — the 5 arrays kv-store.ts
6
+ * and toQuantized/fromKVCache pass around. Not `ops.QuantizedTensor`
7
+ * (mlx's affine int4/int8 scheme): this is the rotation + Lloyd-Max
8
+ * layout (docs/design/turboquant.md), asymmetric-affine for keys,
9
+ * FWHT+Lloyd-Max for values. Field order is the kv-store.ts tensor-slot
10
+ * contract — do not reorder without updating snapshotCache/loadKvCache. */
11
+ export interface TurboQuantTensor {
12
+ kIdx: MlxArray;
13
+ kScales: MlxArray;
14
+ kZeros: MlxArray;
15
+ vPacked: MlxArray;
16
+ vScales: MlxArray;
17
+ }
18
+
19
+ export const disposeTurboQuant = (t: TurboQuantTensor): void => {
20
+ t.kIdx.dispose();
21
+ t.kScales.dispose();
22
+ t.kZeros.dispose();
23
+ t.vPacked.dispose();
24
+ t.vScales.dispose();
25
+ };
26
+
27
+ /** Numerical representation only. Storage owns capacity, positions, row
28
+ * membership and persistence; this codec retains the existing kernel choices. */
29
+ export class TurboQuantCodec implements KvCodec<TurboQuantTensor> {
30
+ constructor(readonly kBits: number, readonly vBits: number, readonly fusedDecode: boolean) {}
31
+
32
+ /** Encode k/v [B,H,L,D] into a fresh TurboQuantTensor (packed at rest). */
33
+ encode(k: MlxArray, v: MlxArray): TurboQuantTensor {
34
+ const signed = this.kBits === 8;
35
+ const kEnc = tq.encodeKeys(k, this.kBits, signed);
36
+ const kIdxPacked = this.kBits < 8 ? tq.packBits(kEnc.indices, this.kBits) : kEnc.indices;
37
+ if (kIdxPacked !== kEnc.indices) kEnc.indices.dispose();
38
+ const vEnc = tq.encodeValues(v, this.vBits);
39
+ const vIdxPacked = this.vBits < 8 ? tq.packBits(vEnc.indices, this.vBits) : vEnc.indices;
40
+ if (vIdxPacked !== vEnc.indices) vEnc.indices.dispose();
41
+ return {
42
+ kIdx: kIdxPacked, kScales: kEnc.scales, kZeros: kEnc.zeros,
43
+ vPacked: vIdxPacked, vScales: vEnc.scales,
44
+ };
45
+ }
46
+
47
+ /** Decode a TurboQuantTensor's active [B,H,upTo,*] window back to
48
+ * bf16 [B,H,upTo,headDim] (k, v). Caller owns the returned arrays.
49
+ * deferV leaves V in the rotated domain (decodeValuesRotated) — the
50
+ * caller un-rotates its attention output instead (tq.unrotateValues). */
51
+ decode(t: TurboQuantTensor, upTo: number, headDim: number, deferV = false): [MlxArray, MlxArray] {
52
+ const cut = (a: MlxArray): MlxArray => {
53
+ const [B, H, , D] = a.shape as [number, number, number, number];
54
+ return a.slice([0, 0, 0, 0], [B, H, upTo, D]);
55
+ };
56
+ if (this.fusedDecode) {
57
+ const inputs = [cut(t.kIdx), cut(t.kScales), cut(t.kZeros), cut(t.vPacked), cut(t.vScales)] as const;
58
+ try {
59
+ const decoded = tq.tryDecodePackedKv(inputs, this.kBits, this.vBits, headDim, deferV);
60
+ if (decoded) return decoded;
61
+ } finally { for (const input of inputs) input.dispose(); }
62
+ }
63
+ const kIdxCut = cut(t.kIdx);
64
+ const kIdxUnpacked = this.kBits < 8 ? tq.unpackBits(kIdxCut, this.kBits, headDim) : kIdxCut;
65
+ if (kIdxUnpacked !== kIdxCut) kIdxCut.dispose();
66
+ const kScalesCut = cut(t.kScales);
67
+ const kZerosCut = cut(t.kZeros);
68
+ const k = tq.decodeKeys(kIdxUnpacked, kScalesCut, kZerosCut);
69
+ for (const a of [kIdxUnpacked, kScalesCut, kZerosCut]) a.dispose();
70
+
71
+ const vPackedCut = cut(t.vPacked);
72
+ const vIdxUnpacked = this.vBits < 8 ? tq.unpackBits(vPackedCut, this.vBits, headDim) : vPackedCut;
73
+ if (vIdxUnpacked !== vPackedCut) vPackedCut.dispose();
74
+ const vScalesCut = cut(t.vScales);
75
+ const v = deferV
76
+ ? tq.decodeValuesRotated(vIdxUnpacked, vScalesCut, this.vBits)
77
+ : tq.decodeValues(vIdxUnpacked, vScalesCut, this.vBits);
78
+ for (const a of [vIdxUnpacked, vScalesCut]) a.dispose();
79
+
80
+ return [k, v];
81
+ }
82
+
83
+ }
@@ -1,8 +1,10 @@
1
+ import { captureKvAttention } from "../kv-attention-view";
1
2
  // UniversalDense — the Tier-0 config-driven dense llama-family module
2
3
  // (docs/design/generic-model-support.md §3.1). One module, shaped exactly
3
4
  // like qwen3.ts, with every arch delta selected by the UniversalArgs
4
5
  // descriptor (archs.ts). Monolith path only: no compiled
5
- // decode, no kv-quant — slow, never broken. The bar is L1 bit-exactness
6
+ // decode. Standard attention uses the shared plain/affine KV port; the
7
+ // softcap branch retains plain KV. The bar is L1 bit-exactness
6
8
  // vs mlx-lm on this machine's GPU (tests/parity/universal-parity.test.ts).
7
9
  //
8
10
  // Porting discipline: each branch transcribes its mlx-lm source op-for-op
@@ -154,19 +156,18 @@ class UniversalAttention {
154
156
  ? this.rope.applyDynamic(k, offArr)
155
157
  : this.rope.apply(k, cache.offset));
156
158
 
157
- const [keys, values] = cache.updateAndFetch(k, v);
158
- k.dispose();
159
- v.dispose();
160
-
161
159
  let attn: MlxArray;
162
- if (a.attnLogitSoftcap !== null) {
163
- attn = this.#softcapAttention(q, keys, values, mask, B, L);
164
- } else {
165
- attn = ops.sdpa(q, keys, values, a.attnScale, mask.mode, mask.arr);
166
- }
167
- keys.dispose();
168
- values.dispose();
169
- q.dispose();
160
+ try {
161
+ if (a.attnLogitSoftcap !== null) {
162
+ const [keys, values] = cache.updateAndFetch(k, v);
163
+ try { attn = this.#softcapAttention(q, keys, values, mask, B, L); }
164
+ finally { keys.dispose(); values.dispose(); }
165
+ } else {
166
+ const view = captureKvAttention(cache, k, v);
167
+ try { attn = view.attend(q, a.attnScale, mask); }
168
+ finally { view.dispose(); }
169
+ }
170
+ } finally { k.dispose(); v.dispose(); q.dispose(); }
170
171
 
171
172
  const attnT = ops.transposeAxes(attn, [0, 2, 1, 3]);
172
173
  attn.dispose();