mlx-bun 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/README.md +57 -32
  2. package/docs/README.md +58 -42
  3. package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
  4. package/docs/archive/investigations/openjarvis-fit.md +198 -0
  5. package/docs/archive/investigations/qwen38-27b-campaign-log.md +1092 -0
  6. package/docs/archive/planning/release-notes-v0.4.0.md +45 -0
  7. package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
  8. package/docs/design/batching.md +1535 -50
  9. package/docs/design/decode-speed-program.md +394 -1171
  10. package/docs/design/generic-model-support.md +195 -3
  11. package/docs/design/kv-cache.md +538 -225
  12. package/docs/design/speculative-decoding.md +552 -50
  13. package/docs/design/turboquant.md +140 -17
  14. package/docs/design/unified-engine-frontier-plan.md +259 -150
  15. package/docs/planning/PRODUCT_ROADMAP.md +21 -6
  16. package/docs/planning/public-readiness-handoff.md +244 -0
  17. package/docs/planning/public-readiness.md +149 -0
  18. package/docs/reference/benchmarks.md +3537 -1112
  19. package/docs/reference/cli.md +102 -2
  20. package/docs/reference/distribution.md +45 -13
  21. package/docs/reference/environment.md +159 -13
  22. package/docs/reference/library-api.md +18 -4
  23. package/docs/reference/memory.md +1 -1
  24. package/docs/reference/models.md +28 -7
  25. package/docs/reference/server-api.md +257 -33
  26. package/docs/reference/server-config.md +372 -151
  27. package/docs/reference/troubleshooting.md +13 -21
  28. package/package.json +2 -2
  29. package/src/audio/audiotoolbox.ts +112 -0
  30. package/src/audio/conformer.ts +8 -0
  31. package/src/audio/mic-capture.ts +98 -0
  32. package/src/audio/silero-vad.ts +379 -0
  33. package/src/audio/whisper-decode.ts +774 -0
  34. package/src/audio/whisper-fast.ts +732 -0
  35. package/src/audio/whisper-format.ts +73 -0
  36. package/src/audio/whisper-mel.ts +185 -0
  37. package/src/audio/whisper-timing.ts +274 -0
  38. package/src/audio/whisper-tokenizer.ts +239 -0
  39. package/src/audio/whisper-transcribe.ts +410 -0
  40. package/src/backends/mlx/assistant-target.ts +46 -0
  41. package/src/backends/mlx/autoregressive-draft-rows.ts +120 -0
  42. package/src/backends/mlx/autoregressive.ts +22 -1
  43. package/src/backends/mlx/batch-group.ts +464 -236
  44. package/src/backends/mlx/cache-attachment.ts +67 -0
  45. package/src/backends/mlx/cache-layout.ts +31 -0
  46. package/src/backends/mlx/checkpoint-state.ts +53 -0
  47. package/src/backends/mlx/continuation-persistence.ts +107 -0
  48. package/src/backends/mlx/continuation-request.ts +43 -0
  49. package/src/backends/mlx/continuation.ts +77 -0
  50. package/src/backends/mlx/draft-target.ts +7 -22
  51. package/src/backends/mlx/encoder-cache.ts +25 -0
  52. package/src/backends/mlx/fill-append.ts +21 -8
  53. package/src/backends/mlx/fill-group.ts +497 -0
  54. package/src/backends/mlx/full-kv-row-append.ts +32 -0
  55. package/src/backends/mlx/full-kv-row-donor.ts +54 -0
  56. package/src/backends/mlx/full-transitioning-kv-rows.ts +65 -0
  57. package/src/backends/mlx/gateway-binding.ts +122 -19
  58. package/src/backends/mlx/kv-codec.ts +9 -0
  59. package/src/backends/mlx/kv-maintenance.ts +161 -0
  60. package/src/backends/mlx/kv-tensor-rows.ts +212 -0
  61. package/src/backends/mlx/mixed-iteration.ts +62 -0
  62. package/src/backends/mlx/model-prompt.ts +46 -32
  63. package/src/backends/mlx/model-serving.ts +5 -4
  64. package/src/backends/mlx/paged-cache-codec.ts +27 -0
  65. package/src/backends/mlx/prefill-cohort.ts +100 -0
  66. package/src/backends/mlx/prefill-policy.ts +26 -0
  67. package/src/backends/mlx/prefill-rows.ts +244 -0
  68. package/src/backends/mlx/prefill.ts +1 -1
  69. package/src/backends/mlx/projected-context-rows.ts +72 -0
  70. package/src/backends/mlx/prompt-input.ts +29 -0
  71. package/src/backends/mlx/qwen-prompt-input.ts +40 -0
  72. package/src/backends/mlx/request-state-policy.ts +54 -0
  73. package/src/backends/mlx/rollback.ts +45 -4
  74. package/src/backends/mlx/row-sampling.ts +49 -0
  75. package/src/backends/mlx/serial-executor.ts +51 -73
  76. package/src/backends/mlx/speculative-group.ts +438 -0
  77. package/src/backends/mlx/speculative-round.ts +161 -0
  78. package/src/backends/mlx/speculative.ts +24 -7
  79. package/src/backends/mlx/state-rows.ts +104 -0
  80. package/src/backends/mlx/state-views.ts +21 -1
  81. package/src/backends/mlx/target-layout-capability.ts +35 -0
  82. package/src/backends/mlx/transitioning-kv-rows.ts +173 -0
  83. package/src/cli.ts +333 -36
  84. package/src/contracts/execution.ts +21 -0
  85. package/src/contracts/object-cache.ts +8 -0
  86. package/src/contracts/pi-provider.ts +1 -1
  87. package/src/contracts/pi.ts +3 -1
  88. package/src/contracts/prefix-cache.ts +33 -0
  89. package/src/contracts/resources.ts +7 -0
  90. package/src/contracts/sampling.ts +23 -0
  91. package/src/contracts/scheduling.ts +27 -0
  92. package/src/engine/execution-plan.ts +38 -25
  93. package/src/engine/execution-tasks.ts +44 -0
  94. package/src/engine/namespaced-cache.ts +13 -0
  95. package/src/engine/resources.ts +12 -1
  96. package/src/engine/scheduler.ts +32 -7
  97. package/src/fill/fill-session.ts +14 -3
  98. package/src/fit.ts +29 -15
  99. package/src/generate.ts +105 -195
  100. package/src/grammar.ts +13 -0
  101. package/src/inference/draft-acceptance.ts +41 -0
  102. package/src/inference/draft-output.ts +49 -0
  103. package/src/inference/prefill.ts +6 -0
  104. package/src/inference/rollback.ts +2 -2
  105. package/src/kv-scheme.ts +92 -12
  106. package/src/kv-store.ts +183 -93
  107. package/src/lab/README.md +5 -1
  108. package/src/lab/paged-kv/paged-attention.ts +117 -0
  109. package/src/lab/paged-kv/paged-kv-rows.ts +111 -0
  110. package/src/lab/paged-kv/paged-kv.ts +126 -46
  111. package/src/md-modules.d.ts +5 -0
  112. package/src/mlx/array.ts +39 -1
  113. package/src/mlx/ffi.ts +19 -0
  114. package/src/mlx/normalized-argmax.ts +116 -0
  115. package/src/mlx/ops.ts +97 -10
  116. package/src/model/artifact-identity.ts +73 -0
  117. package/src/model/batched-kv.ts +235 -0
  118. package/src/model/batched-mask.ts +12 -4
  119. package/src/model/batched-quant.ts +8 -2
  120. package/src/model/batched-quantized-kv.ts +144 -0
  121. package/src/model/batched-rotating-quant.ts +76 -15
  122. package/src/model/batched-rotating-state.ts +76 -0
  123. package/src/model/batched-rotating.ts +64 -22
  124. package/src/model/batched-row-storage.ts +52 -0
  125. package/src/model/batched-ssm.ts +52 -0
  126. package/src/model/batched-turboquant-kv.ts +94 -0
  127. package/src/model/compiled-decode.ts +7 -1
  128. package/src/model/decoded-kv-donor.ts +12 -0
  129. package/src/model/delayed-quantized-kv.ts +72 -0
  130. package/src/model/delayed-rotating-quantized-kv.ts +93 -0
  131. package/src/model/delayed-turboquant-kv.ts +57 -0
  132. package/src/model/factory.ts +26 -0
  133. package/src/model/fingerprint.ts +12 -0
  134. package/src/model/full-prefill-padding.ts +56 -0
  135. package/src/model/full-prefill-row.ts +105 -0
  136. package/src/model/gemma4-base.ts +183 -82
  137. package/src/model/gemma4.ts +116 -28
  138. package/src/model/generated/gemma4-12b.ts +4 -1
  139. package/src/model/generated/gemma4-26b.ts +4 -1
  140. package/src/model/generated/gemma4-e4b.ts +4 -1
  141. package/src/model/glm52-cache.ts +80 -4
  142. package/src/model/glm52-mla.ts +12 -6
  143. package/src/model/glm52.ts +19 -40
  144. package/src/model/kv-attention-view.ts +70 -0
  145. package/src/model/minicpm5.ts +11 -7
  146. package/src/model/profile.ts +29 -4
  147. package/src/model/quantized-append-attention.ts +31 -0
  148. package/src/model/qwen3-delta-state.ts +74 -0
  149. package/src/model/qwen3-delta.ts +59 -36
  150. package/src/model/qwen3-mrope.ts +7 -9
  151. package/src/model/qwen3_5.ts +177 -75
  152. package/src/model/rotating-kv-donor.ts +35 -0
  153. package/src/model/rotating-kv-layout.ts +262 -0
  154. package/src/model/rotating-row-transaction.ts +35 -0
  155. package/src/model/speculative-rotating-kv.ts +101 -0
  156. package/src/model/ssm-prefill-padding.ts +39 -0
  157. package/src/model/support.ts +8 -0
  158. package/src/model/token-groups.ts +66 -0
  159. package/src/model/trellis-linear.ts +4 -4
  160. package/src/model/turboquant-codec.ts +89 -0
  161. package/src/model/universal/dense.ts +14 -13
  162. package/src/model/whisper.ts +435 -0
  163. package/src/native/mic_capture.swift +92 -0
  164. package/src/native-pack.ts +6 -5
  165. package/src/pi-web.ts +9 -1
  166. package/src/preflight.ts +31 -15
  167. package/src/prompt-cache.ts +306 -86
  168. package/src/runtime-config.ts +1 -5
  169. package/src/safetensors.ts +9 -2
  170. package/src/sampler.ts +37 -25
  171. package/src/serve/audio-routes.ts +323 -0
  172. package/src/serve/chat-request.ts +2 -0
  173. package/src/serve/chat-stage.ts +19 -14
  174. package/src/serve/completion-executor.ts +9 -1
  175. package/src/serve/discovery-routes.ts +17 -1
  176. package/src/serve/generated-token-history.ts +57 -0
  177. package/src/serve/generation-gateway.ts +88 -38
  178. package/src/serve/model-binding.ts +7 -1
  179. package/src/serve/model-host.ts +3 -4
  180. package/src/serve/preparation.ts +3 -3
  181. package/src/serve/prompt-response-trace.ts +14 -0
  182. package/src/serve/request-plan.ts +6 -3
  183. package/src/serve/request-prep.ts +40 -25
  184. package/src/serve/text-completion-stage.ts +2 -2
  185. package/src/serve/transcription-server.ts +64 -0
  186. package/src/serve/transcription-service.ts +502 -0
  187. package/src/server.ts +185 -261
  188. package/src/spec/assistant-rows.ts +93 -0
  189. package/src/spec/assistant-source.ts +28 -18
  190. package/src/spec/cache-identity.ts +22 -0
  191. package/src/spec/deepspec-rows.ts +29 -0
  192. package/src/spec/deepspec-source.ts +12 -10
  193. package/src/spec/dflash-source.ts +28 -4
  194. package/src/spec/drafter.ts +153 -142
  195. package/src/spec/dspark/deepspec-module.ts +95 -120
  196. package/src/spec/dspark/module-dflash.ts +126 -170
  197. package/src/spec/glm52-mtp-graph.ts +29 -0
  198. package/src/spec/glm52-mtp-rows.ts +132 -0
  199. package/src/spec/glm52-mtp-source.ts +54 -136
  200. package/src/spec/glm52-mtp-state.ts +33 -0
  201. package/src/spec/ngram-source.ts +115 -21
  202. package/src/spec/projected-draft-rows.ts +73 -0
  203. package/src/spec/qwen-mtp-module.ts +171 -0
  204. package/src/spec/qwen-mtp-rows.ts +230 -0
  205. package/src/spec/qwen-mtp-source.ts +84 -226
  206. package/src/spec/qwen-mtp-state.ts +30 -0
  207. package/src/spec/serve-loop.ts +73 -69
  208. package/src/spec/source.ts +93 -15
  209. package/src/spec/two-model-rows.ts +62 -0
  210. package/src/spec/two-model.ts +45 -103
  211. package/src/ssd-cache.ts +107 -36
  212. package/src/ssd-durability.ts +29 -27
  213. package/src/storage/host-buffer.ts +24 -0
  214. package/src/storage/kv-writer.ts +83 -0
  215. package/src/storage/kv-writer.worker.js +264 -0
  216. package/src/storage/retention-policy.ts +37 -0
  217. package/src/tiered-prompt-cache.ts +45 -0
  218. package/src/vision/embedder.ts +8 -0
  219. package/src/vision/prompt.ts +101 -65
  220. package/src/vision/qwen3vl-prompt.ts +101 -64
  221. package/src/vision/qwen3vl-tower.ts +11 -0
  222. package/src/vision/siglip.ts +8 -0
  223. package/src/web/app.html +12 -0
  224. package/src/web/app.js +217 -57
  225. package/src/web/src/chat.ts +4 -1
  226. package/src/web/src/composer.ts +3 -0
  227. package/src/web/src/voice.ts +141 -0
  228. package/src/web/sw.js +1 -1
  229. package/src/backends/mlx/memory-guard.ts +0 -40
  230. package/src/spec/prefix-state.ts +0 -45
  231. /package/docs/{planning → archive/planning}/release-notes-v0.3.0.md +0 -0
