mlx-bun 0.0.4

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 (210) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +362 -0
  3. package/THIRD_PARTY_LICENSES.md +41 -0
  4. package/bin/mlx-bun.mjs +32 -0
  5. package/docs/curve-designer.html +303 -0
  6. package/docs/design/adapters-end-to-end.md +201 -0
  7. package/docs/design/compat-cli-surface-design.md +227 -0
  8. package/docs/design/docs-reorg-plan.md +152 -0
  9. package/docs/design/hlg-sampling.md +340 -0
  10. package/docs/design/memory-system.md +339 -0
  11. package/docs/design/optimization_plan.md +344 -0
  12. package/docs/design/parallel-slots.md +515 -0
  13. package/docs/design/runtime-isolation.md +247 -0
  14. package/docs/design/segmented-backward-training.md +508 -0
  15. package/docs/investigations/chunk-finetune-experiment.md +84 -0
  16. package/docs/investigations/curve-bisector-routing.md +123 -0
  17. package/docs/investigations/curve-runs/bisector-route.json +98 -0
  18. package/docs/investigations/curve-runs/bisector-trace.json +1393 -0
  19. package/docs/investigations/curve-runs/curve-terrain.json +1179 -0
  20. package/docs/investigations/curve-runs/curve-terrain.svg +42 -0
  21. package/docs/investigations/curve-terrain.html +105 -0
  22. package/docs/investigations/expert-offload-single-user-moe.md +292 -0
  23. package/docs/investigations/hlg-ab.html +34 -0
  24. package/docs/investigations/hlg-explorer.html +238 -0
  25. package/docs/investigations/hlg-figs/cliff.svg +1 -0
  26. package/docs/investigations/hlg-figs/cross.svg +1 -0
  27. package/docs/investigations/hlg-figs/edges.svg +1 -0
  28. package/docs/investigations/hlg-figs/k20-grids.svg +1 -0
  29. package/docs/investigations/hlg-figs/k20-peaks.svg +1 -0
  30. package/docs/investigations/hlg-figs/lw-curve.svg +1 -0
  31. package/docs/investigations/hlg-figs/terrain.svg +1 -0
  32. package/docs/investigations/hlg-report.html +262 -0
  33. package/docs/investigations/hlg-runs/hlg-ab.json +149 -0
  34. package/docs/investigations/hlg-runs/hlg-blind-prompts.md +87 -0
  35. package/docs/investigations/hlg-runs/hlg-blind.json +107 -0
  36. package/docs/investigations/hlg-runs/hlg-cliff.json +197 -0
  37. package/docs/investigations/hlg-runs/hlg-cross.json +505 -0
  38. package/docs/investigations/hlg-runs/hlg-edge.json +388 -0
  39. package/docs/investigations/hlg-runs/hlg-eval1.json +25 -0
  40. package/docs/investigations/hlg-runs/hlg-frontier.json +86 -0
  41. package/docs/investigations/hlg-runs/hlg-grid.json +242 -0
  42. package/docs/investigations/hlg-runs/hlg-grid2.json +343 -0
  43. package/docs/investigations/hlg-runs/hlg-lw-coarse.json +110 -0
  44. package/docs/investigations/hlg-runs/hlg-map.json +383 -0
  45. package/docs/investigations/hlg-runs/run0-original.log +159 -0
  46. package/docs/investigations/hlg-runs/runA-recommended.log +131 -0
  47. package/docs/investigations/hlg-runs/runAA-cliff-cpm5.log +26 -0
  48. package/docs/investigations/hlg-runs/runAB-grid2-e4b.log +18 -0
  49. package/docs/investigations/hlg-runs/runAC-peak-e4b-partial.log +25 -0
  50. package/docs/investigations/hlg-runs/runAD-ab-samples.log +8 -0
  51. package/docs/investigations/hlg-runs/runAE-edge-e4b.log +51 -0
  52. package/docs/investigations/hlg-runs/runAF-logit-calibration.log +10 -0
  53. package/docs/investigations/hlg-runs/runAG-eval-config2.log +16 -0
  54. package/docs/investigations/hlg-runs/runB-gain-variants.log +192 -0
  55. package/docs/investigations/hlg-runs/runC-pivots.log +162 -0
  56. package/docs/investigations/hlg-runs/runD-oetf.log +132 -0
  57. package/docs/investigations/hlg-runs/runE-eotf.log +162 -0
  58. package/docs/investigations/hlg-runs/runF-shaper-working.log +86 -0
  59. package/docs/investigations/hlg-runs/runG-shaper-sweep.log +101 -0
  60. package/docs/investigations/hlg-runs/runH-wsweep.log +176 -0
  61. package/docs/investigations/hlg-runs/runI-asweep.log +176 -0
  62. package/docs/investigations/hlg-runs/runJ-knobmap.log +49 -0
  63. package/docs/investigations/hlg-runs/runK-frontier.log +19 -0
  64. package/docs/investigations/hlg-runs/runL-widemap.log +65 -0
  65. package/docs/investigations/hlg-runs/runM-frontier-emb.log +15 -0
  66. package/docs/investigations/hlg-runs/runN-verify.log +116 -0
  67. package/docs/investigations/hlg-runs/runO-frontier-seed5000.log +15 -0
  68. package/docs/investigations/hlg-runs/runP-grid-AxSm.log +15 -0
  69. package/docs/investigations/hlg-runs/runQ-grid-tgW.log +15 -0
  70. package/docs/investigations/hlg-runs/runR-blind-transcript.log +79 -0
  71. package/docs/investigations/hlg-runs/runS-cross-coherence.log +33 -0
  72. package/docs/investigations/hlg-runs/runT-cross-cpm5.log +33 -0
  73. package/docs/investigations/hlg-runs/runU-cross-12b.log +33 -0
  74. package/docs/investigations/hlg-runs/runV-lw-e4b.log +22 -0
  75. package/docs/investigations/hlg-runs/runW-lw-12b.log +22 -0
  76. package/docs/investigations/hlg-runs/runX-lw-cpm5.log +22 -0
  77. package/docs/investigations/hlg-runs/runY-cliff-e4b.log +26 -0
  78. package/docs/investigations/hlg-runs/runZ-cliff-12b.log +26 -0
  79. package/docs/investigations/hlg-sampling-investigation.md +970 -0
  80. package/docs/investigations/lab-build-journal.md +157 -0
  81. package/docs/investigations/pi-builtin-investigation.md +308 -0
  82. package/docs/investigations/starter-model-port-handoff.md +323 -0
  83. package/docs/planning/IDEAS.md +26 -0
  84. package/docs/planning/PRODUCT_ROADMAP.md +426 -0
  85. package/docs/planning/ResearchTopics.md +119 -0
  86. package/docs/planning/chunk-task-roadmap.md +77 -0
  87. package/docs/planning/journal.md +155 -0
  88. package/docs/reference/distribution.md +121 -0
  89. package/docs/reference/embedding.md +168 -0
  90. package/docs/reference/library-api.md +153 -0
  91. package/docs/reference/server-api.md +224 -0
  92. package/docs/reference/server-config.md +243 -0
  93. package/docs/segmented-backward-handoff.md +148 -0
  94. package/package.json +68 -0
  95. package/src/anthropic.ts +491 -0
  96. package/src/chat-template.ts +186 -0
  97. package/src/cli.ts +1075 -0
  98. package/src/config.ts +229 -0
  99. package/src/curve-sampler.ts +122 -0
  100. package/src/dataset/generators.ts +1087 -0
  101. package/src/dataset/index.ts +12 -0
  102. package/src/dataset/job.ts +81 -0
  103. package/src/dataset/llm.ts +71 -0
  104. package/src/dataset/registry.ts +528 -0
  105. package/src/download.ts +283 -0
  106. package/src/eval/README.md +86 -0
  107. package/src/eval/kl-prompts.ts +29 -0
  108. package/src/eval/kl.ts +294 -0
  109. package/src/eval/quality-db.ts +92 -0
  110. package/src/eval/runner.ts +102 -0
  111. package/src/eval/score.ts +49 -0
  112. package/src/eval/tasks/bfcl.ts +500 -0
  113. package/src/eval/tasks/gsm8k.ts +85 -0
  114. package/src/eval/tasks/hashhop.ts +109 -0
  115. package/src/eval/tasks/humaneval.ts +252 -0
  116. package/src/eval/tasks/ifeval.ts +354 -0
  117. package/src/eval/tasks/mmlu.ts +145 -0
  118. package/src/evaldb.ts +199 -0
  119. package/src/expert-offload-build.ts +77 -0
  120. package/src/expert-offload.ts +61 -0
  121. package/src/expert-trace.ts +67 -0
  122. package/src/fit.ts +254 -0
  123. package/src/generate.ts +451 -0
  124. package/src/harness-pi.ts +177 -0
  125. package/src/hf-push.ts +628 -0
  126. package/src/index.ts +22 -0
  127. package/src/jobs/db.ts +139 -0
  128. package/src/jobs/index.ts +29 -0
  129. package/src/jobs/job-entry.ts +106 -0
  130. package/src/jobs/runner.ts +304 -0
  131. package/src/jobs/sse.ts +114 -0
  132. package/src/jobs/types.ts +58 -0
  133. package/src/kv-store.ts +185 -0
  134. package/src/lora.ts +314 -0
  135. package/src/md-modules.d.ts +7 -0
  136. package/src/mlx/array.ts +237 -0
  137. package/src/mlx/autograd.ts +267 -0
  138. package/src/mlx/checkpoint.ts +116 -0
  139. package/src/mlx/compile.ts +158 -0
  140. package/src/mlx/custom-vjp.ts +140 -0
  141. package/src/mlx/ffi.ts +433 -0
  142. package/src/mlx/metal-kernel.ts +130 -0
  143. package/src/mlx/ops.ts +707 -0
  144. package/src/mmap.ts +90 -0
  145. package/src/model/batched-mask.ts +204 -0
  146. package/src/model/batched-rotating.ts +300 -0
  147. package/src/model/compiled-decode.ts +681 -0
  148. package/src/model/factory.ts +27 -0
  149. package/src/model/fingerprint.ts +33 -0
  150. package/src/model/flash-attention.ts +674 -0
  151. package/src/model/fused-decode-kernel.ts +305 -0
  152. package/src/model/fused-geglu-kernel.ts +87 -0
  153. package/src/model/gemma4-base.ts +1616 -0
  154. package/src/model/gemma4.ts +1012 -0
  155. package/src/model/generated/gemma4-12b.ts +526 -0
  156. package/src/model/generated/gemma4-26b.ts +500 -0
  157. package/src/model/generated/gemma4-e4b.ts +757 -0
  158. package/src/model/generated/index.ts +18 -0
  159. package/src/model/minicpm5.ts +278 -0
  160. package/src/model/qwen3-delta.ts +224 -0
  161. package/src/model/qwen3_5.ts +429 -0
  162. package/src/model/support.ts +46 -0
  163. package/src/native-pack.ts +152 -0
  164. package/src/pi-provider.ts +95 -0
  165. package/src/pi-terminal.ts +264 -0
  166. package/src/pi-web.ts +924 -0
  167. package/src/preflight.ts +123 -0
  168. package/src/prompt-cache.ts +113 -0
  169. package/src/quantize/allocator.ts +627 -0
  170. package/src/quantize/calibration.ts +180 -0
  171. package/src/quantize/config-writer.ts +132 -0
  172. package/src/quantize/index.ts +57 -0
  173. package/src/quantize/job.ts +136 -0
  174. package/src/quantize/quantizer.ts +462 -0
  175. package/src/quantize/safetensors-writer.ts +171 -0
  176. package/src/quantize/sensitivity.ts +234 -0
  177. package/src/registry.ts +336 -0
  178. package/src/responses.ts +774 -0
  179. package/src/safetensors.ts +158 -0
  180. package/src/sampler.ts +517 -0
  181. package/src/serve/batch-scheduler.ts +362 -0
  182. package/src/serve/generation-gateway.ts +160 -0
  183. package/src/server.ts +1891 -0
  184. package/src/spec/drafter.ts +261 -0
  185. package/src/spec/generate.ts +221 -0
  186. package/src/status-page.html +497 -0
  187. package/src/tokenizer.ts +38 -0
  188. package/src/tool-call.ts +242 -0
  189. package/src/train/dataset.ts +461 -0
  190. package/src/train/export.ts +32 -0
  191. package/src/train/forward.ts +229 -0
  192. package/src/train/index.ts +43 -0
  193. package/src/train/job.ts +148 -0
  194. package/src/train/lora-params.ts +194 -0
  195. package/src/train/loss.ts +342 -0
  196. package/src/train/merge.ts +195 -0
  197. package/src/train/optimizer.ts +176 -0
  198. package/src/train/rank.ts +101 -0
  199. package/src/train/segmented.ts +520 -0
  200. package/src/train/trainer.ts +600 -0
  201. package/src/tui.ts +161 -0
  202. package/src/vision/embedder.ts +168 -0
  203. package/src/vision/preprocess.ts +247 -0
  204. package/src/vision/prompt.ts +158 -0
  205. package/src/vision/siglip.ts +599 -0
  206. package/src/web/app.html +2471 -0
  207. package/src/web/skills/web-research/SKILL.md +44 -0
  208. package/src/web/skills.ts +59 -0
  209. package/src/web-tools.ts +426 -0
  210. package/src/weights.ts +91 -0
