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
@@ -0,0 +1,515 @@
1
+ # Parallel slots — configurable batched serving
2
+
3
+ Status: **design + phasing** (S0 landed; S1+ not started)
4
+ Owner: serving layer
5
+ Default: **off** (`--slots 1` = today's serialized path, untouched)
6
+
7
+ ## Goal
8
+
9
+ Let the server process more than one generation request concurrently,
10
+ gated entirely behind a `--slots N` flag. `N=1` runs exactly what we
11
+ have today (the serialized promise chain). `N>1` loads a batch
12
+ scheduler that runs multiple sequences through one forward pass.
13
+
14
+ This is a server-path feature — serving speed is the user metric.
15
+ Direct/embedding mode stays batch=1.
16
+
17
+ ## STATUS (2026-06-14) — LIVE for full-attention models
18
+
19
+ **`--batch N` now serves B>1** for full-attention models (CPM). The numerics
20
+ were proven first (the batched **forward primitive** `BatchedDecodeMaskCache` +
21
+ per-layer fixes = bit-parity with mlx-lm B=N across all 4 models; static-B AND
22
+ dynamic-B `mergeKVRows`/`filterKVRows` vs mlx-lm `BatchKVCache.merge`/`.extract`/
23
+ `.filter`, CPM L1), then the **scheduler** was built and wired in:
24
+ - `BatchScheduler` (`src/serve/batch-scheduler.ts`) — the detached async driver:
25
+ admission queue → running batch → continuous inject (`mergeKVRows`) / evict
26
+ (`filterKVRows`) → per-row sample + accounting. Gated teacher-forced (KL).
27
+ - `GenerationGateway` (`src/serve/generation-gateway.ts`) — lane picker +
28
+ `AsyncMutex` (serial↔batched GPU/`loraState` exclusivity) + the `_is_batchable`
29
+ predicate. Wired into both `handleChat` call sites → per-row SSE fan-out.
30
+ - End-to-end: `tests/batch-serving.test.ts` (live CPM `--batch 2` server).
31
+
32
+ **Sliding-window (Gemma) dynamic-B is now served too** (2026-06-14):
33
+ `BatchedRotatingCache` (`src/model/batched-rotating.ts`, port of mlx-lm
34
+ `BatchRotatingKVCache` incl. the ring-wrap rolled mask) is bit-exact vs mlx-lm
35
+ model-free (`tests/batched-rotating.test.ts`); the scheduler assembles each
36
+ layer's cache by type and Gemma 12B scheduled greedy == the mlx-lm B=2 golden
37
+ with eviction. So `--batch N` batches BOTH CPM and Gemma.
38
+
39
+ **This meets the mlx-lm-parity bar.** mlx-lm's batched path is bf16 (its
40
+ quantized batching is NYI — `BatchRotatingKVCache.to_quantized` raises), so bf16
41
+ continuous batching IS the drop-in. `--batch N` is therefore a bf16 MODE: KV
42
+ quant unset → bf16 (so the batch path engages out of the box, Option B); serial
43
+ default stays mixed-precision (optiq parity); an explicit `--kv-quant` under
44
+ `--batch N` routes those requests to serial (warned).
45
+
46
+ **Batched + mixed-precision KV quant is NOT a parity gap** — no ancestor does it
47
+ (mlx-lm NYI, optiq no batching), so there's no bit-exact oracle. It's an OPTIONAL
48
+ novel extension: a memory-density win (batching × 4-bit KV compound), gated by
49
+ KL + 6-task quality (per the tree framing), deferred. Other follow-ups (all
50
+ optional polish): the `extend` join op (today joins re-merge), KV-budget
51
+ admission, prompt-cache reuse under batching, and **paged KV** (zero-padding-waste
52
+ allocation).
53
+
54
+ ## Why it helps (and when it doesn't)
55
+
56
+ The win is **throughput via memory bandwidth**. Decode on Apple Silicon
57
+ is bandwidth-bound (~273 GB/s on the M4 Pro): every token streams the
58
+ entire weight set through the GPU to produce one token. Batch B
59
+ sequences and you stream the weights **once** to produce **B** tokens.
60
+ Aggregate tok/s scales ~linearly until the compute roofline.
61
+
62
+ The crucial design point — **continuous batching, not static
63
+ batching**:
64
+
65
+ - *Static batching*: form a batch, run to completion, then accept the
66
+ next. A request arriving mid-flight waits for the whole batch to
67
+ finish. This is the wrong design; do not build it.
68
+ - *Continuous batching* (iteration-level scheduling): the scheduler
69
+ works per **decode step**. After each token it can inject a
70
+ newly-arrived request (after a short prefill) and retire finished
71
+ ones. A late request does **not** wait for the in-flight one to
72
+ finish — it joins at the next step.
73
+
74
+ So the benefit window is "any time two requests are alive in the same
75
+ wall-clock window" — not "a backlog has formed." Because generations
76
+ last seconds, requests overlap even at modest rates (Little's law:
77
+ concurrent ≈ arrival_rate × mean_generation_time).
78
+
79
+ When it does **not** help: traffic so light that requests never overlap
80
+ in time. Then there's only ever one live sequence and batching is idle
81
+ — which is exactly why `--slots 1` is and stays the default.
82
+
83
+ ## Current code: what's batch=1 and what isn't
84
+
85
+ Good news: the hot path is already written over a batch dim `B`.
86
+
87
+ - `Attention.forward` reads `const [B, L] = x.shape` and threads `B`
88
+ through every reshape/transpose/SDPA — `src/model/gemma4.ts:152`.
89
+ - `KVCache` stores `[B, H, S, D]` and grows/slices generically —
90
+ `src/model/gemma4-base.ts:204` (STEP=256 growth).
91
+ - The byte-capped LRU prompt cache already accounts bytes, not count —
92
+ `src/prompt-cache.ts` (the precedent for a byte-budgeted KV pool).
93
+
94
+ `B=1` is baked into a finite, findable set of places:
95
+
96
+ | Location | Hardcoded |
97
+ | --- | --- |
98
+ | `src/generate.ts:295,315,378` | prompt/decode tensors built `[1, L]` / `[1, 1]` |
99
+ | `src/generate.ts:320,235` | `hLast` slice `[1, …]`; `sampleStep` reshape `[1, V]` |
100
+ | `src/generate.ts:247` | sampler history `[1]`, one sampled token/step |
101
+ | `src/model/gemma4.ts:647-653` | per-layer-input reshapes `[1, L, …]` |
102
+ | `src/model/gemma4-base.ts:210` | **one scalar `offset` per cache, shared across rows** |
103
+ | `src/model/gemma4.ts:174,206` | **scalar RoPE offset** for the whole batch |
104
+ | `src/model/gemma4-base.ts:256` | `makeMask` from that single offset (returns empty mask at N=1) |
105
+
106
+ ## The one genuinely hard problem: per-sequence position
107
+
108
+ Batched sequences are different lengths and at different decode
109
+ positions, but the cache has a single `offset`, RoPE takes a scalar
110
+ offset, and the mask is built from one offset.
111
+
112
+ Solution (mlx-lm's, and ours): **left-padding + per-row mask + per-row
113
+ RoPE offsets.**
114
+
115
+ - Right-align every sequence so "the current token" is the same column
116
+ for all rows → one global advancing offset still works.
117
+ - Pad on the left; mask the padding with a real `[B, 1, 1, S]` boolean
118
+ mask — **even at the N=1 decode step**, which today returns an empty
119
+ mask (`gemma4-base.ts:257`). That shortcut is the first thing to change.
120
+ - RoPE positions become a per-row vector. **We already have the
121
+ primitive**: `ops.ropeDynamic` + the `cache.ropeOffsetArr` array-offset
122
+ path (`gemma4.ts:179,206`), currently used only by compiled decode.
123
+
124
+ Highest-risk sub-item: Gemma3 alternates sliding-window
125
+ (`RotatingKVCache`) and full-attention layers. Per-row offsets through
126
+ the ring cache's wrap semantics is the trickiest mask math in the
127
+ project. Validate the full-attention mask path first; bring sliding
128
+ layers online separately under teacher-forced parity.
129
+
130
+ ### Reuse: batched PREFILL already exists (big de-risk, found 2026-06-14)
131
+
132
+ The training path already solves batched padded forward and proves it:
133
+ - `src/train/forward.ts` — `buildBatchedPadMask(B, L, validLengths, window)`
134
+ builds the `[B,1,L,L]` mask (causal AND per-row key-validity, per distinct
135
+ window) and `BatchedMaskCache` routes it through the stock attention by
136
+ wrapping a real cache and overriding only `makeMask`. Comment confirms
137
+ "KVCache/RotatingKVCache are already shape-generic over B."
138
+ - `tests/train-batch-e2e.test.ts` proves a B=2 padded forward's per-row loss
139
+ equals two independent B=1 forwards within bf16 tolerance — i.e. the mask
140
+ makes a real row's logits independent of the other (padded) row. That is
141
+ exactly the teacher-forced gate S1 needs, already passing.
142
+ - `tests/train-batch.test.ts` unit-tests the mask (fast, no model).
143
+
144
+ So batched **prefill** is ~done and reusable (offset-0, right-padded). The
145
+ genuinely new work is batched **decode**: a growing per-row KV with per-row
146
+ offsets, per-row RoPE (array-offset path), and a per-row N=1 key-validity
147
+ mask. First decode brick landed 2026-06-14: `src/model/batched-mask.ts`
148
+ `buildBatchedDecodeMask` (left-padded, nonzero-offset counterpart) +
149
+ `tests/batched-decode-mask.test.ts` (fast). The two mask builders consolidate
150
+ when prefill is wired into serving (S1a).
151
+
152
+ ### S1b decode: design traps & sequencing (found 2026-06-14)
153
+
154
+ Reading the attention path surfaced two real subtleties — the kind that
155
+ produce plausible-but-wrong logits, so flagged before writing the cache:
156
+
157
+ 1. **Prefill/decode padding conventions differ.** Training's batched prefill
158
+ RIGHT-pads (real tokens left-aligned, positions 0..len-1 → scalar RoPE
159
+ offset 0 is correct). Batched decode wants LEFT-padding (rows right-aligned
160
+ so every row's next write lands in the same column → one advancing offset).
161
+ Left-padding then forces **per-row RoPE** (real position = column −
162
+ leftPad[b]) via the array-offset path, and means the prefill KV must be
163
+ re-laid-out (right-aligned) before decode. First cut: prefill each request
164
+ solo, copy its KV into the right-aligned row of a shared `[B,H,Smax,D]`
165
+ decode buffer, then batch the decode.
166
+ 2. **RoPE offset-timing trap (FIXED 2026-06-14).** `Attention.forward` read
167
+ `cache.ropeOffsetArr` twice — for K *before* `updateAndFetch` and for Q
168
+ *after* it (gemma4.ts ~179 vs ~195/201). Since `updateAndFetch` advances
169
+ `offset`, a per-row position array derived from the live offset would give
170
+ K and Q different positions. Fixed by capturing `offsetArr` once alongside
171
+ `offset` (line ~174). Behavior-preserving (real caches leave it unset;
172
+ compiled-decode passes a constant) — verified bit-exact against
173
+ `tests/compiled-decode.test.ts` (12B, 7/7).
174
+
175
+ **Sequencing (parity gate first, on the riskiest piece):**
176
+ - **S1b.1** — gated teacher-forced *decode* parity harness: manually assemble a
177
+ left-padded 2-row KV from two solo prefills, run K batched decode steps with
178
+ a `BatchedDecodeMaskCache` (delegates KV, returns `buildBatchedDecodeMask`,
179
+ exposes per-row `ropeOffsetArr` pinned to the pre-write offset), and assert
180
+ each row's logits match its solo greedy decode within bf16 tolerance. Gated
181
+ behind an opt-in env + local weights (like `train-batch-e2e`). Isolates
182
+ decode-batching numerics from the prefill-assembly plumbing.
183
+ **DONE 2026-06-14** — `BatchedDecodeMaskCache` (src/model/batched-mask.ts) +
184
+ `tests/batched-decode-parity.test.ts` (`MLX_BUN_TEST_BATCH_DECODE=1`,
185
+ MiniCPM5-1B). Passes: the unpadded row is **bit-exact** vs its solo decode
186
+ (batching isolates rows); the left-padded row matches within bounded bf16
187
+ reduction-order noise (≤0.23, stable over 8 steps — the shared buffer shifts
188
+ its keys by leftPad columns, so the attention sum accumulates in a different
189
+ order). **The gate earned its keep**: it caught `LlamaAttention`
190
+ (src/model/minicpm5.ts) roping Q/K with the scalar `cache.offset` and ignoring
191
+ `ropeOffsetArr` → left-padded rows mis-positioned (logit diff 8.7). Fixed to
192
+ use the dynamic array-offset path when present; MiniCPM5 solo parity stays
193
+ bit-exact vs the oracle. (Gemma4's `Attention` already had the array path; its
194
+ capture-once fix is verified bit-exact vs compiled-decode.) Lesson: each model
195
+ family's attention needs the per-row offset path wired separately.
196
+ - **S1b.2** — production KV assembly (solo prefill → left-aligned decode buffer).
197
+ - **S1b.3** — scheduler + B-token/step generate loop + per-row stream fan-out.
198
+
199
+ ### The validation matrix — 3 parity layers × every model path
200
+
201
+ **Current state (2026-06-14).** Roster: **CPM (MiniCPM5-1B) + 3 Gemmas
202
+ (e4b / 12B / 26B)**. Every NEW model family (e.g. **Qwen**, planned) adds its
203
+ OWN handler path and its own copy of the three layers below — nothing here is
204
+ generic. This matrix is a snapshot; re-derive it when the roster changes.
205
+
206
+ **Framing (refined 2026-06-14 — it's a TREE, not strict layers).** mlx-lm is the
207
+ base library; **mlx-optiq and mlx-bun are both optimizations on top of it**
208
+ (optiq = mixed-precision per-layer KV quant, "drop-in for mlx_lm.server", no
209
+ batching; mlx-bun = per-model fused kernels + batched serving). Bit-parity with
210
+ an ancestor is just an **easy correctness test where we overlap one** — not a
211
+ porting goal. The "L1/L2/L3" labels below are **parity anchors / correctness
212
+ checkpoints**, not a strict stack: L1 = "matches mlx-lm (bf16)", L2 = "matches
213
+ optiq (mixed-precision quant)", L3 = "our own optimizations, no ancestor does
214
+ them → no oracle". A combo no ancestor does (e.g. **batched mixed-precision
215
+ serving** — mlx-lm can't do mixed-precision, optiq can't batch) is genuinely new
216
+ territory: gate it by KL + the 6-task quality + benchmarks, never bit-exactness.
217
+ The anchors are still useful — we can always dial back to a bit-exact-vs-ancestor
218
+ config as a correctness checkpoint and build outward.
219
+
220
+ - **L1 — mlx-lm parity (standard KV).** bf16 KV, **bit-exact vs mlx-lm**. The
221
+ foundation oracle. Path: monolith `Gemma4Model` / `MiniCPM5Model`, `ops.sdpa`,
222
+ perf kernels off.
223
+ - **L2 — mlx-optiq parity (mixed-precision KV quant).** quant/mixed KV,
224
+ **bit-exact vs optiq**. Path: `QuantizedKVCache` + `quantizedSdpa` *unfused*
225
+ (`MLX_BUN_PERF_KERNEL=0`).
226
+ - **L3 — our perf mode.** generated unrolled handlers + fused/perf kernels;
227
+ **deliberately beyond the oracle — there is NO bit-exact reference for L3**, by
228
+ design (these optimizations go past what mlx-lm/optiq do). The gate is **KL vs
229
+ the compatible upstream + the 6 eval-task mean (`src/eval/tasks/`) + perf
230
+ numbers** — never bit-exactness. Path: generated handler, perf-kernel +
231
+ fused-sdpa + fused-gelu on (the defaults). compiled-decode is inert here
232
+ (CustomKernel has no `output_shapes`) — a compat/benchmark artifact, not a
233
+ production mode. **L3's freedom is bounded by graceful degradation**: it may
234
+ diverge only because L2/L1 stay reachable and provably bit-exact.
235
+
236
+ Degradation levers: `--perf-kernel off` / `--fused-* off` drops L3→L2; bf16 KV
237
+ drops L2→L1. Each layer must stand alone and stay reachable (don't-delete-
238
+ optionality as a fallback ladder).
239
+
240
+ So a feature like batched decode (`--slots`) is "done" only when green at
241
+ **every (model × layer)** cell — and each cell may need its own fix:
242
+
243
+ | Model · path | L1 (bf16 / mlx-lm) | L2 (quant / optiq) | L3 (perf / KL) |
244
+ | --- | --- | --- | --- |
245
+ | CPM (MiniCPM5) | ✅ **oracle-verified** (= mlx-lm B=2) | ☐ quant + unfused-mask | ☐ |
246
+ | Gemma 12B | ✅ **oracle-verified** (= mlx-lm B=2, short-ctx) | ☐ generated + sliding-window | ☐ (gen rope fix) |
247
+ | Gemma e4b | ✅ **oracle-verified** (= mlx-lm B=2, short-ctx) | ☐ generated + same | ☐ (gen rope fix) |
248
+ | Gemma 26B | ✅ **oracle-verified** (= mlx-lm B=2, short-ctx) | ☐ generated + MoE | ☐ (gen rope fix) |
249
+ | *(Qwen, future)* | *new path* | *new path* | *new path* |
250
+
251
+ **🎯 L1 batched decode COMPLETE (2026-06-14d): the entire L1 row is green** —
252
+ all 4 model paths bit-parity with mlx-lm B=2 (CPM full-attn, Gemma 12B
253
+ sliding+full, e4b per-layer-input+KV-share, 26B MoE). Only one per-path fix was
254
+ needed (e4b's `[1,L,…]` per-layer-input hardcode); sliding (RotatingKVCache),
255
+ KV-sharing, and MoE were all already B-generic. Caveat: short-context (pre-wrap)
256
+ — ring-wrap (>window) is the one remaining L1 follow-up. Next rows: **L2**
257
+ (quantized KV → `quantizedSdpaUnfused` + 4-D mask, vs optiq) and **L3** (perf
258
+ kernels under batching, KL+quality).
259
+
260
+ Harness: `tests/batched-decode-parity.test.ts` has two paths — (a)
261
+ `runBatchedDecodeParity` = the internal-consistency KL check (B=N vs our B=1,
262
+ NOT the gate); (b) **`realBatchedGreedy` = the REAL gate**: left-pad → one
263
+ `BatchedDecodeMaskCache` (handles prefill at offset 0 AND per-step decode) →
264
+ batch-prefill → greedy batch-decode, trajectory compared bit-for-bit to the
265
+ mlx-lm B=N oracle fixture (`tests/fixtures/batched-golden-*.json`, generated by
266
+ `scripts/gen-batched-golden.py` in the oracle venv).
267
+
268
+ **CPM L1 oracle-verified 2026-06-14d:** mlx-bun's real batched greedy trajectory
269
+ == mlx-lm B=2 exactly (both rows incl. the left-padded one, 8 steps). Confirms
270
+ `BatchedDecodeMaskCache` == mlx-lm `BatchKVCache` (left-pad + per-row offset RoPE
271
+ + `j>=leftPad` mask + fused bool sdpa), and that the wrapper handles batched
272
+ PREFILL too (offset 0 → left-pad prefill mask + `ropeOffsetArr = -leftPad`). The
273
+ earlier "KL 0.26" was measured vs the wrong oracle (our own B=1).
274
+
275
+ **Gate philosophy (revised twice, 2026-06-14):** batched decode is NOT bit-exact
276
+ vs single-stream (batching changes the attention kernel's reduction order). The
277
+ RIGHT oracle is **mlx-lm's OWN batch mode at the same B** — mlx-lm B=N ≡ mlx-bun
278
+ B=N, **bit-exact**. Comparing our B=N to our own B=1 (the KL harness) measures
279
+ the wrong thing and can't distinguish a real bug from inherent batched-kernel
280
+ behavior. mlx-lm's batch path (oracle venv, confirmed 2026-06-14):
281
+ `BatchKVCache`/`BatchRotatingKVCache(left_padding)` — left-padded, a **per-row
282
+ `offset` array** (`-left_padding`, `+=1`/step → real per-row position, feeding
283
+ RoPE), mask `j >= left_padding[b]` (`create_causal_mask(left_padding=…)`), and
284
+ for bf16 the **same fused bool-mask `mx.fast.scaled_dot_product_attention`** we
285
+ use. So our approach matches in principle and the **additive-mask idea is wrong**
286
+ (would deviate). Oracle generator: `scripts/gen-batched-golden.py` (drives
287
+ mlx-lm's real batch-prefill + greedy batch-decode, dumps per-row trajectories +
288
+ last-position logits). CPM B=2 oracle captured 2026-06-14.
289
+
290
+ **Remaining gap:** the KL harness solo-prefills each row then *assembles* a
291
+ batched cache, and compares to *solo* decode. mlx-lm batch-PREFILLS (B=N) — so
292
+ even prefill KV differs (B=1 vs B=N kernel). To gate faithfully we need a real
293
+ mlx-bun **batched prefill + batched decode** path, compared bit-exact to the
294
+ mlx-lm B=N golden. (The KL B2-vs-B1 harness stays as a cheap internal-consistency
295
+ check, not the gate.)
296
+
297
+ **Gemma 12B L1 — RESOLVED 2026-06-14d: it was the wrong oracle, not a bug.**
298
+ The real `realBatchedGreedy` path (batch-prefill + decode) matches the mlx-lm
299
+ B=2 oracle **exactly** for Gemma 12B (both rows incl. left-padded, sliding
300
+ layers via RotatingKVCache→BatchRotatingKVCache, short-context). So the "padded
301
+ row KL ~0.26" was entirely an artifact of the KL harness comparing our B=2 to
302
+ our own B=1 (different prefill + reduction order) — mlx-lm B=2 diverges from its
303
+ own B=1 the same way. No additive-mask fix was needed; the bool+fused path is
304
+ correct (it's what mlx-lm uses). The KL B2-vs-B1 harness stays only as an
305
+ internal-consistency check, never the gate.
306
+
307
+ **Caveat:** verified at short context (Lmax ≪ 1024 window → pre-wrap, sliding
308
+ window inactive). **Ring-wrap (context > window) is a separate validation** —
309
+ needs a long-context golden (mlx-lm BatchRotatingKVCache wrapped) + mlx-bun
310
+ RotatingKVCache wrapped past the window.
311
+
312
+ Batched-decode landmines found by the modes analysis (2026-06-14):
313
+ - **e4b**: `computePerLayerInputs` hardcodes `[1,L,…]` (gemma4.ts:647) → B>1
314
+ breaks; plus KV-sharing (sharer `SharedKv`).
315
+ - **Gemmas**: `RotatingKVCache` sliding-window ring — per-row decode mask + ring
316
+ write (the hard one). 26B adds MoE (`GatherQMM`/`SwitchGLU`) batched routing.
317
+ - **Generated handlers (L3, all 3 Gemmas)** repeat the K/Q rope timing trap —
318
+ they read `cache.ropeOffsetArr` twice around `updateAndFetch`
319
+ (generated/gemma4-12b.ts:63 & :74). The fix goes in the GENERATOR
320
+ `scripts/gen-model.ts`, then regenerate (the files are DO-NOT-EDIT).
321
+ - **Quant + batched**: the `[B,1,N,S]` array mask disqualifies the fused decode
322
+ kernel (`mask.mode===""` required) and the tiled path (2-D causal-equiv
323
+ required), so quant batched decode falls to `quantizedSdpaUnfused` — correct
324
+ but the perf kernel is bypassed (perf debt; a batched-aware fused decode kernel
325
+ is a later item). `quantizedSdpaUnfused` must be validated with a 4-D bool mask.
326
+ - **All cells**: compiled-decode forced off under `--slots>1`; LoRA batches only
327
+ within one adapter group (else drain).
328
+
329
+ ## KV memory: dynamic allocation, not static partition
330
+
331
+ The naive model — reserve `budget / slots` per row, fixed — is rejected:
332
+ it wastes memory and imposes an arbitrary per-slot context cap. We
333
+ allocate **by need**. Three rungs, in increasing order of "who needs it
334
+ most" fidelity and cost:
335
+
336
+ 1. **Static partition** — *not built.* Fixed per-slot ceiling. Wasteful.
337
+ 2. **Dynamic shared-budget, contiguous caches (padded batch)** — the
338
+ shippable path. Admission is governed by *total projected bytes*
339
+ against one KV budget (mirrors the byte-capped `PromptCache`); each
340
+ sequence grows its own contiguous cache on demand (KVCache already
341
+ grows in 256-token steps). Short prompts use little; long ones take
342
+ more. **Caveat**: a padded batch shares one `[B, H, S, D]` buffer, so
343
+ the buffer width tracks the *longest live sequence* — short rows still
344
+ carry padding to that width. Partial "who needs it most."
345
+ 3. **Paged KV (the true goal)** — fixed-size KV blocks in a shared pool,
346
+ gathered by a custom paged-attention Metal kernel. No padding waste;
347
+ the neediest sequence grabs more blocks, others fewer; maximizes
348
+ concurrent rows per GB. This is what "dynamically allocate based on
349
+ who needs it most" actually means. Cost: a custom attention kernel +
350
+ a block manager. Feasible — the project already ships custom Metal
351
+ kernels (`src/mlx/metal-kernel.ts`, `src/model/fused-decode-kernel.ts`).
352
+
353
+ Plan: ship rung 2 (S1–S2) for the throughput win + dynamic admission
354
+ with no new kernel; build rung 3 (S3+) as the memory-density upgrade.
355
+ Budget accounting is total-bytes from day one.
356
+
357
+ **Current state (2026-06-14):** the batched *primitive* is **rung 2** —
358
+ one shared `[B, H, S, D]` buffer per layer (`KVCache`/`RotatingKVCache`),
359
+ growing in 256-token steps, width = longest live sequence, left-pad
360
+ "waste" on short rows. But the **budget/admission half of rung 2 is NOT
361
+ built** (no `B × S_max` projection, no KV budget enforcement) — that's
362
+ scheduler work (S2), and the scheduler itself isn't built (`--slots`
363
+ inert). And **rung 3 (paged) is a deferred spike**. So today: rung-2
364
+ *allocation shape*, no *budget control*, not wired into serving.
365
+
366
+ KV quantization is a force multiplier: 4-bit KV (`generate.ts:67`,
367
+ already supported) is ~4× smaller, so the same budget buys ~4× the slots
368
+ or ~4× context per slot. Batching + KV-quant compound.
369
+
370
+ Note: the existing `2e9` (`server.ts:494`) is the **prompt (prefix-reuse)
371
+ cache**, not live decode KV. Live caches are made fresh per generation
372
+ (`server.ts:517`). Slots introduce a *new* budget: the pool of live
373
+ decode caches.
374
+
375
+ ## The scheduler (port of mlx-lm `BatchGenerator` / `ResponseGenerator`)
376
+
377
+ When `slots > 1`, the single promise chain (`server.ts:487`) is replaced
378
+ by a scheduler owning:
379
+
380
+ - a **pending queue** of admitted requests;
381
+ - a **running batch** of ≤ `decodeConcurrency` rows;
382
+ - **admission control** against the KV budget;
383
+ - a **batchable gate** (mlx-lm `_is_batchable`): same model, compatible
384
+ sampler params, **no fixed seed**, no vision, compatible adapter set;
385
+ - **prefill phase** (≤ `promptConcurrency` rows) vs **decode phase**;
386
+ - **draining**: an incompatible request waits for the batch to clear,
387
+ then runs in its own batch.
388
+
389
+ Streaming fan-out is the main plumbing rework: today `generate()` yields
390
+ one token to one SSE stream (`server.ts:964,1037`). Batched, each step
391
+ produces B tokens that must route to B independent response streams,
392
+ each with its own `StopSequencer`/tool-call router. Touches
393
+ `server.ts`, `responses.ts`, `anthropic.ts`.
394
+
395
+ ### Concrete plan (the `--batch N` engine, build order — 2026-06-14)
396
+
397
+ Started: `--batch N` flag landed (renamed from `--slots`; `--decode-concurrency`
398
+ accepted as an mlx_lm.server alias). N=1 → today's serial path; N>1 → the engine
399
+ below. The engine is a **mode** (whole server), not a load-fallback (keeps
400
+ determinism + the drop-in promise).
401
+
402
+ **Dynamic-B cache ops** (port of mlx-lm's cache methods — the new piece our
403
+ fixed-B verified forward doesn't have):
404
+ - **`merge(perRowCaches)`** — stack N single-row caches into one `[B,H,S,D]`
405
+ batch, left-pad-aligned (generalizes `realBatchedGreedy`'s assemble).
406
+ - **`filter(keep)`** — slice the B axis to drop finished rows (eviction).
407
+ - **`extend(other)`** — append a freshly-prefilled batch's rows (insertion).
408
+ - mlx-lm's flow: a joining request is **right-padded + prefilled** in a prompt
409
+ batch, then `finalize()` (dynamic-roll) → left-pad, then `merge`/`extend` into
410
+ the running generation batch. Our verified attention/mask/RoPE drop straight in.
411
+
412
+ **Engine loop (Bun-async, NOT Python threads):** an async loop owns the running
413
+ batch. Each iteration: (1) admit waiting+batchable requests up to N (prefill +
414
+ `extend`); (2) one batched decode step (the verified forward); (3) sample
415
+ per-row, push each row's token to its own SSE `ReadableStream` (per-row
416
+ `StopSequencer` + tool parser + sampler); (4) `filter` out rows that hit
417
+ EOS/stop/max-tokens, resolve their streams. HTTP handlers `await` their row's
418
+ stream; no thread, just the event loop + `mx.async_eval` pipelining.
419
+
420
+ **Build sequence:** (1) **DONE 2026-06-14** — dynamic-B cache `merge`/`filter` +
421
+ a pure unit test, then a gated parity test vs an mlx-lm *dynamic* golden (rows
422
+ join/leave mid-stream). `scripts/gen-batched-dynamic-golden.py` drives mlx-lm's
423
+ real `BatchKVCache.merge`/`.extract`/`.filter` through {A,B}→join C→evict A→{B,C};
424
+ `realDynamicBatchedGreedy` (`tests/batched-decode-parity.test.ts`) runs the same
425
+ scenario through `mergeKVRows`/`filterKVRows` and matches all 3 per-row greedy
426
+ trajectories token-for-token (CPM L1). Join = re-merge of extracted advanced-offset
427
+ rows + a fresh solo prefill (proves `mergeKVRows` on non-fresh rows); `extend`
428
+ (keep-the-running-batch optimization, mlx-lm's actual join op) is deferred into
429
+ the scheduler. Added `BatchedDecodeMaskCache.releaseRopeArr()`. (Originally
430
+ full-attention only; sliding-window `BatchedRotatingCache` landed 2026-06-14 —
431
+ see the STATUS block above.)
432
+ (2) **DONE 2026-06-14** — the async loop + per-row SSE wired into `createServer`
433
+ behind `--batch N`. `BatchScheduler` (`src/serve/batch-scheduler.ts`) is the
434
+ detached driver; `GenerationGateway` (`src/serve/generation-gateway.ts`) picks
435
+ the lane and an `AsyncMutex` keeps serial and batched off the GPU/`loraState`
436
+ together. Both `handleChat` call sites route through `gateway.run`; per-row
437
+ onToken closures give per-row SSE fan-out for free. Gated: `tests/batch-scheduler.test.ts`
438
+ (teacher-forced KL, evict + join) + `tests/batch-serving.test.ts` (live CPM
439
+ server, concurrent + streaming + drain). No serial regression (server 17/17,
440
+ tools 13/13).
441
+ (3) **DONE 2026-06-14** — `_is_batchable` gate is the `GenerationGateway.willBatch`
442
+ predicate (full-attention model + no vision/adapter/repetition-penalty/user-seed
443
+ → batch; else serial). `B×S_max` memory admission is still TODO.
444
+
445
+ ## Fallbacks (must degrade, never break)
446
+
447
+ - **Vision** → always solo. Image embeddings + bidirectional mask need
448
+ offset-0 single-sequence prefill (`gemma4.ts:690` throws otherwise).
449
+ - **LoRA** → batch only requests sharing the same adapter set, else
450
+ drain to solo. `loraState.active` is a single per-generation field
451
+ (`generate.ts:187`); cache namespaces are adapter-keyed
452
+ (`prompt-cache.ts:18`). Per-row adapters is a much harder problem;
453
+ group-by-adapter + drain is accepted (confirmed fine for our use).
454
+ - **Fixed seed / unusual sampler** → drain to solo (matches `_is_batchable`).
455
+ - **Compiled decode** (`generate.ts:348`) → start **disabled** under
456
+ batching (single-sequence shapeless replay); extend to `[B,1]` later.
457
+ - **Prompt cache** → per-row take/put still works, but the
458
+ "single queue ⇒ take/reinsert is race-free" invariant
459
+ (`prompt-cache.ts:7`, `lora.ts:7`) now depends on the **scheduler**
460
+ being the sole owner of cache mutations. Keep all take/put inside the
461
+ scheduler loop.
462
+
463
+ ## Config surface
464
+
465
+ - `ServerOptions.slots` (default 1); later `kvBudgetBytes`,
466
+ `promptConcurrency`.
467
+ - CLI: `--slots N` (S0); later `--kv-budget <GB>`.
468
+ - `slots === 1` → today's serialized promise chain, untouched.
469
+ `slots > 1` → scheduler.
470
+
471
+ ## Phasing (incremental, parity-gated)
472
+
473
+ - **S0 — config seam (DONE).** `--slots N` / `ServerOptions.slots`
474
+ plumbed, validated, surfaced (ready card + `/stats`). `N>1` warns that
475
+ batched execution lands in S1 and runs serially. No behavior change.
476
+ - **S1 — static 2-wide, base model only.** Split by the reuse finding:
477
+ - **S1a (prefill)** — reuse the training machinery (`buildBatchedPadMask`
478
+ / `BatchedMaskCache`); wire the serving path to prefill B prompts in one
479
+ forward. Mostly generalization + consolidating the two mask builders.
480
+ - **S1b (decode)** — the new work: a batched KV cache that grows with
481
+ per-row offsets, per-row RoPE via the array-offset path, and the per-row
482
+ `[B,1,1,S]` decode mask (`buildBatchedDecodeMask`, landed 2026-06-14).
483
+ Plus the B-token-per-step decode loop + per-row stream fan-out.
484
+ **Teacher-forced gate**: a 2-row batch must produce per-row logits matching
485
+ two solo runs within bf16 tolerance (the train-batch-e2e methodology, now
486
+ applied to decode).
487
+ - **S2 — N-wide + continuous injection/eviction.** Rows retire on EOS;
488
+ queued requests prefill into freed rows. Dynamic byte-budget admission.
489
+ - **S3+ — paged KV** (custom paged-attention kernel + block manager),
490
+ KV-quant under batch, LoRA-group batching.
491
+
492
+ Every phase ships default-off behind `slots=1`; the serialized path is
493
+ never removed (only bypassed).
494
+
495
+ ## Open questions / to measure
496
+
497
+ - Sliding-window per-row masking (above) — retire this correctness risk
498
+ early.
499
+ - `async_eval` decode pipelining (`generate.ts:386`) builds step n+1
500
+ before reading n; one row stopping mid-pipeline while others continue
501
+ needs care.
502
+ - Throughput ceiling: the B where we cross bandwidth→compute bound, and
503
+ aggregate tok/s vs solo — measured via `benchmark.sh` (preflight-gated;
504
+ loaded-machine numbers don't count).
505
+
506
+ ## References
507
+
508
+ - mlx-lm continuous batching: `BatchGenerator` / `ResponseGenerator`,
509
+ flags `--decode-concurrency` (default 32), `--prompt-concurrency`
510
+ (default 8); `_is_batchable` gate; batch draining.
511
+ https://deepwiki.com/ml-explore/mlx-lm/3.3-http-server ·
512
+ https://github.com/ml-explore/mlx-lm/issues/499
513
+ - optiq: **no** batching — single-process serialized, advises external
514
+ max-concurrency; each in-flight request keeps a resident KV cache.
515
+ https://mlx-optiq.com/docs/serve