@@ -20,7 +20,8 @@
20
20
  // untouched. PERSISTENT (like BatchedRotatingCache, unlike the ephemeral
21
21
  // per-step BatchedQuantDecodeMaskCache): the scheduler passes it through
22
22
  // every step; it self-tracks per-row offset/leftPad and the scalar ring
23
- // state. N=1 decode updates only (rows solo-prefill serially, then merge).
23
+ // state. Multi-token blocks use the same chronological storage operation as
24
+ // the bf16 layout; one-token updates keep the existing in-place ring kernel.
24
25
  //
25
26
  // Gates: tests/unit/batched-rotating-quant.test.ts (model-free byte-identity vs
26
27
  // the serial oracle per row, through ring wrap, B=1 and B=2) + the gemma
@@ -29,11 +30,13 @@
29
30
  import { MlxArray } from "../mlx/array";
30
31
  import * as ops from "../mlx/ops";
31
32
  import { Dtype } from "../mlx/ffi";
32
- import { RotatingQuantizedKVCache, type Mask } from "./gemma4-base";
33
+ import { RotatingQuantizedKVCache, type Mask, type PaddedPrefillCache, type PrefillPadding } from "./gemma4-base";
33
34
  import { buildBatchedRotatingMask } from "./batched-rotating";
34
35
  import type { QuantRow } from "./batched-quant";
