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
package/src/mlx/ops.ts CHANGED
@@ -472,14 +472,12 @@ export function where(cond: MlxArray, x: MlxArray, y: MlxArray, s: S = gpuStream
472
472
  );
473
473
  }
474
474
 
475
- // arange is built host-side and uploaded. The original mlx_arange binding
476
- // broke once the calling path got JIT-optimized; a standalone repro
477
- // (lab/repro/bun-ffi-f64/) traced the root cause to a Bun bug: after DFG
478
- // tier-up, typed-array reads following a bun:ffi call return stale values
479
- // (the JIT eliminates the load across the native call). Not f64 marshaling
480
- // — args reach C intact. See PLAN.md Phase 4 findings. Reuse large ranges
481
- // across attention layers and sampling, but bound their retained storage:
482
- // causal masks request a new range on every round once context exceeds 64K.
475
+ // The old native arange workaround concerned stale typed-array out-pointer
476
+ // reads after Bun JIT tier-up, not f64 argument marshaling. outArray uses
477
+ // native pointer reads now. Large nonnegative unit-step int32 ranges avoid
478
+ // host construction; small/general integer ranges retain JS conversion,
479
+ // including its wrapping behavior outside int32. Cache storage stays bounded
480
+ // as causal masks request new lengths on successive long-context rounds.
483
481
  const arangeCache = new Map<string, { array: MlxArray; bytes: number }>();
484
482
  const ARANGE_CACHE_MIN = 65536;
485
483
  const ARANGE_CACHE_BYTES = 8 * 1024 * 1024;
@@ -498,8 +496,10 @@ export function arange(start: number, stop: number, step: number, dtype: Dtype,
498
496
  }
499
497
 
500
498
  let arr: MlxArray;