package/src/mmap.ts ADDED
@@ -0,0 +1,90 @@
1
+ // libc mmap via bun:ffi.
2
+ //
3
+ // Bun.mmap panics (SIGTRAP, Bun 1.3.3) on files larger than 4 GB because
4
+ // JSC ArrayBuffers cap at 2^32 bytes — and weight shards routinely exceed
5
+ // that. We don't need a whole-file ArrayBuffer anyway: tensors go to mlx
6
+ // as raw pointers (base + offset), and only small ranges (headers) are
7
+ // ever viewed from JS.
8
+
9
+ import { dlopen, FFIType, toArrayBuffer } from "bun:ffi";
10
+ import { closeSync, fstatSync, openSync } from "node:fs";
11
+
12
+ const libc = dlopen("/usr/lib/libSystem.B.dylib", {
13
+ mmap: {
14
+ args: [FFIType.u64, FFIType.u64, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i64],
15
+ returns: FFIType.u64,
16
+ },
17
+ munmap: { args: [FFIType.u64, FFIType.u64], returns: FFIType.i32 },
18
+ madvise: { args: [FFIType.u64, FFIType.u64, FFIType.i32], returns: FFIType.i32 },
19
+ }).symbols;
20
+
21
+ const PROT_READ = 0x1;
22
+ const PROT_WRITE = 0x2;
23
+ const MAP_SHARED = 0x1;
24
+ const MAP_PRIVATE = 0x2;
25
+ const MAP_FAILED = 0xffffffffffffffffn;
26
+
27
+ // madvise advice (macOS <sys/mman.h>). DONTNEED drops clean file-backed
28
+ // pages (they re-fault from the file); FREE_REUSABLE is the Apple-specific
29
+ // lazy-reclaim variant.
30
+ export const MADV_DONTNEED = 4;
31
+ export const MADV_FREE = 5;
32
+ export const MADV_FREE_REUSABLE = 7;
33
+
34
+ export class MmapFile {
35
+ readonly path: string;
36
+ readonly base: bigint;
37
+ readonly size: number;
38
+ #mapped = true;
39
+
40
+ private constructor(path: string, base: bigint, size: number) {
41
+ this.path = path;
42
+ this.base = base;
43
+ this.size = size;
44
+ }
45
+
46
+ /** Open read-only (MAP_SHARED) or copy-on-write (MAP_PRIVATE +
47
+ * PROT_WRITE — writes go to private pages, never the file; used for
48
+ * KV-cache reloads where mlx may donate buffers). */
49
+ static open(path: string, mode: "ro" | "cow" = "ro"): MmapFile {
50
+ const fd = openSync(path, "r");
51
+ try {
52
+ const size = fstatSync(fd).size;
53
+ if (size === 0) throw new Error(`${path}: empty file`);
54
+ const prot = mode === "cow" ? PROT_READ | PROT_WRITE : PROT_READ;
55
+ const flags = mode === "cow" ? MAP_PRIVATE : MAP_SHARED;
56
+ const base = libc.mmap(0n, BigInt(size), prot, flags, fd, 0n);
57
+ if (base === MAP_FAILED) throw new Error(`${path}: mmap failed`);
58
+ return new MmapFile(path, base, size);
59
+ } finally {
60
+ closeSync(fd); // mapping survives close
61
+ }
62
+ }
63
+
64
+ /** Raw pointer to byte `offset` (for FFI consumers like mlx). */
65
+ pointer(offset: number): number {
66
+ if (offset < 0 || offset > this.size) throw new RangeError(`offset ${offset} out of range`);
67
+ return Number(this.base + BigInt(offset));
68
+ }
69
+
70
+ /** JS view of a range. Only for small ranges (headers, spot checks);
71
+ * the view aliases the mapping — do not retain past munmap. */
72
+ view(offset: number, length: number): Uint8Array {
73
+ if (offset + length > this.size) throw new RangeError("view out of range");
74
+ if (length >= 2 ** 32) throw new RangeError("JS views cap at 4 GB");
75
+ return new Uint8Array(toArrayBuffer(this.pointer(offset) as never, 0, length));
76
+ }
77
+
78
+ /** madvise a page-aligned sub-range (offset + length should be page
79
+ * multiples). e.g. advise(off, len, MADV_DONTNEED) to release clean
80
+ * expert pages back to the OS; they re-fault from the file on next read. */
81
+ advise(offset: number, length: number, advice: number): number {
82
+ return Number(libc.madvise(this.base + BigInt(offset), BigInt(length), advice));
83
+ }
84
+
85
+ unmap(): void {
86
+ if (!this.#mapped) return;
87
+ this.#mapped = false;
88
+ libc.munmap(this.base, BigInt(this.size));
89
+ }
90
+ }
@@ -0,0 +1,204 @@
1
+ // Padding-aware attention masks for batched (B>1) serving — decode side.
2
+ //
3
+ // Companion to src/train/forward.ts's buildBatchedPadMask, which builds the
4
+ // PREFILL mask ([B,1,L,L], offset 0, right-padded — the training case, already
5
+ // parity-proven by tests/train-batch-e2e.test.ts). This module covers the
6
+ // DECODE case for batched serving: left-padded rows sharing one growing
7
+ // [B,H,S,D] KV buffer, where a step forwards N new query tokens at a nonzero
8
+ // offset. (The two builders should be consolidated when batched prefill is
9
+ // wired into the serving path — phase S1a, docs/design/parallel-slots.md.)
10
+ //
11
+ // Why left-padding for decode: with rows right-aligned in the KV buffer, every
12
+ // row's next write lands in the SAME column, so one advancing offset serves
13
+ // the whole batch. Each row's RoPE position is still per-row (handled via the
14
+ // array-offset path, ropeOffsetArr / ops.ropeDynamic — not here); the only
15
+ // thing the mask must encode is (a) causality, (b) each row's left padding,
16
+ // and (c) the sliding window, if any.
17
+
18
+ import { MlxArray } from "../mlx/array";
19
+ import * as ops from "../mlx/ops";
20
+ import { createCausalMask, type Cache, type Mask } from "./gemma4-base";
21
+
22
+ /** Padding-aware key-validity mask for batched DECODE with left-padded rows.
23
+ *
24
+ * The shared KV buffer has width S (== cache offset once this step's keys are
25
+ * written). Row b has `leftPad[b]` padding columns on the left, then its real
26
+ * keys. A step forwards N new query tokens whose absolute positions are
27
+ * S-N .. S-1 (N == 1 in the steady decode state; N > 1 only for a batched
28
+ * prefill chunk).
29
+ *
30
+ * Returns [B, 1, N, S] bool, true where query i of row b may attend to key j:
31
+ * allow[b,0,i,j] = (j <= S-N+i) causal (query i at abs pos S-N+i)
32
+ * AND (j >= leftPad[b]) key past row b's left padding
33
+ * AND (S-N+i < j+window) within the sliding window*
34
+ * (*only when windowSize !== null)
35
+ *
36
+ * Built from createCausalMask(N, S-N, windowSize) (the model's own [N, S] bool
37
+ * matrix) broadcast over B, AND-ed with a per-row [B,1,1,S] key-validity mask
38
+ * — exactly mirroring buildBatchedPadMask, but at a decode offset with
39
+ * left-padding instead of offset-0 right-padding. Caller owns the result. */
40
+ export function buildBatchedDecodeMask(
41
+ B: number,
42
+ N: number,
43
+ S: number,
44
+ leftPad: number[],
45
+ windowSize: number | null,
46
+ ): MlxArray {
47
+ // [N, S] causal(+window): queries at abs positions S-N..S-1, keys 0..S-1.
48
+ const causal2d = createCausalMask(N, S - N, windowSize); // [N, S] bool
49
+ const causal4d = ops.reshape(causal2d, [1, 1, N, S]);
50
+ causal2d.dispose();
51
+
52
+ // Per-row key validity: keyValid[b,0,0,j] = j >= leftPad[b]. Build as int32
53
+ // {0,1} then compare to bool, broadcasting over the query axis (i).
54
+ const keyValidData = new Int32Array(B * S);
55
+ for (let b = 0; b < B; b++) {
56
+ const pad = leftPad[b]!;
57
+ for (let j = 0; j < S; j++) keyValidData[b * S + j] = j >= pad ? 1 : 0;
58
+ }
59
+ const keyValidI = MlxArray.fromInt32(keyValidData, [B, 1, 1, S]);
60
+ const zero = MlxArray.fromInt32(new Int32Array([0]), []);
61
+ const keyValid = ops.less(zero, keyValidI); // 0 < x → bool, true where x==1
62
+ zero.dispose();
63
+ keyValidI.dispose();
64
+
65
+ const allow = ops.logicalAnd(causal4d, keyValid); // [1,1,N,S] & [B,1,1,S]
66
+ causal4d.dispose();
67
+ keyValid.dispose();
68
+ return allow; // [B, 1, N, S] bool
69
+ }
70
+
71
+ /** Cache wrapper for batched DECODE with left-padded rows. Delegates all KV
72
+ * storage to a real, shape-[B,H,S,D] cache (KVCache et al. are already
73
+ * B-generic), but overrides two things the stock single-sequence path gets
74
+ * wrong for a padded batch:
75
+ *
76
+ * - `makeMask` returns the per-row left-padding decode mask
77
+ * (buildBatchedDecodeMask) instead of the stock mask, which is EMPTY at
78
+ * N==1 and would let every row attend to its own (garbage) left padding.
79
+ * - `ropeOffsetArr` exposes each row's REAL position (= pre-write offset −
80
+ * leftPad[b]) as an int32 [B] array, so RoPE is applied per-row via the
81
+ * array-offset path (mlx fast_rope_dynamic — verified to rotate each row
82
+ * by its own offset). The scalar offset would mis-position every padded
83
+ * row by its pad amount.
84
+ *
85
+ * `leftPad` is fixed for the batch's lifetime (set when the left-padded decode
86
+ * buffer is assembled from per-row prefills). The position array is recomputed
87
+ * only when the inner offset advances (once per decode step), so the two reads
88
+ * within a step (K before updateAndFetch, Q after — see Attention.forward)
89
+ * return the SAME array; the attention graph holds it until eval, so it must
90
+ * not be freed mid-step. */
91
+ export class BatchedDecodeMaskCache implements Cache {
92
+ #ropeArr: MlxArray | null = null;
93
+ #ropeForOffset = -1;
94
+
95
+ constructor(
96
+ private readonly inner: Cache,
97
+ private readonly B: number,
98
+ private readonly leftPad: number[],
99
+ private readonly window: number | null,
100
+ ) {}
101
+
102
+ get offset(): number {
103
+ return this.inner.offset;
104
+ }
105
+
106
+ get ropeOffsetArr(): MlxArray {
107
+ const off = this.inner.offset;
108
+ if (this.#ropeArr && this.#ropeForOffset === off) return this.#ropeArr;
109
+ this.#ropeArr?.dispose(); // prior step's array; its graph is already evaluated
110
+ const data = new Int32Array(this.B);
111
+ for (let b = 0; b < this.B; b++) data[b] = off - this.leftPad[b]!;
112
+ this.#ropeArr = MlxArray.fromInt32(data, [this.B]);
113
+ this.#ropeForOffset = off;
114
+ return this.#ropeArr;
115
+ }
116
+
117
+ updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
118
+ return this.inner.updateAndFetch(k, v);
119
+ }
120
+
121
+ makeMask(N: number, _windowSize: number | null): Mask {
122
+ // forwardLayers calls makeMask BEFORE this step's updateAndFetch, so the
123
+ // KV the attention then fetches spans inner.offset + N keys. Build a fresh
124
+ // mask each step and hand the model ownership — forwardLayers disposes
125
+ // mask.arr after building the layer graph (mlx refcounts it into the sdpa
126
+ // nodes, so post-build dispose is safe), so no view bookkeeping is needed.
127
+ const S = this.inner.offset + N;
128
+ return { mode: "array", arr: buildBatchedDecodeMask(this.B, N, S, this.leftPad, this.window) };
129
+ }
130
+
131
+ /** Free the per-step RoPE position array WITHOUT disposing the delegated KV
132
+ * (inner). For callers that rebuild a fresh wrapper around a persistent inner
133
+ * cache each decode step (the dynamic-B scheduler / parity harness) — the
134
+ * inner outlives the wrapper, so wrapper.dispose() would be wrong. */
135
+ releaseRopeArr(): void {
136
+ this.#ropeArr?.dispose();
137
+ this.#ropeArr = null;
138
+ this.#ropeForOffset = -1;
139
+ }
140
+
141
+ state(): MlxArray[] {
142
+ return this.inner.state();
143
+ }
144
+ isTrimmable(): boolean {
145
+ return this.inner.isTrimmable();
146
+ }
147
+ trim(n: number): void {
148
+ this.inner.trim(n);
149
+ }
150
+ dispose(): void {
151
+ this.inner.dispose();
152
+ this.#ropeArr?.dispose();
153
+ this.#ropeArr = null;
154
+ }
155
+ }
156
+
157
+ // --- Dynamic-B cache ops (continuous batching) ---------------------------
158
+ // Ports of mlx-lm's per-cache batch methods (merge / filter), the operations
159
+ // the --batch N scheduler needs that our fixed-B verified forward doesn't have.
160
+ // They run per layer on the raw [.,H,.,D] K/V; the scheduler applies them
161
+ // across layers and wraps the result in a BatchedDecodeMaskCache. Pure array
162
+ // surgery — unit-tested without a model (tests/batched-decode-mask.test.ts);
163
+ // numerical correctness is gated separately vs an mlx-lm dynamic golden.
164
+
165
+ /** mlx-lm `merge`: stack N single-row KV slices into one left-padded
166
+ * [B,H,Smax,D] batch. Each row is its sequence's KV sliced to its true length
167
+ * L_i; we left-pad each by Smax−L_i so all rows align on the right edge (the
168
+ * layout decode + BatchedDecodeMaskCache expect). Generalizes
169
+ * realBatchedGreedy's assemble to N rows. Caller owns the result; the input
170
+ * row arrays are not disposed. */
171
+ export function mergeKVRows(
172
+ rows: { keys: MlxArray; values: MlxArray }[],
173
+ ): { keys: MlxArray; values: MlxArray; leftPad: number[]; width: number } {
174
+ const lens = rows.map((r) => r.keys.shape[2]!);
175
+ const width = Math.max(...lens);
176
+ const leftPad = lens.map((l) => width - l);
177
+ const leftPadTo = (a: MlxArray, pad: number): MlxArray => {
178
+ if (pad === 0) return a.slice([0, 0, 0, 0], a.shape as number[]); // fresh full view
179
+ const [B, H, , D] = a.shape as [number, number, number, number];
180
+ const z = ops.zeros([B, H, pad, D], a.dtype);
181
+ const out = ops.concatAxis([z, a], 2);
182
+ z.dispose();
183
+ return out;
184
+ };
185
+ const ks = rows.map((r, i) => leftPadTo(r.keys, leftPad[i]!));
186
+ const vs = rows.map((r, i) => leftPadTo(r.values, leftPad[i]!));
187
+ const keys = ops.concatAxis(ks, 0);
188
+ const values = ops.concatAxis(vs, 0);
189
+ for (const a of [...ks, ...vs]) a.dispose();
190
+ return { keys, values, leftPad, width };
191
+ }
192
+
193
+ /** mlx-lm `filter`: keep only `keep` (sorted row indices) along the batch
194
+ * axis — eviction of finished sequences from a batched [B,H,S,D] buffer.
195
+ * Caller owns the result; inputs are not disposed. */
196
+ export function filterKVRows(
197
+ keys: MlxArray, values: MlxArray, keep: number[],
198
+ ): { keys: MlxArray; values: MlxArray } {
199
+ const idx = MlxArray.fromInt32(Int32Array.from(keep), [keep.length]);
200
+ const k = ops.takeAxis(keys, idx, 0);
201
+ const v = ops.takeAxis(values, idx, 0);
202
+ idx.dispose();
203
+ return { keys: k, values: v };
204
+ }
@@ -0,0 +1,300 @@
1
+ // Batched sliding-window (rotating) KV cache for `--batch N` serving — the
2
+ // hard half of dynamic-B that BatchedDecodeMaskCache (full-attention) doesn't
3
+ // cover. Faithful port of mlx-lm's BatchRotatingKVCache (models/cache.py):
4
+ // a shared ring buffer whose rotation state (_idx / _offset / rotated) is
5
+ // SCALAR across the batch (all rows advance one token per step, so the write
6
+ // column is the same for every row), with PER-ROW `offset` (absolute position,
7
+ // drives RoPE) and `leftPad` (padding columns in the buffer, masked out).
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/
15
+ // rotated) are tracked on the HOST (small deterministic ints) so make_mask is
16
+ // built in JS like buildBatchedDecodeMask; only K/V live on device.
17
+ //
18
+ // The genuinely tricky bit is make_mask once the ring wraps: the buffer is no
19
+ // longer in temporal order, so the causal+window+padding mask is built in
20
+ // temporal coordinates then ROLLED to the ring's physical layout — see
21
+ // buildBatchedRotatingMask (a column-by-column port of mlx-lm make_mask,
22
+ // including the `roll(shift=idx+1)`). Gated model-free against mlx-lm
23
+ // (tests/batched-rotating.test.ts) + end-to-end vs a long-context Gemma oracle.
24
+
25
+ import { MlxArray } from "../mlx/array";
26
+ import * as ops from "../mlx/ops";
27
+ import type { Cache, Mask } from "./gemma4-base";
28
+
29
+ const STEP = 256;
30
+
31
+ /** Port of BatchRotatingKVCache.make_mask for the DECODE case. Returns a
32
+ * [B,1,N,S] bool mask (S == min(maxSize-1, offsetScalar) + N) for the step
33
+ * ABOUT to be written: causal AND sliding-window AND per-row left-padding,
34
+ * rolled to the ring's physical layout once wrapped.
35
+ *
36
+ * Inputs are the PRE-write scalar ring state (`idx`=_idx, `offsetScalar`=_offset,
37
+ * `rotated`) and per-row `leftPad`. The leftPad adjustments here are LOCAL to
38
+ * the mask (mlx-lm decrements left_padding both in make_mask AND in update; the
39
+ * update's decrement is the persistent one, applied separately by the cache). */
40
+ export function buildBatchedRotatingMask(
41
+ B: number, N: number, leftPad: number[],
42
+ maxSize: number, window: number, idx: number, offsetScalar: number, rotated: boolean,
43
+ ): MlxArray {
44
+ const off = Math.min(maxSize - 1, offsetScalar);
45
+ const S = off + N;
46
+
47
+ // Local (non-persisted) leftPad for the mask: trim + rotation shrink it.
48
+ const trimSize = idx - maxSize + (N > 1 ? 1 : 0);
49
+ const isRot = N === 1 && (rotated || idx >= maxSize);
50
+ const lp = leftPad.map((x) => x - (trimSize > 0 ? trimSize : 0) - (isRot ? 1 : 0));
51
+
52
+ // roll(shift): physical column = (temporal column + shift) mod S.
53
+ const shift = isRot ? (idx >= maxSize ? 0 : idx) + 1 : 0;
54
+
55
+ const data = new Int32Array(B * N * S);
56
+ for (let b = 0; b < B; b++) {
57
+ const pad = lp[b]!;
58
+ for (let i = 0; i < N; i++) {
59
+ const lind = off + i; // query temporal position
60
+ for (let j = 0; j < S; j++) {
61
+ // temporal key position j: causal, within window, past this row's pad.
62
+ const allow = lind >= j && lind < j + window && j >= pad;
63
+ if (!allow) continue;
64
+ const col = ((j + shift) % S + S) % S;
65
+ data[(b * N + i) * S + col] = 1;
66
+ }
67
+ }
68
+ }
69
+ const intArr = MlxArray.fromInt32(data, [B, 1, N, S]);
70
+ const zero = MlxArray.fromInt32(new Int32Array([0]), []);
71
+ const mask = ops.less(zero, intArr); // 0 < x → bool
72
+ zero.dispose();
73
+ intArr.dispose();
74
+ return mask;
75
+ }
76
+
77
+ /** Faithful port of mlx-lm BatchRotatingKVCache (decode-only — see file header). */
78
+ export class BatchedRotatingCache implements Cache {
79
+ keys: MlxArray | null = null;
80
+ values: MlxArray | null = null;
81
+ /** Per-row absolute position (drives RoPE) and buffer padding. */
82
+ offsetArr: number[];
83
+ leftPad: number[];
84
+ #idx = 0;
85
+ #offset = 0; // scalar total tokens processed (mlx-lm _offset)
86
+ #rotated = false;
87
+ #ropeArr: MlxArray | null = null;
88
+ #ropeForOffset = -1;
89
+ readonly maxSize: number;
90
+
91
+ constructor(maxSize: number, leftPad: number[]) {
92
+ this.maxSize = maxSize;
93
+ this.leftPad = [...leftPad];
94
+ this.offsetArr = leftPad.map((l) => -l);
95
+ }
96
+
97
+ /** Current batch size — tracks filter() (which shrinks the per-row arrays). */
98
+ get #B(): number {
99
+ return this.leftPad.length;
100
+ }
101
+
102
+ /** mlx-lm uses `cache.offset` (the per-row array) for the scalar interface
103
+ * too; we expose the scalar total as `offset` and the per-row positions via
104
+ * `ropeOffsetArr` (the model's per-row RoPE path). */
105
+ get offset(): number {
106
+ return this.#offset;
107
+ }
108
+
109
+ get ropeOffsetArr(): MlxArray {
110
+ if (this.#ropeArr && this.#ropeForOffset === this.#offset) return this.#ropeArr;
111
+ this.#ropeArr?.dispose();
112
+ this.#ropeArr = MlxArray.fromInt32(Int32Array.from(this.offsetArr), [this.#B]);
113
+ this.#ropeForOffset = this.#offset;
114
+ return this.#ropeArr;
115
+ }
116
+
117
+ makeMask(N: number, windowSize: number | null): Mask {
118
+ const window = windowSize ?? this.maxSize;
119
+ return {
120
+ mode: "array",
121
+ arr: buildBatchedRotatingMask(
122
+ this.#B, N, this.leftPad, this.maxSize, window,
123
+ this.#idx, this.#offset, this.#rotated,
124
+ ),
125
+ };
126
+ }
127
+
128
+ /** N=1 decode update — port of _update_in_place. */
129
+ updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
130
+ const [B, H, S, D] = k.shape as [number, number, number, number];
131
+ const vD = v.shape[3]!;
132
+ if (S !== 1)
133
+ throw new Error("BatchedRotatingCache supports N=1 decode updates only (solo-prefill then merge)");
134
+ const prev = this.#offset;
135
+
136
+ // Grow the buffer (in STEP chunks) until it reaches maxSize.
137
+ if (!this.keys || (prev >= this.keys.shape[2]! && this.keys.shape[2]! < this.maxSize)) {
138
+ const newSize = Math.min(STEP, this.maxSize - prev);
139
+ const newK = ops.zeros([B, H, newSize, D], k.dtype);
140
+ const newV = ops.zeros([B, H, newSize, vD], v.dtype);
141
+ if (this.keys && this.values) {
142
+ const ck = ops.concatAxis([this.keys, newK], 2);
143
+ const cv = ops.concatAxis([this.values, newV], 2);
144
+ for (const a of [this.keys, this.values, newK, newV]) a.dispose();
145
+ this.keys = ck;
146
+ this.values = cv;
147
+ } else {
148
+ this.keys = newK;
149
+ this.values = newV;
150
+ }
151
+ this.#idx = prev;
152
+ }
153
+
154
+ // Trim any overshoot past maxSize (decrements left padding persistently).
155
+ const trimSize = this.keys!.shape[2]! - this.maxSize;
156
+ if (trimSize > 0) {
157
+ const tk = this.keys!.slice([0, 0, trimSize, 0], this.keys!.shape as number[]);
158
+ const tv = this.values!.slice([0, 0, trimSize, 0], this.values!.shape as number[]);
159
+ this.keys!.dispose();
160
+ this.values!.dispose();
161
+ this.keys = tk;
162
+ this.values = tv;
163
+ this.#idx = this.maxSize;
164
+ this.leftPad = this.leftPad.map((x) => x - trimSize);
165
+ }
166
+
167
+ // Rotate when the write head reaches the end of the ring.
168
+ if (this.#idx === this.maxSize) {
169
+ this.#rotated = true;
170
+ this.#idx = 0;
171
+ }
172
+ if (this.#rotated) this.leftPad = this.leftPad.map((x) => x - S);
173
+
174
+ // Write the new K/V at the ring head.
175
+ const [, , SK, DK] = this.keys!.shape as [number, number, number, number];
176
+ const k2 = ops.sliceUpdate(this.keys!, k, [0, 0, this.#idx, 0], [B, H, this.#idx + S, DK]);
177
+ const v2 = ops.sliceUpdate(this.values!, v, [0, 0, this.#idx, 0], [B, H, this.#idx + S, vD]);
178
+ this.keys!.dispose();
179
+ this.values!.dispose();
180
+ this.keys = k2;
181
+ this.values = v2;
182
+ this.#offset += S;
183
+ this.offsetArr = this.offsetArr.map((x) => x + S);
184
+ this.#idx += S;
185
+
186
+ // Return the populated prefix (ring not yet full) or the whole buffer.
187
+ if (this.#offset < this.maxSize) {
188
+ return [
189
+ this.keys.slice([0, 0, 0, 0], [B, H, this.#offset, DK]),
190
+ this.values.slice([0, 0, 0, 0], [B, H, this.#offset, vD]),
191
+ ];
192
+ }
193
+ return [
194
+ this.keys.slice([0, 0, 0, 0], [B, H, SK, DK]),
195
+ this.values.slice([0, 0, 0, 0], [B, H, SK, vD]),
196
+ ];
197
+ }
198
+
199
+ /** Free the per-step RoPE array without disposing KV (wrapper-rebuild path). */
200
+ releaseRopeArr(): void {
201
+ this.#ropeArr?.dispose();
202
+ this.#ropeArr = null;
203
+ this.#ropeForOffset = -1;
204
+ }
205
+
206
+ /** Ring contents in temporal order, cut to the valid length (extract). */
207
+ temporalView(): [MlxArray, MlxArray] {
208
+ if (!this.keys || !this.values) throw new Error("cache is empty");
209
+ const valid = Math.min(this.#offset, this.maxSize);
210
+ const order = (a: MlxArray): MlxArray => {
211
+ const [B, H, Sbuf, D] = a.shape as [number, number, number, number];
212
+ let t: MlxArray;
213
+ if (this.#idx === Sbuf) {
214
+ t = a.slice([0, 0, 0, 0], [B, H, Sbuf, D]);
215
+ } else if (this.#idx < this.#offset) {
216
+ const tail = a.slice([0, 0, this.#idx, 0], [B, H, Sbuf, D]);
217
+ const head = a.slice([0, 0, 0, 0], [B, H, this.#idx, D]);
218
+ t = ops.concatAxis([tail, head], 2);
219
+ tail.dispose();
220
+ head.dispose();
221
+ } else {
222
+ t = a.slice([0, 0, 0, 0], [B, H, this.#idx, D]);
223
+ }
224
+ const [, , , Dd] = t.shape as [number, number, number, number];
225
+ const cut = t.slice([0, 0, 0, 0], [B, H, valid, Dd]);
226
+ t.dispose();
227
+ return cut;
228
+ };
229
+ return [order(this.keys), order(this.values)];
230
+ }
231
+
232
+ /** Keep only `keep` rows along the batch axis (eviction). */
233
+ filter(keep: number[]): void {
234
+ if (this.keys && this.values) {
235
+ const idxArr = MlxArray.fromInt32(Int32Array.from(keep), [keep.length]);
236
+ const k = ops.takeAxis(this.keys, idxArr, 0);
237
+ const v = ops.takeAxis(this.values, idxArr, 0);
238
+ idxArr.dispose();
239
+ this.keys.dispose();
240
+ this.values.dispose();
241
+ this.keys = k;
242
+ this.values = v;
243
+ }
244
+ this.offsetArr = keep.map((i) => this.offsetArr[i]!);
245
+ this.leftPad = keep.map((i) => this.leftPad[i]!);
246
+ this.releaseRopeArr();
247
+ }
248
+
249
+ state(): MlxArray[] {
250
+ return this.keys && this.values ? [this.keys, this.values] : [];
251
+ }
252
+ isTrimmable(): boolean {
253
+ return this.#offset < this.maxSize;
254
+ }
255
+ trim(n: number): void {
256
+ const k = Math.min(this.#offset, n);
257
+ this.#offset -= k;
258
+ this.#idx -= k;
259
+ this.offsetArr = this.offsetArr.map((x) => x - k);
260
+ }
261
+ dispose(): void {
262
+ this.keys?.dispose();
263
+ this.values?.dispose();
264
+ this.keys = this.values = null;
265
+ this.#ropeArr?.dispose();
266
+ this.#ropeArr = null;
267
+ }
268
+
269
+ /** Assemble a batch from per-row temporal KV slices (port of merge). Each row
270
+ * is its solo cache's temporalView ([1,H,Li,D], Li ≤ maxSize, temporal
271
+ * order) and `offsets[i]` its absolute position. Left-pads to the longest
272
+ * row; the result is in temporal order (rotated=false, idx=offset=width). */
273
+ static merge(
274
+ rows: { keys: MlxArray; values: MlxArray }[], offsets: number[], maxSize: number,
275
+ ): BatchedRotatingCache {
276
+ const lens = rows.map((r) => r.keys.shape[2]!);
277
+ const width = Math.max(...lens, 0);
278
+ const leftPad = lens.map((l) => width - l);
279
+ const cache = new BatchedRotatingCache(maxSize, leftPad);
280
+ cache.offsetArr = [...offsets];
281
+ if (width === 0) return cache;
282
+
283
+ const padTo = (a: MlxArray, pad: number): MlxArray => {
284
+ if (pad === 0) return a.slice([0, 0, 0, 0], a.shape as number[]);
285
+ const [B, H, , D] = a.shape as [number, number, number, number];
286
+ const z = ops.zeros([B, H, pad, D], a.dtype);
287
+ const out = ops.concatAxis([z, a], 2);
288
+ z.dispose();
289
+ return out;
290
+ };
291
+ const ks = rows.map((r, i) => padTo(r.keys, leftPad[i]!));
292
+ const vs = rows.map((r, i) => padTo(r.values, leftPad[i]!));
293
+ cache.keys = ops.concatAxis(ks, 0);
294
+ cache.values = ops.concatAxis(vs, 0);
295
+ for (const a of [...ks, ...vs]) a.dispose();
296
+ cache.#idx = width;
297
+ cache.#offset = width;
298
+ return cache;
299
+ }
300
+ }