35
- import { BatchedRotatingState } from "./batched-rotating-state";
36
+ import { BatchedRotatingState, type RotatingPositionSnapshot } from "./batched-rotating-state";
36
37
  import {
38
+ appendRotatingStorage,
39
+ rowRollIndices,
37
40
  mergeStorageRows,
38
41
  quantizedRowStorage,
39
42
  temporalStorageView,
@@ -54,7 +57,7 @@ const mapTriple = (
54
57
  * mechanics (see batched-rotating.ts) with RotatingQuantizedKVCache
55
58
  * storage. Scalar ring state (`ringIdx`/`offset` reuse the base fields);
56
59
  * per-row `offsetArr` (absolute positions → RoPE) and `leftPad`. */
57
- export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
60
+ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache implements PaddedPrefillCache {
58
61
  readonly #rows: BatchedRotatingState;
59
62
  /** Per-row RoPE positions. STABLE ACROSS A STEP — refreshed only at
60
63
  * releaseRopeArr() (the scheduler's post-dispatch hook), never inside
@@ -72,7 +75,34 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
72
75
  ) {
73
76
  super(maxSize, groupSize, bits);
74
77
  this.#rows = new BatchedRotatingState(maxSize, leftPad, offsets);
75
- this.ropeOffsetArr = MlxArray.fromInt32(Int32Array.from(offsets), [offsets.length]);
78
+ this.ropeOffsetArr = offsets.length ? MlxArray.fromInt32(Int32Array.from(offsets), [offsets.length]) : ops.zeros([0], Dtype.int32);
79
+ }
80
+
81
+ static empty(maxSize: number, groupSize: number, bits: number, leftPad: readonly number[]): BatchedRotatingQuantCache {
82
+ return new BatchedRotatingQuantCache(maxSize, groupSize, bits, [...leftPad], leftPad.map(pad => -pad));
83
+ }
84
+
85
+ get positionSnapshot(): RotatingPositionSnapshot { return this.#rows.snapshot(); }
86
+ validOffset(row: number): number { return this.#rows.validOffset(row); }
87
+ preparePrefill(padding: PrefillPadding, rightPaddedBatch?: boolean): void { this.#rows.preparePrefill(padding, rightPaddedBatch); this.releaseRopeArr(); }
88
+ finalizePrefill(): void {
89
+ const shifts = this.#rows.prefillRoll();
90
+ if (shifts?.some(shift => shift !== 0) && this.keys && this.values) {
91
+ using indices = rowRollIndices(this.keys.packed.shape[2]!, shifts);
92
+ const keys = quantizedRowStorage.rollRows(this.keys, indices);
93
+ let values: ops.QuantizedTensor;
94
+ try { values = quantizedRowStorage.rollRows(this.values, indices); }
95
+ catch (error) { disposeTriple(keys); throw error; }
96
+ disposeTriple(this.keys); disposeTriple(this.values); this.keys = keys; this.values = values;
97
+ }
98
+ this.#rows.finalizePrefill(); this.releaseRopeArr();
99
+ }
100
+ /** Adopt owned tensors without changing their physical ring columns. */
101
+ static adoptPhysical(keys: ops.QuantizedTensor | null, values: ops.QuantizedTensor | null, groupSize: number, bits: number, position: RotatingPositionSnapshot): BatchedRotatingQuantCache {
102
+ const cache = new BatchedRotatingQuantCache(position.maxSize, groupSize, bits, [...position.leftPad], [...position.offsets]);
103
+ cache.#rows.restore(position); cache.keys = keys; cache.values = values;
104
+ cache.offset = position.totalOffset; cache.ringIdx = position.ringIndex;
105
+ return cache;
76
106
  }
77
107
 
78
108
  get offsetArr(): number[] { return this.#rows.offsets; }
@@ -93,7 +123,7 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
93
123
  * old handle after this). */
94
124
  releaseRopeArr(): void {
95
125
  this.ropeOffsetArr.dispose();
96
- this.ropeOffsetArr = MlxArray.fromInt32(Int32Array.from(this.offsetArr), [this.#B]);
126
+ this.ropeOffsetArr = this.#B ? MlxArray.fromInt32(Int32Array.from(this.offsetArr), [this.#B]) : ops.zeros([0], Dtype.int32);
97
127
  }
98
128
 
99
129
  #allocTriple(B: number, H: number, T: number, dim: number, dtype: Dtype): ops.QuantizedTensor {
@@ -111,7 +141,7 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
111
141
  mode: "array",
112
142
  arr: buildBatchedRotatingMask(
113
143
  this.#B, N, this.leftPad, this.maxSize, window,
114
- this.#rows.ringIndex, this.#rows.totalOffset, this.#rows.rotated,
144
+ this.#rows.ringIndex, this.#rows.totalOffset, this.#rows.rotated, N > 1 || this.#rows.hasPendingPadding,
115
145
  ),
116
146
  };
117
147
  }
@@ -122,8 +152,7 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
122
152
  override updateAndFetchQuantized(k: MlxArray, v: MlxArray): [ops.QuantizedTensor, ops.QuantizedTensor] {
123
153
  const [B, H, S, D] = k.shape as [number, number, number, number];
124
154
  const vD = v.shape[3]!;
125
- if (S !== 1)
126
- throw new Error("BatchedRotatingQuantCache supports N=1 decode updates only (solo-prefill then merge)");
155
+ if (S !== 1 || this.#rows.hasPendingPadding) return this.#updateConcat(k, v);
127
156
  const prev = this.#rows.totalOffset;
128
157
 
129
158
  // Grow the buffer (in STEP chunks) until it reaches maxSize.
@@ -202,6 +231,29 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
202
231
  return [cut(this.keys!), cut(this.values!)];
203
232
  }
204
233
 
234
+ #updateConcat(k: MlxArray, v: MlxArray): [ops.QuantizedTensor, ops.QuantizedTensor] {
235
+ const kq = ops.quantize(k, this.groupSize, this.bits);
236
+ const vq = ops.quantize(v, this.groupSize, this.bits);
237
+ let keys: ops.QuantizedTensor | undefined;
238
+ let values: ops.QuantizedTensor;
239
+ try {
240
+ keys = appendRotatingStorage(quantizedRowStorage, this.keys, kq, this.#rows);
241
+ values = appendRotatingStorage(quantizedRowStorage, this.values, vq, this.#rows);
242
+ } catch (error) { if (keys) disposeTriple(keys); throw error; }
243
+ finally { disposeTriple(kq); disposeTriple(vq); }
244
+ const history = this.keys ? this.#rows.activeLength : 0;
245
+ if (this.keys) disposeTriple(this.keys);
246
+ if (this.values) disposeTriple(this.values);
247
+ this.keys = keys; this.values = values;
248
+ this.#rows.commitConcat(k.shape[2]!, history);
249
+ this.offset = this.#rows.totalOffset;
250
+ this.ringIdx = this.#rows.ringIndex;
251
+ return [
252
+ quantizedRowStorage.slice(keys, 0, this.#B, 0, this.ringIdx),
253
+ quantizedRowStorage.slice(values, 0, this.#B, 0, this.ringIdx),
254
+ ];
255
+ }
256
+
205
257
  #assign2(dst: MlxArray, src: MlxArray, S: number): MlxArray {
206
258
  const [B, H, , D] = dst.shape as [number, number, number, number];
207
259
  const out = ops.sliceUpdate(dst, src, [0, 0, this.ringIdx, 0], [B, H, this.ringIdx + S, D]);
@@ -214,8 +266,12 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
214
266
  override temporalView(): [ops.QuantizedTensor, ops.QuantizedTensor] {
215
267
  if (!this.keys || !this.values) throw new Error("cache is empty");
216
268
  return [
217
- temporalStorageView(quantizedRowStorage, this.keys, this.#rows),
218
- temporalStorageView(quantizedRowStorage, this.values, this.#rows),
269
+ temporalStorageView(quantizedRowStorage, this.keys, this.#rows, {
270
+ from: Math.max(0, this.#rows.activeLength - this.maxSize), to: this.#rows.activeLength,
271
+ }),
272
+ temporalStorageView(quantizedRowStorage, this.values, this.#rows, {
273
+ from: Math.max(0, this.#rows.activeLength - this.maxSize), to: this.#rows.activeLength,
274
+ }),
219
275
  ];
220
276
  }
221
277
 
@@ -228,15 +284,15 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
228
284
  * is byte-safe (packing along HEAD_DIM — file header); per-row byte
229
285
  * identity vs the serial oracle is the class invariant
230
286
  * (tests/batched-rotating-quant), extraction is a pure slice+copy. */
231
- extractRow(i: number): RotatingQuantizedKVCache | null {
287
+ extractRow(i: number, limit = Infinity): RotatingQuantizedKVCache | null {
232
288
  if (!this.keys || !this.values) return null;
233
- const pad = Math.max(0, this.leftPad[i]!);
289
+ const pad = Math.max(0, this.leftPad[i]!, this.#rows.activeLength - limit);
234
290
  const c = new RotatingQuantizedKVCache(this.maxSize, this.groupSize, this.bits);
235
291
  const k = temporalStorageView(quantizedRowStorage, this.keys, this.#rows, {
236
- row: i, from: pad, copy: true,
292
+ row: i, from: pad, to: this.#rows.activeLength, copy: true,
237
293
  });
238
294
  const v = temporalStorageView(quantizedRowStorage, this.values, this.#rows, {
239
- row: i, from: pad, copy: true,
295
+ row: i, from: pad, to: this.#rows.activeLength, copy: true,
240
296
  });
241
297
  c.restoreState(k, v, this.offsetArr[i]!, k.packed.shape[2]!);
242
298
  return c;
@@ -258,6 +314,11 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
258
314
 
259
315
  filterRows(keep: readonly number[]): void { this.filter([...keep]); }
260
316
 
317
+ override trim(count: number): void {
318
+ this.#rows.trim(count); this.offset = this.#rows.totalOffset; this.ringIdx = this.#rows.ringIndex;
319
+ this.releaseRopeArr();
320
+ }
321
+
261
322
  override isTrimmable(): boolean {
262
323
  return false; // batched rows never re-enter the prompt cache directly
263
324
  }
@@ -1,3 +1,27 @@
1
+ import type { PrefillPadding } from "./gemma4-base";
2
+
3
+ export interface RotatingPositionSnapshot {
4
+ readonly maxSize: number;
5
+ readonly offsets: readonly number[];
6
+ readonly leftPad: readonly number[];
7
+ readonly ringIndex: number;
8
+ readonly totalOffset: number;
9
+ readonly rotated: boolean;
10
+ readonly prefillEnds?: readonly number[];
11
+ }
12
+
13
+ /** Acceptance changes row positions, independently of plain or encoded planes. */
14
+ export function rotatingRollbackPlan(snapshot: RotatingPositionSnapshot, before: readonly number[],
15
+ keep: number | readonly number[], preserveColumns = false): { trim: number } | { shifts: number[]; position: RotatingPositionSnapshot } {
16
+ const counts = before.map((_, row) => typeof keep === "number" ? keep : keep[row]!);
17
+ if (!preserveColumns && before.length === 1 && snapshot.totalOffset < snapshot.maxSize)
18
+ return { trim: snapshot.offsets[0]! - before[0]! - counts[0]! };
19
+ const offsets = before.map((offset, row) => offset + counts[row]!);
20
+ const shifts = snapshot.offsets.map((offset, row) => offset - offsets[row]!);
21
+ return { shifts, position: { ...snapshot, offsets,
22
+ leftPad: snapshot.leftPad.map((pad, row) => pad + shifts[row]!) } };
23
+ }
24
+
1
25
  /** Host-side row and ring bookkeeping shared by plain and quantized storage. */
2
26
  export class BatchedRotatingState {
3
27
  readonly maxSize: number;
@@ -6,6 +30,7 @@ export class BatchedRotatingState {
6
30
  ringIndex = 0;
7
31
  totalOffset = 0;
8
32
  rotated = false;
33
+ #prefillEnds?: number[];
9
34
 
10
35
  constructor(maxSize: number, leftPad: readonly number[], offsets?: readonly number[]) {
11
36
  this.maxSize = maxSize;
@@ -13,8 +38,45 @@ export class BatchedRotatingState {
13
38
  this.offsets = offsets ? [...offsets] : leftPad.map((pad) => -pad);
14
39
  }
15
40
 
41
+ snapshot(): RotatingPositionSnapshot {
42
+ return { maxSize: this.maxSize, offsets: [...this.offsets], leftPad: [...this.leftPad],
43
+ ringIndex: this.ringIndex, totalOffset: this.totalOffset, rotated: this.rotated,
44
+ ...(this.#prefillEnds ? { prefillEnds: [...this.#prefillEnds] } : {}) };
45
+ }
46
+ restore(snapshot: RotatingPositionSnapshot): void {
47
+ this.offsets = [...snapshot.offsets]; this.leftPad = [...snapshot.leftPad];
48
+ this.ringIndex = snapshot.ringIndex; this.totalOffset = snapshot.totalOffset; this.rotated = snapshot.rotated;
49
+ this.#prefillEnds = snapshot.prefillEnds ? [...snapshot.prefillEnds] : undefined;
50
+ }
51
+
52
+ preparePrefill(padding: PrefillPadding, rightPaddedBatch = padding.rightPadding?.some(pad => pad > 0)): void {
53
+ if (!this.batchSize) { this.leftPad = padding.lengths.map(() => 0); this.offsets = [...this.leftPad]; }
54
+ if (padding.leftPadding) {
55
+ this.leftPad = this.leftPad.map((pad, row) => pad + padding.leftPadding![row]!);
56
+ this.offsets = this.offsets.map((offset, row) => offset - padding.leftPadding![row]!);
57
+ }
58
+ this.#prefillEnds = rightPaddedBatch
59
+ ? padding.lengths.map((length, row) => length + this.offsets[row]!) : undefined;
60
+ }
61
+ get hasPendingPadding(): boolean { return this.#prefillEnds !== undefined; }
62
+ validOffset(row: number): number {
63
+ return Math.min(this.offsets[row]!, this.#prefillEnds?.[row] ?? this.offsets[row]!);
64
+ }
65
+
66
+ prefillRoll(): number[] | undefined {
67
+ return this.#prefillEnds?.map((end, row) => Math.max(0, this.offsets[row]! - end));
68
+ }
69
+ #rollPadding(): void {
70
+ const shifts = this.prefillRoll();
71
+ if (!shifts) return;
72
+ this.leftPad = this.leftPad.map((pad, row) => pad + shifts[row]!);
73
+ this.offsets = this.offsets.map((offset, row) => offset - shifts[row]!);
74
+ }
75
+ finalizePrefill(): void { this.#rollPadding(); this.#prefillEnds = undefined; }
76
+
16
77
  get batchSize(): number { return this.leftPad.length; }
17
78
  get validLength(): number { return Math.min(this.totalOffset, this.maxSize); }
79
+ get activeLength(): number { return this.rotated ? this.maxSize : this.ringIndex; }
18
80
  get trimmable(): boolean { return this.totalOffset < this.maxSize; }
19
81
 
20
82
  markGrown(previousOffset: number): void {
@@ -43,9 +105,22 @@ export class BatchedRotatingState {
43
105
  this.ringIndex += tokens;
44
106
  }
45
107
 
108
+ /** mlx-lm BatchRotatingKVCache._update_concat: the block keeps up to
109
+ * maxSize - 1 historical columns so each query has a complete window. */
110
+ commitConcat(tokens: number, historicalLength: number): void {
111
+ if (historicalLength) this.#rollPadding();
112
+ const trim = Math.max(0, historicalLength - this.maxSize + 1);
113
+ this.leftPad = this.leftPad.map(pad => pad - trim);
114
+ this.totalOffset += tokens;
115
+ this.offsets = this.offsets.map(offset => offset + tokens);
116
+ this.ringIndex = historicalLength - trim + tokens;
117
+ this.rotated = false;
118
+ }
119
+
46
120
  filter(keep: readonly number[]): void {
47
121
  this.offsets = keep.map((row) => this.offsets[row]!);
48
122
  this.leftPad = keep.map((row) => this.leftPad[row]!);
123
+ if (this.#prefillEnds) this.#prefillEnds = keep.map(row => this.#prefillEnds![row]!);
49
124
  }
50
125
 
51
126
  trim(tokens: number): number {
@@ -61,6 +136,7 @@ export class BatchedRotatingState {
61
136
  this.ringIndex = width;
62
137
  this.offsets = [...offsets];
63
138
  this.rotated = false;
139
+ this.#prefillEnds = undefined;
64
140
  }
65
141
 
66
142
  /** Physical ranges that expose a ring buffer in temporal order. */
@@ -6,12 +6,10 @@
6
6
  // column is the same for every row), with PER-ROW `offset` (absolute position,
7
7
  // drives RoPE) and `leftPad` (padding columns in the buffer, masked out).
8
8
  //
9
- // SCOPE: only the pieces the scheduler needs — `merge` (assemble from solo
10
- // prefills), the N=1 decode update (`_update_in_place`), `make_mask`, `filter`,
11
- // and temporalView (extract). The N>1 batched-PREFILL path
12
- // (`_update_concat`/`finalize`/`_lengths`) is NOT ported: the scheduler
13
- // solo-prefills each request (single-stream RotatingKVCache) then merges, so
14
- // this cache only ever sees N=1 updates. Positions (offset/leftPad/_idx/_offset/
9
+ // Includes merge, one-token writes, block prefill, padding finalization, masks
10
+ // and row extraction. Prefill preparation is a storage capability; the serving
11
+ // scheduler's use of whole prompt cohorts is a separate concern.
12
+ // Positions (offset/leftPad/_idx/_offset/
15
13
  // rotated) are tracked on the HOST (small deterministic ints) so make_mask is
16
14
  // built in JS like buildBatchedDecodeMask; only K/V live on device.
17
15
  //
@@ -24,12 +22,14 @@
24
22
 
25
23
  import { MlxArray } from "../mlx/array";
26
24
  import * as ops from "../mlx/ops";
27
- import { RotatingKVCache, type Cache, type Mask } from "./gemma4-base";
28
- import { BatchedRotatingState } from "./batched-rotating-state";
25
+ import { RotatingKVCache, type Cache, type Mask, type PaddedPrefillCache, type PrefillPadding } from "./gemma4-base";
26
+ import { BatchedRotatingState, type RotatingPositionSnapshot } from "./batched-rotating-state";
29
27
  import {
28
+ appendRotatingStorage,
30
29
  mergeStorageRows,
31
30
  plainRowStorage,
32
31
  temporalStorageView,
32
+ rowRollIndices,
33
33
  } from "./batched-row-storage";
34
34
 
35
35
  const STEP = 256;
@@ -45,14 +45,14 @@ const STEP = 256;
45
45
  * update's decrement is the persistent one, applied separately by the cache). */
46
46
  export function buildBatchedRotatingMask(
47
47
  B: number, N: number, leftPad: number[],
48
- maxSize: number, window: number, idx: number, offsetScalar: number, rotated: boolean,
48
+ maxSize: number, window: number, idx: number, offsetScalar: number, rotated: boolean, block = N > 1,
49
49
  ): MlxArray {
50
50
  const off = Math.min(maxSize - 1, offsetScalar);
51
51
  const S = off + N;
52
52
 
53
53
  // Local (non-persisted) leftPad for the mask: trim + rotation shrink it.
54
- const trimSize = idx - maxSize + (N > 1 ? 1 : 0);
55
- const isRot = N === 1 && (rotated || idx >= maxSize);
54
+ const trimSize = idx - maxSize + (block ? 1 : 0);
55
+ const isRot = !block && (rotated || idx >= maxSize);
56
56
  const lp = leftPad.map((x) => x - (trimSize > 0 ? trimSize : 0) - (isRot ? 1 : 0));
57
57
 
58
58
  // roll(shift): physical column = (temporal column + shift) mod S.
@@ -80,8 +80,9 @@ export function buildBatchedRotatingMask(
80
80
  return mask;
81
81
  }
82
82
 
83
- /** Faithful port of mlx-lm BatchRotatingKVCache (decode-only — see file header). */
84
- export class BatchedRotatingCache implements Cache {
83
+ /** Port of mlx-lm BatchRotatingKVCache; padded singleton chunks use its
84
+ * concat operation with the matching block mask until finalization. */
85
+ export class BatchedRotatingCache implements Cache, PaddedPrefillCache {
85
86
  keys: MlxArray | null = null;
86
87
  values: MlxArray | null = null;
87
88
  readonly #rows: BatchedRotatingState;
@@ -103,6 +104,29 @@ export class BatchedRotatingCache implements Cache {
103
104
  * misroutes a batched cache into a serial-only path. */
104
105
  signature(): string { return "kv:rotating-plain"; }
105
106
 
107
+ get positionSnapshot(): RotatingPositionSnapshot { return this.#rows.snapshot(); }
108
+ validOffset(row: number): number { return this.#rows.validOffset(row); }
109
+ preparePrefill(padding: PrefillPadding, rightPaddedBatch?: boolean): void { this.#rows.preparePrefill(padding, rightPaddedBatch); this.releaseRopeArr(); }
110
+ finalizePrefill(): void {
111
+ const shifts = this.#rows.prefillRoll();
112
+ if (shifts?.some(shift => shift !== 0) && this.keys && this.values) {
113
+ using indices = rowRollIndices(this.keys.shape[2]!, shifts);
114
+ const keys = plainRowStorage.rollRows(this.keys, indices);
115
+ let values: MlxArray;
116
+ try { values = plainRowStorage.rollRows(this.values, indices); }
117
+ catch (error) { keys.dispose(); throw error; }
118
+ this.keys.dispose(); this.values.dispose(); this.keys = keys; this.values = values;
119
+ }
120
+ this.#rows.finalizePrefill(); this.releaseRopeArr();
121
+ }
122
+ /** Adopt owned tensors without changing their physical ring columns. */
123
+ static adoptPhysical(keys: MlxArray | null, values: MlxArray | null, position: RotatingPositionSnapshot): BatchedRotatingCache {
124
+ const cache = new BatchedRotatingCache(position.maxSize, [...position.leftPad]);
125
+ cache.#rows.restore(position); cache.keys = keys; cache.values = values;
126
+
127
+ return cache;
128
+ }
129
+
106
130
  get offsetArr(): number[] { return this.#rows.offsets; }
107
131
  get leftPad(): number[] { return this.#rows.leftPad; }
108
132
  get batchSize(): number { return this.#rows.batchSize; }
@@ -133,7 +157,7 @@ export class BatchedRotatingCache implements Cache {
133
157
  mode: "array",
134
158
  arr: buildBatchedRotatingMask(
135
159
  this.#B, N, this.leftPad, this.maxSize, window,
136
- this.#rows.ringIndex, this.offset, this.#rows.rotated,
160
+ this.#rows.ringIndex, this.offset, this.#rows.rotated, N > 1 || this.#rows.hasPendingPadding,
137
161
  ),
138
162
  };
139
163
  }
@@ -142,8 +166,7 @@ export class BatchedRotatingCache implements Cache {
142
166
  updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
143
167
  const [B, H, S, D] = k.shape as [number, number, number, number];
144
168
  const vD = v.shape[3]!;
145
- if (S !== 1)
146
- throw new Error("BatchedRotatingCache supports N=1 decode updates only (solo-prefill then merge)");
169
+ if (S !== 1 || this.#rows.hasPendingPadding) return this.#updateConcat(k, v);
147
170
  const prev = this.offset;
148
171
 
149
172
  // Grow the buffer (in STEP chunks) until it reaches maxSize.
@@ -202,6 +225,21 @@ export class BatchedRotatingCache implements Cache {
202
225
  ];
203
226
  }
204
227
 
228
+ #updateConcat(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
229
+ const keys = appendRotatingStorage(plainRowStorage, this.keys, k, this.#rows);
230
+ let values: MlxArray;
231
+ try { values = appendRotatingStorage(plainRowStorage, this.values, v, this.#rows); }
232
+ catch (error) { keys.dispose(); throw error; }
233
+ const history = this.keys ? this.#rows.activeLength : 0;
234
+ this.keys?.dispose(); this.values?.dispose();
235
+ this.keys = keys; this.values = values;
236
+ this.#rows.commitConcat(k.shape[2]!, history);
237
+ return [
238
+ plainRowStorage.slice(keys, 0, this.#B, 0, this.#rows.ringIndex),
239
+ plainRowStorage.slice(values, 0, this.#B, 0, this.#rows.ringIndex),
240
+ ];
241
+ }
242
+
205
243
  /** Free the per-step RoPE array without disposing KV (wrapper-rebuild path). */
206
244
  releaseRopeArr(): void {
207
245
  this.#ropeArr?.dispose();
@@ -213,8 +251,12 @@ export class BatchedRotatingCache implements Cache {
213
251
  temporalView(): [MlxArray, MlxArray] {
214
252
  if (!this.keys || !this.values) throw new Error("cache is empty");
215
253
  return [
216
- temporalStorageView(plainRowStorage, this.keys, this.#rows),
217
- temporalStorageView(plainRowStorage, this.values, this.#rows),
254
+ temporalStorageView(plainRowStorage, this.keys, this.#rows, {
255
+ from: Math.max(0, this.#rows.activeLength - this.maxSize), to: this.#rows.activeLength,
256
+ }),
257
+ temporalStorageView(plainRowStorage, this.values, this.#rows, {
258
+ from: Math.max(0, this.#rows.activeLength - this.maxSize), to: this.#rows.activeLength,
259
+ }),
218
260
  ];
219
261
  }
220
262
 
@@ -228,15 +270,15 @@ export class BatchedRotatingCache implements Cache {
228
270
  * oracle's `cache._idx = cache.keys.shape[2]`. Bit-exact vs a solo run:
229
271
  * merge/decode/filter keep each row's ring bytes identical to the serial
230
272
  * cache's (tests/batched-rotating) and this is a pure slice+copy. */
231
- extractRow(i: number): RotatingKVCache | null {
273
+ extractRow(i: number, limit?: number): RotatingKVCache | null {
232
274
  if (!this.keys || !this.values) return null;
233
- const pad = Math.max(0, this.leftPad[i]!);
275
+ const pad = Math.max(0, this.leftPad[i]!, limit === undefined ? 0 : this.#rows.activeLength - limit);
234
276
  const c = new RotatingKVCache(this.maxSize);
235
277
  const k = temporalStorageView(plainRowStorage, this.keys, this.#rows, {
236
- row: i, from: pad, copy: true,
278
+ row: i, from: pad, to: this.#rows.activeLength, copy: true,
237
279
  });
238
280
  const v = temporalStorageView(plainRowStorage, this.values, this.#rows, {
239
- row: i, from: pad, copy: true,
281
+ row: i, from: pad, to: this.#rows.activeLength, copy: true,
240
282
  });
241
283
  c.restoreState(k, v, this.offsetArr[i]!, k.shape[2]!);
242
284
  return c;
@@ -1,6 +1,7 @@
1
1
  import { MlxArray } from "../mlx/array";
2
2
  import * as ops from "../mlx/ops";
3
3
  import { BatchedRotatingState } from "./batched-rotating-state";
4
+ import { Dtype } from "../mlx/ffi";
4
5
 
5
6
  export interface RowStorage<T> {
6
7
  shape(value: T): readonly [number, number, number, number];
@@ -10,6 +11,7 @@ export interface RowStorage<T> {
10
11
  padLeft(value: T, tokens: number): T;
11
12
  takeRows(value: T, keep: readonly number[]): T;
12
13
  copy(value: T): T;
14
+ rollRows(value: T, indices: MlxArray): T;
13
15
  dispose(value: T): void;
14
16
  }
15
17
 
@@ -36,6 +38,7 @@ export const plainRowStorage: RowStorage<MlxArray> = {
36
38
  return out;
37
39
  },
38
40
  copy: (value) => ops.copyOf(value),
41
+ rollRows: (value, indices) => ops.takeAlongAxis(value, indices, 2),
39
42
  dispose: (value) => value.dispose(),
40
43
  };
41
44
 
@@ -77,6 +80,14 @@ export const quantizedRowStorage: RowStorage<ops.QuantizedTensor> = {
77
80
  biases: ops.copyOf(value.biases),
78
81
  };
79
82
  },
83
+ rollRows(value, indices) {
84
+ const packed = ops.takeAlongAxis(value.packed, indices, 2);
85
+ let scales: MlxArray | undefined;
86
+ try {
87
+ scales = ops.takeAlongAxis(value.scales, indices, 2);
88
+ return { packed, scales, biases: ops.takeAlongAxis(value.biases, indices, 2) };
89
+ } catch (error) { packed.dispose(); scales?.dispose(); throw error; }
90
+ },
80
91
  dispose(value) {
81
92
  value.packed.dispose();
82
93
  value.scales.dispose();
@@ -84,6 +95,19 @@ export const quantizedRowStorage: RowStorage<ops.QuantizedTensor> = {
84
95
  },
85
96
  };
86
97
 
98
+ /** Per-row cyclic shift, shared by plain and encoded storage planes.
99
+ * This is mlx-lm's dynamic_roll indexing; only positions cross the FFI. */
100
+ export function rowRollIndices(length: number, shifts: readonly number[]): MlxArray {
101
+ using columns = ops.arange(0, length, 1, Dtype.int32);
102
+ using positions = ops.reshape(columns, [1, 1, length, 1]);
103
+ using starts = ops.fromInt32(shifts.map(shift => ((-shift % length) + length) % length), [shifts.length, 1, 1, 1]);
104
+ using raw = ops.add(positions, starts);
105
+ using width = ops.fromInt32([length], []);
106
+ using wrapped = ops.sub(raw, width);
107
+ using over = ops.greaterEqual(raw, width);
108
+ return ops.where(over, wrapped, raw);
109
+ }
110
+
87
111
  export function mergeStorageRows<T>(
88
112
  storage: RowStorage<T>,
89
113
  rows: readonly T[],
@@ -125,3 +149,31 @@ export function temporalStorageView<T>(
125
149
  storage.dispose(cut);
126
150
  return owned;
127
151
  }
152
+
153
+ /** Borrow storage, return an owned chronological block without decoding the
154
+ * representation. Port of mlx-lm's BatchRotatingKVCache._update_concat
155
+ * (MIT): discard spare capacity, retain the full window for the first query. */
156
+ export function appendRotatingStorage<T>(
157
+ storage: RowStorage<T>, previous: T | null, incoming: T, state: BatchedRotatingState,
158
+ ): T {
159
+ if (previous === null) return storage.slice(incoming, 0, storage.shape(incoming)[0], 0, storage.shape(incoming)[2]);
160
+ const active = state.activeLength;
161
+ const shifts = state.prefillRoll();
162
+ if (shifts?.some(shift => shift !== 0)) {
163
+ const chronological = temporalStorageView(storage, previous, state, { to: active });
164
+ let rolled: T | undefined, history: T | undefined;
165
+ try {
166
+ using indices = rowRollIndices(active, shifts);
167
+ rolled = storage.rollRows(chronological, indices);
168
+ history = storage.slice(rolled, 0, state.batchSize, Math.max(0, active - state.maxSize + 1), active);
169
+ return storage.concatTokens([history, incoming]);
170
+ } finally {
171
+ storage.dispose(chronological); if (rolled) storage.dispose(rolled); if (history) storage.dispose(history);
172
+ }
173
+ }
174
+ const history = temporalStorageView(storage, previous, state, {
175
+ from: Math.max(0, active - state.maxSize + 1), to: active,
176
+ });
177
+ try { return storage.concatTokens([history, incoming]); }
178
+ finally { storage.dispose(history); }
179
+ }
@@ -0,0 +1,52 @@
1
+ import { SsmPrefillPadding } from "./ssm-prefill-padding";
2
+ import type { PaddedPrefillCache, PrefillPadding } from "./gemma4-base";
3
+ import * as ops from "../mlx/ops";
4
+ import type { MlxArray } from "../mlx/array";
5
+ import type { BatchableCache, Cache } from "./gemma4-base";
6
+ import { SSMCache } from "./qwen3-delta";
7
+
8
+ /** Recurrent storage behind the same row-layout port as attention KV.
9
+ * Inputs to merge are borrowed; request membership is owned by the caller. */
10
+ export class BatchedSSMCache extends SSMCache implements BatchableCache, PaddedPrefillCache {
11
+ constructor() { super(); this.offsets = []; }
12
+ preparePrefill(padding: PrefillPadding): void {
13
+ if (!this.offsets!.length) this.offsets = padding.lengths.map(() => 0);
14
+ const padded = padding.leftPadding?.some(n => n > 0) || padding.rightPadding?.some(n => n > 0);
15
+ this.prefillPadding = padded ? new SsmPrefillPadding(padding) : null;
16
+ }
17
+ finalizePrefill(): void { this.prefillPadding = null; }
18
+ get rowOffsets(): readonly number[] { return this.offsets!; }
19
+ get leftPad(): readonly number[] { return this.rowOffsets.map(() => 0); }
20
+ makeEmptyBatch(): BatchedSSMCache { return new BatchedSSMCache(); }
21
+ projectedBytes(_tokens: number): number {
22
+ return this.batchSize ? this.state().reduce((bytes, array) => bytes + array.nbytes, 0) / this.batchSize : 0;
23
+ }
24
+
25
+ mergeRows(rows: readonly Cache[]): void {
26
+ if (!rows.length) { this.dispose(); return; }
27
+ const sources = rows as readonly SSMCache[];
28
+ const offsets = sources.flatMap(source => source.offsets ?? [source.offset]);
29
+ const merge = (field: "conv" | "recurrent"): MlxArray | null => {
30
+ const prototype = sources.find(source => source[field])?.[field];
31
+ if (!prototype) return null;
32
+ if (sources.length === 1) return ops.copyOf(prototype);
33
+ const empty: MlxArray[] = [];
34
+ try {
35
+ return ops.concatAxis(sources.map(source => {
36
+ if (source[field]) return source[field]!;
37
+ const zeros = ops.zeros([source.offsets?.length ?? 1, ...prototype.shape.slice(1)], prototype.dtype);
38
+ empty.push(zeros); return zeros;
39
+ }), 0);
40
+ } finally { for (const array of empty) array.dispose(); }
41
+ };
42
+ const conv = merge("conv");
43
+ let recurrent;
44
+ try { recurrent = merge("recurrent"); }
45
+ catch (error) { conv?.dispose(); throw error; }
46
+ this.dispose();
47
+ this.conv = conv; this.recurrent = recurrent;
48
+ this.offsets = offsets; this.offset = Math.max(0, ...offsets);
49
+ }
50
+
51
+ override dispose(): void { super.dispose(); this.offset = 0; this.offsets = []; }
52
+ }