501
- if (dtype === Dtype.int32) {
502
- // host-built int32 leaf (the Bun-f64-FFI-safe path — see note above).
499
+ const nativePositions = n === 0 ||
500
+ (n >= ARANGE_CACHE_MIN && step === 1 && start >= 0 && stop <= 2147483647);
501
+ if (dtype === Dtype.int32 && !nativePositions) {
502
+ // Preserve JS conversion for small/general integer ranges.
503
503
  const data = new Int32Array(n);
504
504
  for (let i = 0; i < n; i++) data[i] = start + i * step;
505
505
  arr = MlxArray.fromInt32(data, [n]);
@@ -1019,3 +1019,90 @@ export function leftShift(a: MlxArray, b: MlxArray, s: S = gpuStream): MlxArray
1019
1019
  export function rightShift(a: MlxArray, b: MlxArray, s: S = gpuStream): MlxArray {
1020
1020
  return new MlxArray(outArray("right_shift", (o) => C.mlx_right_shift(o, a.handle, b.handle, s)));
1021
1021
  }
1022
+
1023
+ // --- Whisper additions (fft / strided framing / pad / reductions) ----------
1024
+
1025
+ /** mx.fft.rfft(a, n, axis) with numpy "backward" normalization. Real input
1026
+ * → complex64 output of length n/2+1 along `axis`. */
1027
+ export function rfft(a: MlxArray, n: number, axis = -1, s: S = gpuStream): MlxArray {
1028
+ return new MlxArray(outArray("fft_rfft", (o) => C.mlx_fft_rfft(o, a.handle, n, axis, 0, s)));
1029
+ }
1030
+
1031
+ /** mx.as_strided(a, shape, strides, offset) — element strides. */
1032
+ export function asStrided(
1033
+ a: MlxArray, shape: number[], strides: number[], offset = 0, s: S = gpuStream,
1034
+ ): MlxArray {
1035
+ const shp = new Int32Array(shape);
1036
+ const str = new BigInt64Array(strides.map((v) => BigInt(v)));
1037
+ return new MlxArray(
1038
+ outArray("as_strided", (o) =>
1039
+ C.mlx_as_strided(o, a.handle, ptr(shp), BigInt(shape.length), ptr(str), BigInt(strides.length), BigInt(offset), s),
1040
+ ),
1041
+ );
1042
+ }
1043
+
1044
+ /** mx.pad(a, [(lo, hi), …]) with constant zero fill (all axes given). */
1045
+ export function pad(a: MlxArray, widths: [number, number][], s: S = gpuStream): MlxArray {
1046
+ const axes = new Int32Array(widths.map((_, i) => i));
1047
+ const lo = new Int32Array(widths.map((w) => w[0]));
1048
+ const hi = new Int32Array(widths.map((w) => w[1]));
1049
+ const zero = scalarLike(0, a);
1050
+ try {
1051
+ return new MlxArray(
1052
+ outArray("pad", (o) =>
1053
+ C.mlx_pad(
1054
+ o, a.handle, ptr(axes), BigInt(widths.length), ptr(lo), BigInt(widths.length),
1055
+ ptr(hi), BigInt(widths.length), zero.handle, ptr(cstr("constant")), s,
1056
+ ),
1057
+ ),
1058
+ );
1059
+ } finally {
1060
+ zero.dispose();
1061
+ }
1062
+ }
1063
+
1064
+ export function log10(a: MlxArray, s: S = gpuStream): MlxArray {
1065
+ return new MlxArray(outArray("log10", (o) => C.mlx_log10(o, a.handle, s)));
1066
+ }
1067
+
1068
+ /** Reduce-max over all elements → 0-d array (keepdims=false). */
1069
+ export function maxAll(a: MlxArray, s: S = gpuStream): MlxArray {
1070
+ return new MlxArray(outArray("max", (o) => C.mlx_max(o, a.handle, false, s)));
1071
+ }
1072
+
1073
+ export function broadcastTo(a: MlxArray, shape: number[], s: S = gpuStream): MlxArray {
1074
+ const buf = new Int32Array(shape);
1075
+ return new MlxArray(
1076
+ outArray("broadcast_to", (o) => C.mlx_broadcast_to(o, a.handle, ptr(buf), BigInt(shape.length), s)),
1077
+ );
1078
+ }
1079
+
1080
+ /** mx.topk(a, k, axis) — the k largest values along axis (unsorted). */
1081
+ export function topkAxis(a: MlxArray, k: number, axis: number, s: S = gpuStream): MlxArray {
1082
+ return new MlxArray(outArray("topk_axis", (o) => C.mlx_topk_axis(o, a.handle, k, axis, s)));
1083
+ }
1084
+
1085
+ export function varAxis(a: MlxArray, axis: number, keepdims: boolean, ddof = 0, s: S = gpuStream): MlxArray {
1086
+ return new MlxArray(
1087
+ outArray("var_axis", (o) => C.mlx_var_axis(o, a.handle, axis, keepdims, ddof, s)),
1088
+ );
1089
+ }
1090
+
1091
+ export function stackAxis(arrays: MlxArray[], axis: number, s: S = gpuStream): MlxArray {
1092
+ const handles = new BigUint64Array(arrays.map((a) => a.handle));
1093
+ const vec = C.mlx_vector_array_new_data(ptr(handles), BigInt(arrays.length));
1094
+ try {
1095
+ return new MlxArray(outArray("stack_axis", (o) => C.mlx_stack_axis(o, vec, axis, s)));
1096
+ } finally {
1097
+ C.mlx_vector_array_free(vec);
1098
+ }
1099
+ }
1100
+
1101
+ export function minimum(a: MlxArray, b: MlxArray, s: S = gpuStream): MlxArray {
1102
+ return new MlxArray(outArray("minimum", (o) => C.mlx_minimum(o, a.handle, b.handle, s)));
1103
+ }
1104
+
1105
+ /** Flat mx.take(a, indices) — gathers from the flattened array. */
1106
+ export function takeFlat(a: MlxArray, indices: MlxArray, s: S = gpuStream): MlxArray {
1107
+ return new MlxArray(outArray("take", (o) => C.mlx_take(o, a.handle, indices.handle, s)));
1108
+ }
@@ -0,0 +1,73 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ /** Identity calculation owns bytes; this interface owns memo persistence. */
7
+ export interface ArtifactIdentityStore {
8
+ get(key: string): Promise<string | undefined>;
9
+ put(key: string, digest: string): void;
10
+ }
11
+
12
+ export class FileArtifactIdentityStore implements ArtifactIdentityStore {
13
+ readonly #memory = new Map<string, string>();
14
+ readonly #pending = new Set<Promise<void>>();
15
+ constructor(readonly directory: string) {}
16
+
17
+ async get(key: string): Promise<string | undefined> {
18
+ const memory = this.#memory.get(key);
19
+ if (memory) return memory;
20
+ try {
21
+ const digest = await readFile(join(this.directory, key), "utf8");
22
+ if (!/^[0-9a-f]{64}$/.test(digest)) return undefined;
23
+ this.#memory.set(key, digest);
24
+ return digest;
25
+ } catch { return undefined; }
26
+ }
27
+
28
+ put(key: string, digest: string): void {
29
+ this.#memory.set(key, digest);
30
+ const temporary = join(this.directory, `${key}.${process.pid}.${randomUUID()}.tmp`);
31
+ const write = async () => {
32
+ try {
33
+ await mkdir(this.directory, { recursive: true });
34
+ await writeFile(temporary, digest);
35
+ await rename(temporary, join(this.directory, key));
36
+ } catch { await rm(temporary, { force: true }).catch(() => {}); }
37
+ };
38
+ const pending = write().finally(() => this.#pending.delete(pending));
39
+ this.#pending.add(pending);
40
+ }
41
+
42
+ async flush(): Promise<void> { await Promise.all(this.#pending); }
43
+ }
44
+
45
+ const defaultStore = new FileArtifactIdentityStore(join(homedir(), ".cache", "mlx-bun", "artifact-identities"));
46
+ export interface ArtifactIdentityFile { name: string; path: string; }
47
+
48
+ async function revision(files: readonly ArtifactIdentityFile[]): Promise<string> {
49
+ return JSON.stringify(await Promise.all(files.map(async file => {
50
+ const path = await realpath(file.path), info = await stat(path, { bigint: true });
51
+ return [file.name, path, info.dev, info.ino, info.size, info.mtimeNs, info.ctimeNs].map(String);
52
+ })));
53
+ }
54
+
55
+ /** Preserve the existing seed/name/byte digest, reusing it while file revisions
56
+ * are unchanged. ctime catches same-size edits even when mtime is restored.
57
+ * A memo miss computes the full identity; persistence never delays its caller. */
58
+ export async function artifactIdentity(seed: string, input: readonly ArtifactIdentityFile[],
59
+ store: ArtifactIdentityStore = defaultStore): Promise<string> {
60
+ const files = [...input].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
61
+ const before = await revision(files);
62
+ const key = createHash("sha256").update(JSON.stringify([seed, before])).digest("hex");
63
+ const cached = await store.get(key);
64
+ if (cached) return cached;
65
+ const digest = createHash("sha256").update(seed);
66
+ for (const file of files) {
67
+ digest.update(file.name);
68
+ for await (const chunk of Bun.file(file.path).stream()) digest.update(chunk);
69
+ }
70
+ const value = digest.digest("hex");
71
+ if (before === await revision(files)) store.put(key, value);
72
+ return value;
73
+ }
@@ -0,0 +1,235 @@
1
+ import { MlxArray } from "../mlx/array";
2
+ import { Dtype } from "../mlx/ffi";
3
+ import * as ops from "../mlx/ops";
4
+ import { materializeCopy } from "../mlx/materialize";
5
+ import { KVCache, isPlainKvCache, type BatchableCache, type Cache, type Mask, type PaddedPrefillCache, type PrefillPadding } from "./gemma4-base";
6
+ import { FullPrefillPadding } from "./full-prefill-padding";
7
+
8
+ /** Full-attention storage with independently advancing row positions.
9
+ * Speculative rollback changes coverage, without moving the retained KV.
10
+ * Subsequent appends overwrite each row's rejected suffix in place when MLX
11
+ * can donate the buffer. Queue membership is outside this layout. */
12
+ export class BatchedKVCache implements BatchableCache, PaddedPrefillCache {
13
+ keys: MlxArray | null = null;
14
+ values: MlxArray | null = null;
15
+ rowOffsets: number[] = [];
16
+ leftPad: number[] = [];
17
+ #rope: MlxArray | undefined;
18
+ #beforeRound: number[] | undefined;
19
+ readonly #padding = new FullPrefillPadding();
20
+
21
+ restorePrefillEnds(ends: readonly number[] | undefined): void { this.#padding.restoreEnds(ends); }
22
+ preparePrefill(padding: PrefillPadding): void {
23
+ this.#padding.prepare(this, padding); this.#positionsChanged();
24
+ }
25
+ finalizePrefill(): void {
26
+ const next = this.#padding.finalize(this.state(), this);
27
+ if (!next) return;
28
+ this.keys?.dispose(); this.values?.dispose();
29
+ this.keys = next[0] ?? null; this.values = next[1] ?? null;
30
+ this.#positionsChanged();
31
+ }
32
+
33
+ captureDonorRows(): import("./gemma4-base").KvDonorRows {
34
+ const B = this.rowOffsets.length, width = this.offset;
35
+ const keys = this.keys!.slice([0,0,0,0], [B,this.keys!.shape[1]!,width,this.keys!.shape[3]!]);
36
+ let values: MlxArray;
37
+ try { values = this.values!.slice([0,0,0,0], [B,this.values!.shape[1]!,width,this.values!.shape[3]!]); }
38
+ catch (error) { keys.dispose(); throw error; }
39
+ return { keys, values, offsets: [...this.rowOffsets], starts: [...this.leftPad], ends: this.#ends() };
40
+ }
41
+
42
+ signature(): string { return "kv:batched-positions"; }
43
+ get batchSize(): number | null { return this.rowOffsets.length || null; }
44
+ get offset(): number { return Math.max(0, ...this.#ends()); }
45
+ #ends(): number[] { return this.rowOffsets.map((offset, row) => offset + this.leftPad[row]!); }
46
+ get ropeOffsetArr(): MlxArray | undefined {
47
+ // A common unpadded position uses the existing scalar RoPE operation.
48
+ if (this.leftPad.every(pad => pad === 0) && this.rowOffsets.every(offset => offset === this.rowOffsets[0])) return undefined;
49
+ return this.#rope ??= ops.fromInt32(this.rowOffsets, [this.rowOffsets.length]);
50
+ }
51
+ #positionsChanged(): void { this.#rope?.dispose(); this.#rope = undefined; }
52
+ makeEmptyBatch(): BatchedKVCache { return new BatchedKVCache(); }
53
+ bytesPerToken(): number {
54
+ if (!this.keys || !this.values) return 0;
55
+ return (this.keys.nbytes + this.values.nbytes) / (this.rowOffsets.length * this.keys.shape[2]!);
56
+ }
57
+ projectedBytes(tokens: number): number { return this.bytesPerToken() * tokens; }
58
+ state(): MlxArray[] { return this.keys && this.values ? [this.keys, this.values] : []; }
59
+ isTrimmable(): boolean { return true; }
60
+ trim(count: number): void {
61
+ this.rowOffsets = this.rowOffsets.map(offset => offset - count);
62
+ this.#positionsChanged();
63
+ }
64
+ specRoundBegin(): void { this.#beforeRound = [...this.rowOffsets]; }
65
+ specRoundCommit(): void { this.#beforeRound = undefined; }
66
+ specRoundRollback(keep: number | readonly number[]): void {
67
+ this.rowOffsets = this.#beforeRound!.map((offset, row) => offset + (typeof keep === "number" ? keep : keep[row]!));
68
+ this.#beforeRound = undefined;
69
+ this.#positionsChanged();
70
+ }
71
+
72
+ makeMask(N: number, window: number | null): Mask {
73
+ const B = this.rowOffsets.length, ends = this.#ends(), S = this.offset + N;
74
+ if (window === null && this.leftPad.every(pad => pad === 0) && ends.every(end => end === ends[0]))
75
+ return { mode: N === 1 ? "" : "causal", arr: null };
76
+ using starts = ops.fromInt32(ends, [B, 1, 1, 1]);
77
+ using relative = ops.arange(0, N, 1, Dtype.int32);
78
+ using queries = ops.reshape(relative, [1, 1, N, 1]);
79
+ using position = ops.add(starts, queries);
80
+ using indices = ops.arange(0, S, 1, Dtype.int32);
81
+ using keys = ops.reshape(indices, [1, 1, 1, S]);
82
+ using pads = ops.fromInt32(this.leftPad, [B, 1, 1, 1]);
83
+ using causal = ops.lessEqual(keys, position);
84
+ using valid = ops.greaterEqual(keys, pads);
85
+ const mask = ops.logicalAnd(causal, valid);
86
+ if (window === null) return { mode: "array", arr: mask };
87
+ using windowSize = ops.fromInt32([window], []);
88
+ using limit = ops.add(keys, windowSize);
89
+ using within = ops.less(position, limit);
90
+ try { return { mode: "array", arr: ops.logicalAnd(mask, within) }; }
91
+ finally { mask.dispose(); }
92
+ }
93
+
94
+ updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
95
+ const B = this.rowOffsets.length, N = k.shape[2]!, ends = this.#ends();
96
+ const needed = Math.max(...ends) + N;
97
+ const grow = (array: MlxArray) => {
98
+ const [rows, heads, capacity, width] = array.shape as [number, number, number, number];
99
+ if (capacity >= needed) return array;
100
+ using padding = ops.zeros([rows, heads, Math.ceil(needed / 256) * 256 - capacity, width], array.dtype);
101
+ const grown = ops.concatAxis([array, padding], 2);
102
+ array.dispose();
103
+ return grown;
104
+ };
105
+ const allocate = (input: MlxArray) => ops.zeros(
106
+ [B, input.shape[1]!, Math.ceil(needed / 256) * 256, input.shape[3]!], input.dtype);
107
+ this.keys = this.keys ? grow(this.keys) : allocate(k);
108
+ this.values = this.values ? grow(this.values) : allocate(v);
109
+ const common = ends.every(end => end === ends[0]);
110
+ using positions = common ? null : ops.fromInt32(ends.flatMap(end =>
111
+ Array.from({ length: N }, (_, position) => end + position)), [B, 1, N, 1]);
112
+ const append = (array: MlxArray, values: MlxArray) => common
113
+ ? ops.sliceUpdate(array, values, [0, 0, ends[0]!, 0], [B, array.shape[1]!, ends[0]! + N, array.shape[3]!])
114
+ : ops.putAlongAxis(array, positions!, values, 2);
115
+ const keys = append(this.keys, k);
116
+ let values: MlxArray;
117
+ try { values = append(this.values, v); }
118
+ catch (error) { keys.dispose(); throw error; }
119
+ this.keys.dispose(); this.values.dispose();
120
+ this.keys = keys; this.values = values;
121
+ this.rowOffsets = this.rowOffsets.map(offset => offset + N);
122
+ this.#positionsChanged();
123
+ return [keys.slice([0, 0, 0, 0], [B, keys.shape[1]!, needed, keys.shape[3]!]),
124
+ values.slice([0, 0, 0, 0], [B, values.shape[1]!, needed, values.shape[3]!])];
125
+ }
126
+
127
+ mergeRows(rows: readonly Cache[]): void {
128
+ const only = rows.length === 1 ? rows[0] : undefined;
129
+ if (only && (only instanceof BatchedKVCache || isPlainKvCache(only))) {
130
+ // Preserve allocated capacity when a request starts alone. Immutable
131
+ // aliases transfer ownership without copying its prefill storage.
132
+ const keys = only.keys ? ops.copyOf(only.keys) : null;
133
+ let values: MlxArray | null;
134
+ try { values = only.values ? ops.copyOf(only.values) : null; }
135
+ catch (error) { keys?.dispose(); throw error; }
136
+ const offsets = only instanceof BatchedKVCache ? [...only.rowOffsets] : [only.offset];
137
+ const pads = only instanceof BatchedKVCache ? [...only.leftPad] : [0];
138
+ this.dispose(); this.keys = keys; this.values = values;
139
+ this.rowOffsets = offsets; this.leftPad = pads;
140
+ return;
141
+ }
142
+ const chunks: Array<{ keys: MlxArray | null; values: MlxArray | null; offsets: number[]; pads: number[]; width: number }> = [];
143
+ const held: MlxArray[] = [];
144
+ try {
145
+ for (const cache of rows) {
146
+ let keys: MlxArray | null, values: MlxArray | null, offsets: number[], pads: number[];
147
+ if (cache instanceof BatchedKVCache && cache.offset === 0) {
148
+ keys = values = null; offsets = [...cache.rowOffsets]; pads = [...cache.leftPad];
149
+ } else if (cache instanceof BatchedKVCache) {
150
+ const B = cache.rowOffsets.length, width = cache.offset;
151
+ keys = cache.keys!.slice([0, 0, 0, 0], [B, cache.keys!.shape[1]!, width, cache.keys!.shape[3]!]);
152
+ held.push(keys);
153
+ values = cache.values!.slice([0, 0, 0, 0], [B, cache.values!.shape[1]!, width, cache.values!.shape[3]!]);
154
+ held.push(values);
155
+ offsets = [...cache.rowOffsets]; pads = [...cache.leftPad];
156
+ } else if (isPlainKvCache(cache)) {
157
+ if (cache.offset === 0) keys = values = null;
158
+ else { [keys, values] = cache.temporalView(); held.push(keys, values); }
159
+ offsets = [cache.offset]; pads = [0];
160
+ } else throw new Error(`full-attention batch cannot merge ${cache.signature()}`);
161
+ chunks.push({ keys, values, offsets, pads, width: keys?.shape[2] ?? 0 });
162
+ }
163
+ const width = Math.max(0, ...chunks.map(chunk => chunk.width));
164
+ const padded = (array: MlxArray, count: number) => {
165
+ if (!count) return array;
166
+ using padding = ops.zeros([array.shape[0]!, array.shape[1]!, count, array.shape[3]!], array.dtype);
167
+ const result = ops.concatAxis([padding, array], 2); held.push(result); return result;
168
+ };
169
+ const merge = (field: "keys" | "values") => {
170
+ if (!width) return null; // Geometry arrives with the first append.
171
+ const prototype = chunks.find(chunk => chunk[field])![field]!;
172
+ return ops.concatAxis(chunks.map(chunk => {
173
+ const array = chunk[field];
174
+ if (array) return padded(array, width - chunk.width);
175
+ const empty = ops.zeros([chunk.offsets.length, prototype.shape[1]!, width, prototype.shape[3]!], prototype.dtype);
176
+ held.push(empty); return empty;
177
+ }), 0);
178
+ };
179
+ const keys = merge("keys");
180
+ let values: MlxArray | null;
181
+ try { values = merge("values"); }
182
+ catch (error) { keys?.dispose(); throw error; }
183
+ this.dispose();
184
+ this.keys = keys; this.values = values;
185
+ this.rowOffsets = chunks.flatMap(chunk => chunk.offsets);
186
+ this.leftPad = chunks.flatMap(chunk => chunk.pads.map(pad => pad + width - chunk.width));
187
+ } finally { for (const array of held) array.dispose(); }
188
+ }
189
+
190
+ filterRows(rows: readonly number[]): void {
191
+ if (!rows.length) { this.dispose(); return; }
192
+ const pads = rows.map(row => this.leftPad[row]!);
193
+ const sharedPadding = Math.min(...pads);
194
+ using indices = this.keys ? ops.fromInt32([...rows], [rows.length]) : null;
195
+ const select = (array: MlxArray | null): MlxArray | null => {
196
+ if (!array) return null;
197
+ const selected = ops.takeAxis(array, indices!, 0);
198
+ if (!sharedPadding) return selected;
199
+ // mlx-lm BatchKVCache.filter removes padding common to all survivors.
200
+ // Keeping it changes attention reduction geometry after retirement.
201
+ try { return selected.slice([0, 0, sharedPadding, 0], [...selected.shape]); }
202
+ finally { selected.dispose(); }
203
+ };
204
+ const keys = select(this.keys);
205
+ let values: MlxArray | null;
206
+ try { values = select(this.values); }
207
+ catch (error) { keys?.dispose(); throw error; }
208
+ this.keys?.dispose(); this.values?.dispose();
209
+ this.keys = keys; this.values = values;
210
+ this.rowOffsets = rows.map(row => this.rowOffsets[row]!);
211
+ this.leftPad = pads.map(pad => pad - sharedPadding);
212
+ this.#padding.filter(rows);
213
+ this.#positionsChanged();
214
+ }
215
+
216
+ extractRow(row: number): KVCache {
217
+ const start = this.leftPad[row]!, count = this.rowOffsets[row]!;
218
+ if (count === 0) return new KVCache();
219
+ const copy = (array: MlxArray) => {
220
+ using view = array.slice([row, 0, start, 0], [row + 1, array.shape[1]!, start + count, array.shape[3]!]);
221
+ return materializeCopy(view);
222
+ };
223
+ const keys = copy(this.keys!);
224
+ let values: MlxArray;
225
+ try { values = copy(this.values!); }
226
+ catch (error) { keys.dispose(); throw error; }
227
+ const cache = new KVCache(); cache.restoreState(keys, values, count); return cache;
228
+ }
229
+
230
+ dispose(): void {
231
+ this.keys?.dispose(); this.values?.dispose(); this.#positionsChanged();
232
+ this.keys = this.values = null;
233
+ this.rowOffsets = []; this.leftPad = []; this.#beforeRound = undefined; this.#padding.clear();
234
+ }
235
+ }
@@ -259,13 +259,21 @@ export function extractKVRow(cache: KVCache, leftPad: number, i: number): KVCach
259
259
 
260
260
  /** mlx-lm `filter`: keep only `keep` (sorted row indices) along the batch
261
261
  * axis — eviction of finished sequences from a batched [B,H,S,D] buffer.
262
- * Caller owns the result; inputs are not disposed. */
262
+ * `trimLeft` removes padding shared by all survivors (the caller updates
263
+ * offsets and per-row padding). Caller owns the result; inputs stay owned. */
263
264
  export function filterKVRows(
264
- keys: MlxArray, values: MlxArray, keep: number[],
265
+ keys: MlxArray, values: MlxArray, keep: number[], trimLeft = 0,
265
266
  ): { keys: MlxArray; values: MlxArray } {
266
267
  const idx = MlxArray.fromInt32(Int32Array.from(keep), [keep.length]);
267
- const k = ops.takeAxis(keys, idx, 0);
268
- const v = ops.takeAxis(values, idx, 0);
268
+ const take = (a: MlxArray) => {
269
+ const rows = ops.takeAxis(a, idx, 0);
270
+ if (trimLeft === 0) return rows;
271
+ const trimmed = rows.slice([0, 0, trimLeft, 0], rows.shape);
272
+ rows.dispose();
273
+ return trimmed;
274
+ };
275
+ const k = take(keys);
276
+ const v = take(values);
269
277
  idx.dispose();
270
278
  return { keys: k, values: v };
271
279
  }
@@ -134,11 +134,17 @@ export function extractQuantRow(
134
134
  /** Quantized twin of filterKVRows: keep `keep` rows along the batch axis.
135
135
  * Caller owns the result; inputs are not disposed. */
136
136
  export function filterQuantRows(
137
- keys: ops.QuantizedTensor, values: ops.QuantizedTensor, keep: number[],
137
+ keys: ops.QuantizedTensor, values: ops.QuantizedTensor, keep: number[], trimLeft = 0,
138
138
  ): { keys: ops.QuantizedTensor; values: ops.QuantizedTensor } {
139
139
  const idx = MlxArray.fromInt32(Int32Array.from(keep), [keep.length]);
140
140
  const take = (t: ops.QuantizedTensor): ops.QuantizedTensor =>
141
- tripleMap(t, (a) => ops.takeAxis(a, idx, 0));
141
+ tripleMap(t, (a) => {
142
+ const rows = ops.takeAxis(a, idx, 0);
143
+ if (trimLeft === 0) return rows;
144
+ const trimmed = rows.slice([0, 0, trimLeft, 0], rows.shape);
145
+ rows.dispose();
146
+ return trimmed;
147
+ });
142
148
  const k = take(keys);
143
149
  const v = take(values);
144
150
  idx.dispose();
@@ -0,0 +1,144 @@
1
+ import type { MlxArray } from "../mlx/array";
2
+ import * as ops from "../mlx/ops";
3
+ import { cleanupFailure, disposeResources } from "../engine/resources";
4
+ import { BatchedKVCache } from "./batched-kv";
5
+ import { KVCache, QuantizedKVCache, isQuantizedKvCache,
6
+ type BatchableCache, type Cache, type Mask, type QuantizedAttentionState, type PaddedPrefillCache, type PrefillPadding } from "./gemma4-base";
7
+
8
+ import { KvTensorRows } from "../backends/mlx/kv-tensor-rows";
9
+ import { quantizedDonorAttention } from "./kv-attention-view";
10
+
11
+ const FIELDS = ["packed", "scales", "biases"] as const;
12
+
13
+ /** Affine KV uses the same row-position storage operations for its packed
14
+ * values, scales and biases. No dequantization or requantization is needed
15
+ * for merge, rollback, retirement or extraction. Inputs share this codec. */
16
+ export class BatchedQuantizedKVCache implements BatchableCache, QuantizedAttentionState, PaddedPrefillCache {
17
+ #planes = FIELDS.map(() => new BatchedKVCache());
18
+ #minimum: number[] = [];
19
+ get minimumReusableOffset(): number { return Math.max(0, ...this.#minimum); }
20
+ constructor(readonly groupSize: number, readonly bits: number) {}
21
+ restorePrefillEnds(ends: readonly number[] | undefined): void { for (const plane of this.#planes) plane.restorePrefillEnds(ends); }
22
+ preparePrefill(padding: PrefillPadding): void {
23
+ for (const plane of this.#planes) plane.preparePrefill(padding);
24
+ if (!this.#minimum.length) this.#minimum = padding.lengths.map(() => 0);
25
+ }
26
+ finalizePrefill(): void { for (const plane of this.#planes) plane.finalizePrefill(); }
27
+ get quantizedAttention(): QuantizedAttentionState { return this; }
28
+ captureDonorAttention(): import("./gemma4-base").KvDonorAttention {
29
+ const views: import("./gemma4-base").KvDonorRows[] = [];
30
+ try {
31
+ for (const plane of this.#planes) views.push(plane.captureDonorRows());
32
+ const field = (name: "keys" | "values") => ({
33
+ packed: views[0]![name], scales: views[1]![name], biases: views[2]![name],
34
+ });
35
+ const { offsets, starts, ends } = views[0]!;
36
+ return quantizedDonorAttention(field("keys"), field("values"), { offsets, starts, ends }, this.groupSize, this.bits);
37
+ } catch (error) { disposeResources(views.flatMap(view => [view.keys, view.values])); throw error; }
38
+ }
39
+ signature(): string { return `kv:batched-quant:${this.bits}:${this.groupSize}`; }
40
+ get rowOffsets(): readonly number[] { return this.#planes[0]!.rowOffsets; }
41
+ get leftPad(): readonly number[] { return this.#planes[0]!.leftPad; }
42
+ get batchSize(): number | null { return this.#planes[0]!.batchSize; }
43
+ get offset(): number { return this.#planes[0]!.offset; }
44
+ get ropeOffsetArr(): MlxArray | undefined { return this.#planes[0]!.ropeOffsetArr; }
45
+ makeEmptyBatch(): BatchedQuantizedKVCache { return new BatchedQuantizedKVCache(this.groupSize, this.bits); }
46
+ bytesPerToken(): number { return this.#planes.reduce((n, plane) => n + plane.bytesPerToken(), 0); }
47
+ projectedBytes(tokens: number): number { return this.bytesPerToken() * tokens; }
48
+ makeMask(tokens: number, window: number | null): Mask { return this.#planes[0]!.makeMask(tokens, window); }
49
+ isTrimmable(): boolean { return true; }
50
+ trim(count: number): void { for (const plane of this.#planes) plane.trim(count); }
51
+ specRoundBegin(): void { for (const plane of this.#planes) plane.specRoundBegin(); }
52
+ specRoundCommit(): void { for (const plane of this.#planes) plane.specRoundCommit(); }
53
+ specRoundRollback(keep: number | readonly number[]): void {
54
+ for (const plane of this.#planes) plane.specRoundRollback(keep);
55
+ }
56
+ updateAndFetch(): [MlxArray, MlxArray] { throw new Error("quantized attention uses updateAndFetchQuantized"); }
57
+
58
+ updateAndFetchQuantized(k: MlxArray, v: MlxArray): [ops.QuantizedTensor, ops.QuantizedTensor] {
59
+ using inputs = new DisposableStack();
60
+ const key = ops.quantize(k, this.groupSize, this.bits);
61
+ for (const field of FIELDS) inputs.use(key[field]);
62
+ const value = ops.quantize(v, this.groupSize, this.bits);
63
+ for (const field of FIELDS) inputs.use(value[field]);
64
+ const held: MlxArray[] = [], keys: Partial<ops.QuantizedTensor> = {}, values: Partial<ops.QuantizedTensor> = {};
65
+ try {
66
+ for (const [index, field] of FIELDS.entries()) {
67
+ const [kk, vv] = this.#planes[index]!.updateAndFetch(key[field], value[field]);
68
+ held.push(kk, vv); keys[field] = kk; values[field] = vv;
69
+ }
70
+ return [keys as ops.QuantizedTensor, values as ops.QuantizedTensor];
71
+ } catch (error) { return cleanupFailure(error, () => disposeResources(held)); }
72
+ }
73
+
74
+ /** Borrow prepared request state. All planes stage replacements before
75
+ * releasing the current group, preserving donor ownership on failure. */
76
+ mergeRows(rows: readonly Cache[]): void {
77
+ const next: BatchedKVCache[] = [];
78
+ try {
79
+ for (const [index, field] of FIELDS.entries()) {
80
+ const held: KVCache[] = [], inputs: Cache[] = [];
81
+ try {
82
+ for (const row of rows) {
83
+ if (row instanceof BatchedQuantizedKVCache) inputs.push(row.#planes[index]!);
84
+ else if (isQuantizedKvCache(row)) {
85
+ const view = new KVCache(); held.push(view);
86
+ if (row.offset > 0) {
87
+ const key = ops.copyOf(row.keys![field]);
88
+ let value: MlxArray;
89
+ try { value = ops.copyOf(row.values![field]); }
90
+ catch (error) { key.dispose(); throw error; }
91
+ view.restoreState(key, value, row.offset);
92
+ }
93
+ inputs.push(view);
94
+ } else throw new Error(`quantized row layout cannot merge ${row.signature()}`);
95
+ }
96
+ const plane = new BatchedKVCache(); next.push(plane); plane.mergeRows(inputs);
97
+ } finally { disposeResources(held); }
98
+ }
99
+ } catch (error) { return cleanupFailure(error, () => disposeResources(next)); }
100
+ const previous = this.#planes; this.#planes = next; disposeResources(previous);
101
+ this.#minimum = rows.flatMap(row => row instanceof BatchedQuantizedKVCache ? row.#minimum : [row.minimumReusableOffset ?? 0]);
102
+ }
103
+
104
+ alignRows(leftPad: readonly number[]): void {
105
+ if (leftPad.every((pad, row) => pad === this.leftPad[row])) return;
106
+ const aligned = new KvTensorRows();
107
+ try {
108
+ aligned.mergeRows([{ planes: this.#planes.flatMap(plane => plane.keys ? [plane.keys, plane.values!] : []),
109
+ rowOffsets: this.rowOffsets, leftPad: this.leftPad }]);
110
+ aligned.alignRows(leftPad);
111
+ for (const plane of this.#planes) {
112
+ plane.keys?.dispose(); plane.values?.dispose();
113
+ plane.keys = aligned.planes.shift() ?? null; plane.values = aligned.planes.shift() ?? null;
114
+ plane.leftPad = [...leftPad];
115
+ }
116
+ } finally { aligned.dispose(); }
117
+ }
118
+ filterRows(keep: readonly number[]): void {
119
+ for (const plane of this.#planes) plane.filterRows(keep);
120
+ this.#minimum = keep.map(row => this.#minimum[row]!);
121
+ }
122
+ state(): MlxArray[] {
123
+ if (!this.#planes[0]!.keys) return [];
124
+ return [...this.#planes.map(plane => plane.keys!), ...this.#planes.map(plane => plane.values!)];
125
+ }
126
+
127
+ /** Return the existing persisted representation, owning compact arrays. */
128
+ extractRow(row: number): QuantizedKVCache {
129
+ const offset = this.rowOffsets[row]!, result = new QuantizedKVCache(this.groupSize, this.bits);
130
+ if (this.#minimum[row]) result.minimumReusableOffset = this.#minimum[row];
131
+ if (offset === 0) return result;
132
+ const held: KVCache[] = [];
133
+ try {
134
+ for (const plane of this.#planes) held.push(plane.extractRow(row));
135
+ result.restoreState(
136
+ { packed: held[0]!.keys!, scales: held[1]!.keys!, biases: held[2]!.keys! },
137
+ { packed: held[0]!.values!, scales: held[1]!.values!, biases: held[2]!.values! }, offset);
138
+ for (const cache of held) cache.keys = cache.values = null;
139
+ return result;
140
+ } finally { disposeResources(held); }
141
+ }
142
+
143
+ dispose(): void { disposeResources(this.#planes); this.#minimum = []; }
144
+ }