mlx-bun 0.0.9 → 0.0.11

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 (179) hide show
  1. package/README.md +66 -21
  2. package/THIRD_PARTY_LICENSES.md +1 -0
  3. package/docs/archive/mlx-bun-lab-report.html +275 -0
  4. package/docs/archive/wwdc-mlx-bun.html +848 -0
  5. package/docs/design/audio-input-plan.md +356 -0
  6. package/docs/design/batching-perf-path.md +255 -0
  7. package/docs/design/batching-v2-plan.md +23 -9
  8. package/docs/design/bucketing-stage.md +2 -1
  9. package/docs/design/compat-cli-surface-design.md +4 -3
  10. package/docs/design/decode-speed-program.md +172 -0
  11. package/docs/design/docs-reorg-plan.md +1 -1
  12. package/docs/design/dspark-serving-program.md +371 -0
  13. package/docs/design/dspark-speculative-decoding.md +167 -5
  14. package/docs/design/faithful-l1-consolidation.md +310 -0
  15. package/docs/design/generic-model-support.md +8 -5
  16. package/docs/design/grammar-spec-batching-integration.md +301 -0
  17. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  18. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  19. package/docs/design/omlx-adoption-map.md +98 -0
  20. package/docs/design/orpo-training.md +34 -30
  21. package/docs/design/paged-kv-cache.md +214 -0
  22. package/docs/design/parallel-slots.md +82 -49
  23. package/docs/design/parity-tier-dag.md +43 -33
  24. package/docs/design/repo-cleanup-plan.md +189 -0
  25. package/docs/design/runtime-isolation.md +19 -3
  26. package/docs/design/segmented-backward-training.md +18 -2
  27. package/docs/design/ssd-kv-cold-tier.md +288 -0
  28. package/docs/design/structured-output.md +484 -0
  29. package/docs/design/the-dreaming-master-plan.md +2 -3
  30. package/docs/design/turboquant-kv.md +235 -0
  31. package/docs/design/unified-engine-frontier-plan.md +774 -0
  32. package/docs/design/web-chat-beat-matrix.md +315 -0
  33. package/docs/design/web-chat-redesign.md +1112 -0
  34. package/docs/investigations/decode-roofline-lookagain.md +92 -6
  35. package/docs/investigations/dspark-handoff.md +182 -16
  36. package/docs/investigations/kernel-perf-review-2026-07.md +105 -26
  37. package/docs/investigations/lab-build-journal.md +2 -2
  38. package/docs/investigations/openwiki-evaluation.md +249 -0
  39. package/docs/planning/IDEAS.md +14 -0
  40. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  41. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  42. package/docs/planning/chat-ui-vision.md +50 -0
  43. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  44. package/docs/planning/release-notes-v0.0.10.md +57 -0
  45. package/docs/planning/release-notes-v0.0.11.md +239 -0
  46. package/docs/reference/cli.md +35 -16
  47. package/docs/reference/distribution.md +13 -6
  48. package/docs/reference/embedding.md +35 -14
  49. package/docs/reference/features-matrix.md +97 -0
  50. package/docs/reference/library-api.md +30 -9
  51. package/docs/reference/memory.md +26 -6
  52. package/docs/reference/models.md +11 -1
  53. package/docs/reference/orpo-quickstart.md +3 -4
  54. package/docs/reference/server-api.md +634 -22
  55. package/docs/reference/server-config.md +251 -119
  56. package/docs/reference/training.md +20 -16
  57. package/package.json +5 -3
  58. package/src/anthropic.ts +13 -0
  59. package/src/audio/conformer.ts +647 -0
  60. package/src/audio/decode.ts +133 -0
  61. package/src/audio/features.ts +202 -0
  62. package/src/audio/transcode.ts +67 -0
  63. package/src/cli.ts +402 -107
  64. package/src/config.ts +49 -2
  65. package/src/download.ts +40 -0
  66. package/src/eval/README.md +7 -6
  67. package/src/eval/kl.ts +59 -3
  68. package/src/eval/quality-db.ts +2 -2
  69. package/src/eval/runner.ts +3 -3
  70. package/src/expert-offload.ts +15 -2
  71. package/src/flags.ts +13 -0
  72. package/src/generate.ts +330 -16
  73. package/src/grammar.ts +585 -0
  74. package/src/hub-rest.ts +242 -0
  75. package/src/kv-store.ts +618 -122
  76. package/src/lora.ts +8 -0
  77. package/src/md-modules.d.ts +42 -0
  78. package/src/media-fetch.ts +284 -0
  79. package/src/memory/rest.ts +245 -0
  80. package/src/memory/vault.ts +74 -0
  81. package/src/mlx/array.ts +127 -19
  82. package/src/mlx/autograd.ts +2 -2
  83. package/src/mlx/ffi.ts +27 -3
  84. package/src/mlx/metal-kernel.ts +7 -4
  85. package/src/mlx/ops.ts +179 -23
  86. package/src/mlx/turboquant-ops.ts +831 -0
  87. package/src/mlx/turboquant-tables.ts +237 -0
  88. package/src/model/batched-mask.ts +62 -1
  89. package/src/model/batched-quant.ts +206 -0
  90. package/src/model/batched-rotating-quant.ts +346 -0
  91. package/src/model/batched-rotating.ts +46 -1
  92. package/src/model/compiled-decode.ts +4 -15
  93. package/src/model/factory.ts +10 -4
  94. package/src/model/gemma4-base.ts +391 -34
  95. package/src/model/gemma4.ts +122 -33
  96. package/src/model/generated/gemma4-12b.ts +9 -13
  97. package/src/model/generated/gemma4-26b.ts +10 -5
  98. package/src/model/generated/gemma4-e4b.ts +14 -9
  99. package/src/model/minicpm5.ts +58 -102
  100. package/src/model/paged-kv.ts +253 -0
  101. package/src/model/qwen3-delta.ts +123 -15
  102. package/src/model/qwen3-moe.ts +404 -0
  103. package/src/model/qwen3.ts +9 -8
  104. package/src/model/qwen3_5.ts +99 -25
  105. package/src/model/support.ts +12 -1
  106. package/src/model/universal/dense.ts +40 -5
  107. package/src/model/universal/rope.ts +36 -21
  108. package/src/pi-web.ts +1299 -82
  109. package/src/preflight.ts +27 -8
  110. package/src/prompt-cache.ts +338 -32
  111. package/src/quantize/quantizer.ts +16 -1
  112. package/src/registry.ts +61 -3
  113. package/src/serve/batch-scheduler.ts +1001 -76
  114. package/src/serve/generation-gateway.ts +292 -30
  115. package/src/serve/isolate.ts +417 -0
  116. package/src/serve/lane-registry.ts +44 -0
  117. package/src/serve/session-search.ts +326 -0
  118. package/src/server.ts +1460 -105
  119. package/src/spec/assistant-source.ts +150 -0
  120. package/src/spec/deepspec-source.ts +143 -0
  121. package/src/spec/dflash-source.ts +124 -0
  122. package/src/spec/dspark/ab-stats.ts +136 -0
  123. package/src/spec/dspark/calibration.ts +112 -0
  124. package/src/spec/dspark/deepspec-module.ts +801 -0
  125. package/src/spec/dspark/generate-dflash.ts +72 -20
  126. package/src/spec/dspark/loader.ts +23 -0
  127. package/src/spec/dspark/module-dflash.ts +278 -36
  128. package/src/spec/dspark/quantize-drafter.ts +91 -0
  129. package/src/spec/generate.ts +59 -25
  130. package/src/spec/ngram-source.ts +132 -0
  131. package/src/spec/serve-loop.ts +536 -0
  132. package/src/spec/source.ts +93 -0
  133. package/src/spec/two-model.ts +171 -0
  134. package/src/ssd-cache.ts +285 -0
  135. package/src/tokenizer.ts +288 -2
  136. package/src/tool-approvals.ts +131 -0
  137. package/src/tool-call.ts +408 -20
  138. package/src/train/flash-cce.ts +29 -26
  139. package/src/train/job.ts +0 -2
  140. package/src/train/loss.ts +53 -11
  141. package/src/train/prefix-shared.ts +30 -7
  142. package/src/train/segmented.ts +150 -46
  143. package/src/train/trainer.ts +22 -15
  144. package/src/vision/prompt.ts +217 -48
  145. package/src/web/app.html +1184 -1789
  146. package/src/web/app.js +5667 -0
  147. package/src/web/icon.svg +13 -0
  148. package/src/web/manifest.webmanifest +24 -0
  149. package/src/web/src/adapters-panel.ts +288 -0
  150. package/src/web/src/api.ts +57 -0
  151. package/src/web/src/assistant.ts +485 -0
  152. package/src/web/src/chat.ts +1050 -0
  153. package/src/web/src/composer.ts +959 -0
  154. package/src/web/src/dataset.ts +132 -0
  155. package/src/web/src/finetune.ts +267 -0
  156. package/src/web/src/hub.ts +428 -0
  157. package/src/web/src/main.ts +98 -0
  158. package/src/web/src/markdown.ts +0 -0
  159. package/src/web/src/memory-panel.ts +572 -0
  160. package/src/web/src/model-picker.ts +159 -0
  161. package/src/web/src/palette.ts +346 -0
  162. package/src/web/src/protocol.ts +62 -0
  163. package/src/web/src/quantize.ts +167 -0
  164. package/src/web/src/rag.ts +274 -0
  165. package/src/web/src/sessions.ts +469 -0
  166. package/src/web/src/shell.ts +804 -0
  167. package/src/web/src/status.ts +252 -0
  168. package/src/web/src/ui-catalog.ts +92 -0
  169. package/src/web/sw.js +71 -0
  170. package/src/web/vendor/README.md +61 -0
  171. package/src/web/vendor/hljs-LICENSE +29 -0
  172. package/src/web/vendor/hljs-theme.css +59 -0
  173. package/src/web/vendor/hljs.js +26 -0
  174. package/src/model/fused-decode-kernel.ts +0 -312
  175. package/src/model/fused-geglu-kernel.ts +0 -198
  176. package/src/model/fused-mlp-kernel.ts +0 -169
  177. package/src/model/fused-swiglu-kernel.ts +0 -240
  178. package/src/model/steel-linear-kernel.ts +0 -96
  179. package/src/status-page.html +0 -497
@@ -0,0 +1,774 @@
1
+ # Unified engine + the frontier program — the reorganization plan
2
+
3
+ Status: **PLAN** (2026-07-05, from the flag-architecture discussion with Josh
4
+ after the naked-=-L1 decision). This is the design that makes the library
5
+ "make sense": one engine, one honest flag surface, per-scheme oracles, and a
6
+ Lab where optimization experiments live until they earn defaults.
7
+
8
+ ## 0. The goals (Josh, 2026-07-05 — the two-part product)
9
+
10
+ 1. **A drop-in replacement for mlx-lm: support EVERYTHING they do, with
11
+ bit-exact parity.** This is the default. Parity is only DEFINED over
12
+ outputs mlx-lm can produce — you cannot ask for "mlx-lm's bits under
13
+ mixed-precision KV" because mlx-lm cannot output anything under it.
14
+ 2. **Beyond mlx-lm — features you opt into by deliberately trading the
15
+ parity guarantee for something you value MORE**, each answering to its
16
+ own oracle (whoever already ships it):
17
+ - **multi-model switching** (oracle: optiq per-request switching / oMLX
18
+ EnginePool) — ❌ missing today; the newest backlog item (layer 4).
19
+ - **mixed-precision KV cache** (oracle: optiq) — ✅ serial, bit-exact;
20
+ ✅ **under batching for all-full-attention configs (LANDED 2026-07-05
21
+ — Phase 3.1 P1: cpm5 end-to-end, per-row bit-exact vs the optiq
22
+ composition; no other stack ships this)**; rotating-layer configs
23
+ (gemma) = milestone 2.
24
+ - **prefix sharing** (oracle: vLLM/SGLang) — ⚠️ whole-prefix reuse only;
25
+ true cross-request sharing (one system prompt's KV, N rows) missing.
26
+ - **prompt caching** (oracle: mlx-lm's LRUPromptCache) — ✅ byte-capped
27
+ + SSD cold tier beyond anyone; ❌ batched rows → Phase 3.2.
28
+ - **structured outputs** (oracle: oMLX/xgrammar) — ✅ serial + batched,
29
+ byte-identical vs oMLX; composes with spec decode.
30
+
31
+ Supersedes: batching-v2-plan.md's standing decision that `--batch N` is a
32
+ mode switch (reversal documented in §4, with the new rationale — the old
33
+ rejection's determinism argument is answered, not ignored). Builds on:
34
+ faithful-l1-consolidation.md (the L1 baseline, now the naked default),
35
+ parity-tier-dag.md (per-scheme oracles), omlx-adoption-map.md (survey),
36
+ batching-perf-path.md (the B=1 gap numbers this plan must close).
37
+
38
+ ---
39
+
40
+ ## 1. North star: beyond the Pareto frontier
41
+
42
+ The claim we are building toward: **"the best way to run local AI on a Mac."**
43
+ Formally: every (model, workload) point we ship should sit on or beyond the
44
+ current local-inference Pareto frontier in this space:
45
+
46
+ | axis | direction | measured by |
47
+ |---|---|---|
48
+ | memory | less is better | generation peak (Metal), fit-model predictions |
49
+ | decode tok/s | more | bench-h2h (stable cells only) |
50
+ | prefill / TTFT | faster | bench-h2h, server TTFT |
51
+ | throughput | higher | batched aggregate tok/s (bench-serving-load) |
52
+ | intelligence | higher | frozen eval suite (MMLU/GSM8K/IFEval/HumanEval/BFCL/HashHop, evals.sqlite) |
53
+
54
+ **THE CONTRACT (Josh, 2026-07-05):** two rules, and they compose:
55
+
56
+ 1. **The drop-in promise: we must produce the SAME BITS as mlx-lm** — the
57
+ original oracle. How the bits are produced (storage, kernels,
58
+ scheduling, engine, language) is the space we compete in.
59
+ 2. **The oracle for ANYTHING is whoever already does that thing.** Want
60
+ mlx-lm's decode → mlx-lm is the oracle. Want optiq's mixed KV → optiq.
61
+ Want vLLM's paged attention → **vLLM is the oracle**: read their
62
+ implementation, copy what they did, verify against them, then optimize.
63
+ This is the standing house method (FaithfulMiniCPM5 = copy mlx-lm;
64
+ mixed KV = copy optiq; batching = copy BatchGenerator; SSD tier = copy
65
+ oMLX). Never frame a capability someone ships as oracle-less. "No
66
+ oracle" means NOBODY does the thing — genuine research — and that, and
67
+ only that, is what the Lab is for.
68
+
69
+ Verification form follows the oracle's framework: same-framework oracles
70
+ (mlx-lm, optiq — same libmlx) admit bit-exact gates; cross-framework
71
+ oracles (vLLM — CUDA/PyTorch) anchor the ALGORITHM and behavior (block
72
+ semantics, sharing correctness, utilization), while rule 1 still binds the
73
+ final output bits in the default regime. "Faithful" kernel-set identity is
74
+ the easiest route to same-bits, not the contract; the one constraint
75
+ bit-exactness puts on implementations is math/reduction order (the perf
76
+ kernel died exactly there).
77
+
78
+ Two consequences drive everything below:
79
+
80
+ - **The baseline must be even and proven before optimizing.** We now have it:
81
+ the L1 faithful path is bit-exact with mlx-lm at 1.00× speed on every
82
+ model. Anything that claims to beat it proves it with a paired A/B on a
83
+ stable pass — otherwise it is noise or a regression (2026-07-05 evidence:
84
+ every output-changing lever failed this bar).
85
+ - **Frontier-shifting work is usually a *quality-per-byte* play, not a raw
86
+ kernel play.** Mixed-precision quantization (weights AND KV) moves memory
87
+ down while holding or raising intelligence — a genuine beyond-the-frontier
88
+ move, unlike micro-kernel wins that trade along it. This is why quantized
89
+ KV stays first-class even though it lost the speed A/B: it buys context
90
+ headroom (12B@16k: −1.3 GB, growing linearly) and mixed schemes buy
91
+ intelligence-per-byte.
92
+
93
+ ## 2. Survey — what the other engines got right (and wrong for us)
94
+
95
+ - **mlx-lm** (`server.py`/`generate.py`): the original oracle and THE
96
+ drop-in target — and the project most similar to ours: it spans layers
97
+ 1+2+3(+4), shipping the reference graphs, native bf16 + uniform 4/8-bit
98
+ quantized KV (`QuantizedKVCache`/`--kv-bits` — layer 2 is theirs too),
99
+ the batched engine, and the server. Its server is **concurrency-driven
100
+ by default** — it auto-creates a `BatchGenerator` (decode-concurrency
101
+ cap 32, prefill concurrency, chunked `prefill_step_size`) and routes any
102
+ batchable request into it; only non-batchable requests (draft models
103
+ etc.) serve sequentially. There is no "batching mode" flag — the cap is
104
+ the only knob. Count-capped prompt cache (the OOM footgun we already
105
+ fixed by byte-capping); no quantization on the batched path.
106
+ → **Adopt: the concurrency-driven default is our drop-in-parity behavior.**
107
+ - **vLLM**: continuous batching + PagedAttention — the KV cache is block-paged
108
+ with a block table per sequence, which is what makes batching, long context,
109
+ and prefix sharing *compose* instead of conflict. No batch flag; `max_num_seqs`
110
+ caps. Chunked prefill interleaves with decode so TTFT doesn't starve running
111
+ streams. → **Adopt (long-term): block-paged KV as the structure that unifies
112
+ batch + quantized KV + prompt cache + SSD tier. Adopt (near-term): the
113
+ no-mode-flag scheduler and prefill/decode interleaving policy.**
114
+ - **llama.cpp** (server): slot-based (`-np` = slot count), one shared KV
115
+ buffer partitioned by sequence — concurrency-driven, no mode flag. KV cache
116
+ default is **f16; quantized KV (q8_0/q4_0) is opt-in** — independent
117
+ confirmation of our bf16-default decision. Its imatrix (importance-matrix)
118
+ quantization is the ecosystem's calibration-driven intelligence-per-byte
119
+ play — the same family as oQ and our knapsack `--target-bpw`.
120
+ → **Adopt: nothing structural we don't have; validates KV defaults; imatrix
121
+ is prior art for the §7 quant program.**
122
+ - **oMLX**: scheduler on mlx-lm's BatchGenerator + product appliances. Already
123
+ systematically mapped (omlx-adoption-map.md): batching parity + SSD tier
124
+ ported and won; burst decode ported and REFUTED (Python-GIL medicine, our
125
+ disease doesn't exist); oQ quant + DFlash queued. → **Keep mining the queue;
126
+ its engine architecture is not ahead of ours.**
127
+ - **mlx-optiq**: the L2 oracle — per-layer mixed KV (`kv_config.json`), fused
128
+ quantized SDPA, vision sidecar. Not a batching engine. → **Its role is the
129
+ oracle for mixed-precision schemes (§5) and the source of the next quant
130
+ ideas (TurboQuant per Josh).**
131
+
132
+ ## 3. The architecture (four layers, each with one job)
133
+
134
+ ```mermaid
135
+ flowchart TB
136
+ subgraph clients["CLIENTS"]
137
+ direction LR
138
+ PI["Pi agent<br/>chat UI + terminal"]
139
+ CLI["mlx-bun CLI<br/>generate · eval · train"]
140
+ API["any OpenAI client"]
141
+ LIB["TS/Bun library import"]
142
+ end
143
+ subgraph surface["SERVING SURFACE — src/server.ts"]
144
+ V1["OpenAI /v1 API"]
145
+ ADMIT["admission control<br/>--memory-budget · --kv-budget"]
146
+ end
147
+ subgraph engine["ENGINE — one loop (Phase 2: concurrency IS the batch size)"]
148
+ SCHED["continuous-batching scheduler<br/>--batch N = cap (default 32 after B=1 gate)"]
149
+ B1["B=1 fast path<br/>pipelined + compiled step (today's serial loop)"]
150
+ BN["B>=2 batched step<br/>extend-join · bit-exact vs mlx-lm at same composition"]
151
+ FEAT["shared, implemented ONCE: prompt cache ·<br/>SSD KV tier · spec decode · grammar"]
152
+ end
153
+ subgraph models["MODEL LAYER — fingerprint-routed, NEVER flag-routed"]
154
+ OPT["optimized-verified graphs<br/>gemma4 12B/e4b/26B · MiniCPM5 · Qwen3/3.5"]
155
+ UNI["universal graph — any HF model, best effort"]
156
+ end
157
+ subgraph numerics["NUMERICS / KV SCHEMES — per-scheme oracles"]
158
+ BF["bf16 (default) — oracle: mlx-lm"]
159
+ UKV["uniform kv 4/8 — oracle: mlx-lm --kv-bits"]
160
+ MKV["mixed kv_config — oracle: mlx-optiq"]
161
+ LAB["LAB: TurboQuant · mixed-bpw weights ·<br/>new kernels — KL + eval gates"]
162
+ end
163
+ HW["libmlx (Metal) via bun:ffi — the only runtime link"]
164
+ PI --> V1
165
+ CLI --> V1
166
+ API --> V1
167
+ LIB --> SCHED
168
+ V1 --> ADMIT --> SCHED
169
+ SCHED -->|1 active| B1
170
+ SCHED -->|2+ active| BN
171
+ SCHED -.-> FEAT
172
+ B1 --> OPT
173
+ BN --> OPT
174
+ B1 --> UNI
175
+ BN --> UNI
176
+ OPT --> BF
177
+ OPT --> UKV
178
+ OPT --> MKV
179
+ UNI --> BF
180
+ MKV -.-> LAB
181
+ BF --> HW
182
+ UKV --> HW
183
+ MKV --> HW
184
+ ```
185
+
186
+ How a model becomes servable, and the promotion path:
187
+
188
+ ```mermaid
189
+ flowchart LR
190
+ Q["mlx-bun serve QUERY"] --> REG["registry (local HF cache)"]
191
+ REG -->|not downloaded| GET["mlx-bun get"]
192
+ GET --> REG
193
+ REG --> FP["config.json fingerprint"]
194
+ FP -->|optimized-verified| OPTF["custom graph for THAT model"]
195
+ FP -->|anything else| UNIF["universal graph"]
196
+ ```
197
+
198
+ ```mermaid
199
+ flowchart LR
200
+ A["runs on universal"] --> B["custom per-model graph<br/>(port the oracle's exact ops)"]
201
+ B --> C["bit-exact goldens vs scheme oracle"]
202
+ C --> D["h2h bench >= 1.00x mlx-lm, stable pass"]
203
+ D --> E["OPTIMIZED-VERIFIED badge"]
204
+ E -.-> F["Lab experiments on top<br/>paired A/B + expiry"]
205
+ ```
206
+
207
+ **The model-layer philosophy (Josh's, verbatim intent):** the universal layer
208
+ is the promise that anything from Hugging Face *runs*; the optimized-verified
209
+ layer is the promise that the models we care about run *better than anywhere
210
+ else*. Promotion from universal → optimized is a defined checklist: custom
211
+ graph file, bit-exact parity goldens vs the scheme oracle, h2h bench entry at
212
+ ≥1.00× vs mlx-lm, eval-suite scores recorded. "Optimized and verified" is a
213
+ badge with teeth, and the badge list is the marketing claim.
214
+
215
+ ### Layer 0 — the SSD spill substrate (Josh, 2026-07-05)
216
+
217
+ Every stage that tried it found the same result: **it is almost always
218
+ faster to cache to SSD and mmap it back than to regenerate, and spilling
219
+ lets RAM be used as a cache instead of the only tier.** Three independent
220
+ implementations already prove the pattern: the SSD KV cold tier (restart
221
+ TTFT 12.1 s → 0.24 s, 0% decode overhead), MoE expert offload (page-aligned
222
+ mmap, phys footprint ≈ active params), and the byte-capped prompt cache.
223
+ Decision: extract the pattern into ONE generic substrate — content-addressed
224
+ keys (model fingerprint + scheme + adapter + producer version), zero-copy
225
+ mmap restore, byte-capped LRU eviction, corruption self-quarantine — that
226
+ layers 1/2/4 consume. Future clients: vision encoder features (oMLX
227
+ adoption-map #6), compiled xgrammar grammars, quantized-conversion results,
228
+ TurboQuant artifacts. `src/ssd-cache.ts` is the seed to generalize.
229
+
230
+ ### The batching workload (Josh, 2026-07-05)
231
+
232
+ The motivating case is THE USER'S AGENT HARNESS: someone points pi / a
233
+ Claude-Code-style coding agent at their local server and spins up
234
+ SUB-AGENTS to work different parts of a project. Serial, each agent queues
235
+ behind all the others — the Nth agent's first token waits for N−1 full
236
+ generations and every agent's loop stalls. Batched, 4–8 agents decode
237
+ simultaneously: each stream is somewhat slower, but nobody waits, every
238
+ agent starts instantly, and the fleet's wall-clock collapses (cpm5: 345
239
+ aggregate at B=4 vs 267 solo → four jobs finish in ~3.1×T with zero queue
240
+ latency, vs 4×T serial with the last agent idle through three
241
+ generations). That is the entire reason a single-person laptop runtime
242
+ carries a continuous-batching engine. Consequences: concurrency-driven
243
+ batching (sub-agents don't coordinate arrivals); **prefix sharing gains
244
+ priority** (sub-agents share long system prompts — one shared KV prefix ×
245
+ N rows); per-slot adapters (different agents, different specializations);
246
+ and batching × quantized KV first (agent fleets want long contexts).
247
+ "Single-user" in this project means single-user, many-agents.
248
+
249
+ ### Layer responsibilities (the contract, 2026-07-05)
250
+
251
+ 1. **Model graph** — one forward step given hidden + caches; attention
252
+ dispatches on the CACHE TYPE it is handed (bf16 → ops.sdpa, quantized →
253
+ quantizedSdpa); shape-generic in B; selected by fingerprint, never flags.
254
+ *Responsibility: numerics of a step, for any B, for whatever cache it's
255
+ given.*
256
+ 2. **KV scheme (cache classes)** — the memory format and its semantics:
257
+ bf16 / uniform / mixed per-layer; conversion timing (bf16 prefill →
258
+ quantize populated rows — the optiq hook semantics); in batched form the
259
+ multi-row layout (padding, per-row rope, merge/extract/extend/filter).
260
+ *Responsibility: the scheme PER ROW — a row's bytes and math identical
261
+ solo or batched. This is where each scheme's oracle binds.*
262
+ 3. **Engine (scheduler)** — rows: admission, solo prefill, join/merge, the
263
+ step loop, per-row sampling, eviction, pipelining, the concurrency cap.
264
+ Never implements scheme math; calls layer-2 operations.
265
+ *Responsibility: how many rows and when — never what a row's bytes mean.*
266
+ 4. **Serving surface** — endpoints, per-request features (adapters,
267
+ grammar, sampling, spec policy), prompt cache (keyed fingerprint +
268
+ effective scheme), admission budgets. *Responsibility: requests, not
269
+ tensors.*
270
+
271
+ **Features decompose ALONG the layers (that's the model working, not
272
+ leaking).** Per-request LoRA is the worked example: layer 1 = the math (a
273
+ parameterization — `Wx + scale·B(Ax)` — oracle-gated vs mlx-lm's tuner);
274
+ layer 3 = the policy (which rows run which adapter; today one active
275
+ adapter per batch, per-slot adapters are the matrix item); layer 4 = the
276
+ surface (`adapter` field, mount/hot-swap, discovery); plus one sideways
277
+ obligation — KV under adapter A ≠ base KV, so the prompt cache keys by
278
+ adapter (the SSD tier already does). When placing a feature, name its
279
+ component at each layer and each component's gate.
280
+
281
+ **Sampling's placement (and the debranching invariant).** The model
282
+ graph's contract ENDS AT LOGITS. Sampling is *specified* at layer 4 (the
283
+ request's params become a per-row sampler closure) and *executed* at
284
+ layer 3 (each step, on each row's [1,V] slice; vectorized when all-greedy)
285
+ — it never enters layer 1. This is the load-bearing placement behind the
286
+ layer-1 debranching goal (Josh: the generated file pre-decides all ops and
287
+ shapes; the path is a serial line with no branchable checks): everything
288
+ per-request-variable is either POST-GRAPH (sampling, grammar masks, stop
289
+ logic) or DATA the fixed graph consumes (adapter weights, caches). Mixed
290
+ adapter/no-adapter batches stay branch-free via vLLM's Punica/SGMV pattern
291
+ — every row runs the adapter math, non-adapter rows at scale 0; data, not
292
+ control flow (vLLM = the oracle for per-slot LoRA batching). Debranching
293
+ backlog: the per-token runtime checks still in generated files (`L > 1`,
294
+ `fusedSdpaRuntimeOk`) are per-GENERATION-decidable → hoist them out of the
295
+ token path; compiled decode already achieves the fully-debranched line
296
+ dynamically (trace once, replay).
297
+
298
+ **The option surface IS the four layers.** Users adjust: (1) how the model
299
+ graph works — universal vs optimized file, compiled decode, future
300
+ unrolled/fused variants; (2) how the quantization works — weights bpw,
301
+ KV scheme, future TurboQuant; (3) how the scheduler works — concurrency
302
+ cap, KV budget, prefill chunking, spec policy; (4) the serving surface —
303
+ endpoints, adapters, structured output, sampling defaults, caches,
304
+ multi-model. Flags, help text, and reference docs should be ARRANGED in
305
+ these four groups.
306
+
307
+ **Composition truth (2026-07-05, source-verified to the line AND
308
+ empirically proven live):** "does mixed-precision KV work with batched
309
+ requests?" — optiq serve as shipped: **NO** (install_mixed_kv hooks
310
+ stream_generate, the serial path; mlx_lm.server 0.31.3 contains zero
311
+ quantization in its 1,904 lines and BatchKVCache has no to_quantized —
312
+ batchable requests decode bf16). **Live 2×2 proof** (optiq serve, cpm5,
313
+ 9,132-token prompt, 128-tok decode, fresh server per arm; seeded requests
314
+ route serial per mlx-lm's `_is_batchable`): batched path decode is
315
+ IDENTICAL with and without --kv-config (120.5 vs 120.6 tok/s — the config
316
+ is inert there), while the serial path drops 148.7 → 32.7 tok/s when the
317
+ mixed hook engages (4.5× slower at this context in the shipped python
318
+ pipeline). So optiq's composed reality today: batched requests silently
319
+ bf16; quantized requests pay a heavy serial penalty. Phase 3.1's batched
320
+ mixed-KV at full engine decode speed is an uncontested frontier point. mlx-bun
321
+ today: **NO** (willBatch routes shape.kvQuant serial). mlx-bun after Phase
322
+ 3.1: **YES — the build**, and a first on this stack. In layer terms the
323
+ limitation is purely layer 2: the quantized schemes only have single-row
324
+ implementations, so layer 3 has nothing to call — not an engine limitation,
325
+ not a model limitation. Phase 3.1 = give layer 2 batched quantized caches
326
+ (copy the serial per-row ops that already bit-match optiq), teach layer 3 to
327
+ call them at join/evict, hand layer 1 what it already dispatches on, then
328
+ delete `shape.kvQuant` from willBatch — that deletion IS the feature.
329
+ Bonus finding from the same review: optiq's Mac-safe concurrency default is
330
+ **8** (they warn mlx-lm's 32 OOMs unified memory under burst) — adopt 8 as
331
+ the `--batch` default, superseding the earlier 32.
332
+
333
+ ## 4. The engine: concurrency IS the batch size
334
+
335
+ **Decision (Josh, 2026-07-05): batching is determined by how many concurrent
336
+ requests are in flight, not by a flag.** One request = batch of 1 at full
337
+ serial speed; N requests = continuous batching; requests join/leave running
338
+ batches (the extend-join machinery already landed and is token-exact vs
339
+ mlx-lm B=2).
340
+
341
+ This **reverses** batching-v2-plan.md's standing decision (`--batch N` as a
342
+ mode switch, auto-batching rejected for determinism). The old objection was
343
+ "an idle-vs-loaded server produces different numerics for the same request."
344
+ It gets a real answer, not a shrug:
345
+
346
+ 1. **The L1 oracle itself behaves this way.** mlx_lm.server auto-batches by
347
+ default; "drop-in for mlx-lm" now *requires* concurrency-driven batching.
348
+ Our parity contract becomes: **bit-exact to mlx-lm at the same batch
349
+ composition** — already golden-verified at B=1 and B=2.
350
+ 2. Anyone who needs load-independent numerics pins the cap to 1 (see below).
351
+ 3. **Batch-invariant kernels** (identical numerics regardless of B — the
352
+ Thinking-Machines-style program) go to the Lab as a research item; if that
353
+ ever lands, determinism-under-load comes back for free and it's an
354
+ arXiv-lens result on Apple Silicon.
355
+
356
+ **Flag decision (Josh, 2026-07-05): the flag stays `--batch` — no rename.**
357
+ Its *semantics* change from mode switch to cap: `--batch N` = maximum
358
+ concurrent rows (mlx-lm's `--decode-concurrency` twin), **default 32**, and
359
+ `--batch 1` is the force-serial / determinism pin. TIMING: the 32 default
360
+ lands **with Phase 2, not before** — flipping it while the batched lane is
361
+ still 1.8× slower at B=1 would regress every single-request user ~44%. Until
362
+ GATE-B1-SPEED passes, `--batch` keeps today's default (1 = the serial lane).
363
+
364
+ **The crux gate — the B=1 gap.** Today the batched lane at B=1 runs cpm5 at
365
+ ~149 tok/s vs 267 serial (batching-perf-path.md): the unified engine is
366
+ **1.8× off** at the composition that matters most for a single-user runtime.
367
+
368
+ **Measured 2026-07-05 (paired in-process A/B, cpm5, M1 Max): mlx-lm's own
369
+ `BatchGenerator` at B=1 runs 256.5 tok/s vs 264.6 for its `stream_generate`
370
+ — a 3.3% tax on the most host-tax-sensitive model we have.** Two
371
+ consequences: (1) the unified design is *proven achievable* — a continuous-
372
+ batching loop can serve a lone request at ~0.97× of the best serial loop,
373
+ so our lane's 1.8× gap is an implementation artifact, not an inherent cost
374
+ of the architecture; (2) our h2h 1.00× parity numbers were measured against
375
+ `stream_generate` (mlx-lm's FASTEST single-stream path), so the baseline we
376
+ match is not batched-handicapped — and `mlx_lm.server` actually serves a
377
+ single request ~3% below the number we already match. The serial lane
378
+ cannot be deleted until:
379
+
380
+ - `GATE-B1-SPEED`: unified engine at B=1 ≥ 99% of today's serial decode tok/s
381
+ and TTFT on cpm5 + e4b + 12B (stable bench pass), and
382
+ - `GATE-B1-PARITY`: unified engine at B=1 bit-exact vs the L1 goldens (full
383
+ logprobs, not just greedy).
384
+
385
+ How to close 1.8×: the serial lane's wins are known and portable — the
386
+ pipelined decode loop (async-eval overlap), compiled per-step graph replay,
387
+ and no per-token scheduler hops. The likely landing: **the scheduler owns
388
+ admission and join/leave; when exactly one sequence is active it executes the
389
+ same pipelined/compiled step the serial path runs today** (B=1 specialization
390
+ inside one engine — a fast path, not a second lane), falling back to the
391
+ general batched step at B≥2. Same public machinery, no gateway routing rules,
392
+ no feature×lane matrix.
393
+
394
+ **COMPOSITION IS THE PRODUCT (Josh, 2026-07-05).** The goal state, verbatim
395
+ intent: *"you start with the server, it serves an optimized version of the
396
+ model, you add on top of that mixed-precision quantized KV cache, as well as
397
+ LoRA adapters, and you add on top of that DSpark speculative decoding, and
398
+ structured output potentially, and then you also can switch the sampling
399
+ method and values."* Every serving capability is a STACKABLE layer on one
400
+ engine — never a lane-routing condition, never mutually exclusive. The
401
+ current gateway is the anti-pattern: `willBatch` routes vision / adapters /
402
+ logprobs / seeds / kv-quant / drafts to a different lane, which is exactly
403
+ the "batching OR performance" choice being killed.
404
+
405
+ The composition matrix the unified engine must satisfy (each row = a layer;
406
+ any subset of rows must stack):
407
+
408
+ | layer | today | end state |
409
+ |---|---|---|
410
+ | optimized per-model graph | ✅ both lanes | ✅ (fingerprint-routed, unchanged) |
411
+ | mixed-precision quantized KV | serial only | one active scheme per server, applies at any B |
412
+ | LoRA adapters (hot-swap, per-request select) | serial only (compiled decode also skips them) | per-slot adapter state in the batched step |
413
+ | speculative decoding (two-model + assistant + DSpark, 2026-07-06) | serial only, forces ALL requests serial; the `DraftSource` seam is now KV-borrowing-ready (target donor-KV / anchor-hidden / tapped H_ctx flow through it — the per-slot substrate is in place) | per-slot drafting behind the `DraftSource` seam |
414
+ | structured output (grammar) | ✅ both lanes (B1 per-row matchers) | ✅ (already composes; keep the conformance gate) |
415
+ | sampling method + values (temp/top-p/top-k/min-p/XTC/penalties/HLG/seed) | per-row samplers batch; seeds force serial | fully per-row, seeds included |
416
+ | prompt cache / SSD tier | serial only | prefix reuse for batched rows |
417
+
418
+ Ordering (by value): quantized KV under batching first (the
419
+ agent-fan-out-with-long-context composition), then prompt cache, then
420
+ adapters, then spec decode per slot, then chunked prefill interleaving
421
+ (vLLM policy) so a 16k prefill doesn't stall running streams.
422
+ `scripts/bench-modes.ts` is already the composition scoreboard (its cell
423
+ matrix spans kv × batch × grammar × cache × spec) — every layer added to
424
+ the batched engine gets a cell there and a parity gate.
425
+
426
+ ## 5. Fidelity: per-SCHEME oracles (fixes "L1 can't oracle mixed")
427
+
428
+ The tier ladder stops being a product mode and becomes what it always really
429
+ was — a **map from numeric scheme to its verification oracle**:
430
+
431
+ | scheme | oracle | verification |
432
+ |---|---|---|
433
+ | bf16 (default), any batch B | **mlx-lm** at same composition | bit-exact goldens (serial + B=2 landed) |
434
+ | uniform kv 4/8 | **mlx-lm** `--kv-bits` (+ optiq's rotating cache class where upstream is NYI) | bit-exact (kv-quant.test) |
435
+ | mixed per-layer kv (`kv_config.json`) | **mlx-optiq** `install_mixed_kv` | bit-exact (mixed-kv-parity.test, landed 2026-07-05, maxDiff 0) |
436
+ | mixed-precision weights (knapsack/oQ/TurboQuant), original kernels, batch-invariant kernels | **none exists** → Lab gates | KL vs our own reference path + frozen eval suite (intelligence axis) + envelope tests + kill switch |
437
+
438
+ So: mixed-precision quantization is NOT demoted by the naked-=-L1 decision —
439
+ it is the flagship of the row that mlx-lm cannot oracle, verified against
440
+ optiq where optiq reaches (KV) and against the eval suite where no oracle
441
+ exists (weights). `--l1/--l2` survive as *bench/test vocabulary* for the
442
+ first three rows; they stop being user-facing product modes.
443
+
444
+ **Composition rule (Josh, 2026-07-05): a composition inherits its scheme's
445
+ oracle.** Batching, prompt cache, spec decode, adapters — none of them
446
+ change a scheme's numerics, so composing them with a scheme verifies
447
+ against THAT SCHEME'S oracle, per row. Batched bf16 anchored to mlx-lm
448
+ (B=N token-exact goldens); batched mixed-KV anchors to the L2 oracle the
449
+ same way: copy optiq's per-row quantization mechanics (`install_mixed_kv`'s
450
+ per-layer map + streaming `to_quantized` + RotatingQuantizedKVCache — our
451
+ serial `maybeQuantizeKv` is already the verified op-for-op copy), then
452
+ gate on per-row bit-exactness against it (row cache contents; B=1 through
453
+ the engine vs the mixed-KV golden; B>1 trajectories at the standing
454
+ long-prefix bar). Do NOT invent Lab-style KL/teacher-forced gates for a
455
+ composition of an oracle-backed scheme — the Lab is only for schemes with
456
+ no oracle at all (new quant methods, original kernels).
457
+
458
+ ## 6. The flag surface (end state — every flag with its reason)
459
+
460
+ User-facing serve/generate flags after the deletion pass:
461
+
462
+ | flag | why it exists |
463
+ |---|---|
464
+ | `--kv-quant config\|4\|8\|off` | the ONE performance trade-off a user makes: decode speed vs context headroom (+ intelligence-per-byte with mixed). Default off (bf16). Composition (fused vs unfused quantized SDPA) is DERIVED from the scheme's oracle, not chosen. |
465
+ | `--batch N` | cap on concurrent rows (mlx-lm's `--decode-concurrency` twin). Default 32 once Phase 2's B=1 gate passes; `--batch 1` = force-serial / determinism pin. Name kept (Josh 2026-07-05) — semantics change from mode to cap. |
466
+ | `--draft-model`, `--num-draft-tokens` | speculative decoding (mlx-lm parity) |
467
+ | `--adapter`, `--memory-budget`, `--kv-budget`, `--prompt-cache`, `--ssd-cache`, `--expert-offload`, `--force-wire` | capacity/serving features, not numerics levers |
468
+ | sampling flags (`--temperature` etc., HLG family) | request-default sampling |
469
+
470
+ Everything else becomes either a **kill switch** (env-only, documented for
471
+ debugging: `MLX_BUN_COMPILED_DECODE=0`, `MLX_BUN_COMPILED_GEGLU=0`,
472
+ `MLX_BUN_NO_FUSED_SDPA=1`) or a **Lab flag** (env-only, lives with its bench
473
+ script and expiry — §7). Kill switches are bit-exact by definition (they
474
+ select a slower same-parity path); anything output-changing is Lab.
475
+
476
+ **Deleted — EXECUTED 2026-07-05** (one funeral each; ~50 files touched, 23
477
+ deleted, suite green):
478
+ - `--fused-decode` / `MLX_BUN_FUSED_DECODE` — 1.00×, forces uncompiled,
479
+ silent-wrongness footgun. Delete kernel path + flag + backstop throw.
480
+ - `--fused-gelu` / `MLX_BUN_FUSED_GELU` + `MLX_BUN_FUSED_SWIGLU` — +0–1%;
481
+ the compiled closures already own the fusion win bit-exactly. Delete both
482
+ custom Metal kernels + flags (git history keeps the source).
483
+ - `--perf-kernel` / `MLX_BUN_PERF_KERNEL` — **DELETE** (Josh 2026-07-05:
484
+ "we are going to start a full optimization program anyway… we will end up
485
+ redoing all the work we have currently done given that we now have a
486
+ different starting point"). The kernel, its frozen-oracle test scaffolding
487
+ (perf-kernel-oracle.test.ts, freeze-perf-oracle.ts), and the flag all go;
488
+ git history + the h2h evidence (+6% 12B@16k, −38% e4b@16k, KL WARN) are
489
+ the breadcrumb. If a flash-decode kernel returns, it's re-derived from the
490
+ L1 baseline in the Lab under the §7.4 program, not resurrected.
491
+ - `MLX_BUN_CPM5_FAITHFUL` + `FaithfulMiniCPM5` — the default IS the faithful
492
+ path now; the A/B reference served its purpose.
493
+ - `--l3` as a product mode — the Lab replaces it; the flag now hard-errors
494
+ with a pointer here. `--l1`/`--l2` stay as documented aliases.
495
+ - Also deleted with the fused-swiglu family: `fused-mlp-kernel.ts` and
496
+ `steel-linear-kernel.ts` (only reachable through the deleted gates) and
497
+ the ~17 `scripts/experiments/swiglu-*`/geglu/steel one-off scripts.
498
+ - Training flag sanitization (`MLX_BUN_PERF_KERNEL=0` / `MLX_BUN_FUSED_GELU=0`
499
+ in trainers/launchers/recipes) — obsolete; the no-vjp kernels it guarded
500
+ against no longer exist.
501
+
502
+ **Lab lifecycle rule** (the anti-flag-pile law): every Lab experiment ships
503
+ with (a) a hypothesis stated in frontier-axis terms, (b) a paired A/B bench
504
+ script, (c) an expiry review date. Outcomes: promote to default (with the
505
+ stable-pass numbers) or delete (with a breadcrumb in the design doc). No
506
+ third state. "Off in every regime with no promotion path" — the fused-decode
507
+ condition — is a deletion that hasn't happened yet.
508
+
509
+ ## 7. The frontier program (AFTER stability — the order matters)
510
+
511
+ Phase gate: none of this starts until §8 phases 0–2 are done. "Now we
512
+ finally have a baseline that is even and proven; investigate THAT."
513
+
514
+ Ranked by expected frontier shift (memory ↓ / intelligence ↑ first, then
515
+ speed):
516
+
517
+ 1. **Mixed-precision weights** — knapsack `--target-bpw` (ours) vs oQ
518
+ (calibration-driven sensitivity, omlx-adoption-map §4) vs llama.cpp
519
+ imatrix as prior art. Gate: perplexity + frozen 6-task eval at equal bpw.
520
+ The purest beyond-the-frontier play we know: same memory, more
521
+ intelligence. (arXiv-lens candidate.)
522
+ 2. **TurboQuant KV** (Josh) — ORTHOGONAL to mixed precision and composes
523
+ with it: mixed precision is the ALLOCATION axis (how many bits per
524
+ transformer layer, optiq's sensitivity map), TurboQuant is the
525
+ QUANTIZER axis (how a given bit budget encodes each K/V vector, within
526
+ a layer — rotation-based, online, calibration-free). Composition =
527
+ sensitivity allocation × better quantizer: more fidelity at the same
528
+ KV bytes. Architecture: a layer-2 cache format + a layer-1 attention
529
+ kernel keyed by cache type (same shape as optiq's scheme). Oracle: the
530
+ paper + its reference implementation anchor the ALGORITHM (read them
531
+ first, per the oracle rule); the output is genuinely novel (no serving
532
+ stack ships it) → eval-suite gates. Composes with Phase 3.1's batched
533
+ quantized buffers.
534
+ 3. **Speculative decoding depth** — DFlash/DSpark behind the existing
535
+ `DraftSource` seam; decode tok/s at zero quality cost when drafts land.
536
+ 4. **Per-model graph work from the baseline** — the optimized-verified
537
+ layer's whole point: unroll a model's flat DAG, find fusion the compiler
538
+ misses, prove with kernel-trace diffs, promote per model. The perf-kernel
539
+ root-cause lives here.
540
+ 5. **Batch-invariant kernels** (research) — restores determinism-under-load;
541
+ novel on Apple Silicon.
542
+
543
+ ## 8. Migration phases (each with a hard gate)
544
+
545
+ - **Phase 0 — measure the gap** — **DONE 2026-07-05** (M1 Max, same server
546
+ harness both lanes, median-of-5, bf16 KV):
547
+
548
+ | model | serial B=1 | batch-lane B=1 | ratio | extra host ms/token | TTFT serial→batch |
549
+ |---|---|---|---|---|---|
550
+ | cpm5 | 281.4 tok/s | 128.8 | 0.46× | +4.2 ms | 44 → 55 ms |
551
+ | e4b | 62.1 | 44.9 | 0.72× | +6.2 ms | 54 → 121 ms |
552
+ | 12B | 29.8 | 25.6 | 0.86× | +5.5 ms | 82 → 251 ms |
553
+
554
+ The overhead is a roughly CONSTANT ~4–6 ms per decode step, independent of
555
+ model size → per-step host tax, not GPU work. cpm5 (which never uses
556
+ compiled decode) pays it too, so compiled-decode's absence is not the
557
+ story. Suspects (in dispatch-site order): the `setImmediate` macrotask hop
558
+ per drive-loop iteration, per-step per-layer `BatchedDecodeMaskCache`
559
+ construction + mask materialization at B=1 (serial passes caches bare with
560
+ an empty mask), and the per-token emit path. TTFT gap = solo-prefill +
561
+ merge + admission hops. Closure worklist = profile these three, in order.
562
+ - **Phase 1 — the deletion pass** (§6): dead kernels, flag surface, docs,
563
+ Lab scaffolding (move perf-kernel + its bench + oracle tests). Gate: full
564
+ suite green; naked defaults byte-identical to today's L1 route.
565
+ - **Phase 2 — engine unification** — **DECODE GAP CLOSED 2026-07-05.** The
566
+ ~4–6 ms/step host tax decomposed into exactly two bugs, both fixed:
567
+ 1. **The pipelined token read enqueued an `astype` kernel** —
568
+ `prev.toFloat32()` on the int token array created a NEW cast op that
569
+ queued BEHIND the entire just-dispatched next step on the GPU stream,
570
+ stalling the "overlapped" read for a FULL step of GPU work every
571
+ token (this alone was the bulk of the gap; zero host/GPU overlap,
572
+ proven by NO_PIPELINE ≈ pipelined). Fix: `MlxArray.toIntTokens()` —
573
+ eval + raw uint32/int32 buffer read, no cast (mirrors serial's
574
+ `itemUint32`).
575
+ 2. **Per-layer per-step mask/rope wrapper churn**: `#step` rebuilt a
576
+ `BatchedDecodeMaskCache` + array mask for every full layer every
577
+ token even when no row had left padding. Fix: the unpadded fast path —
578
+ bare caches (KVCache.makeMask(1) = the empty mask, scalar rope) →
579
+ the batch step dispatches the SAME graph the serial loop builds.
580
+
581
+ Measured after (B=1 through the batch lane, same SSE harness as Phase 0):
582
+ cpm5 129→**264** (serial 281 SSE / in-process ratio **0.994**), e4b
583
+ 45→**57.6** (0.93 — remainder = compiled decode, serial-only today),
584
+ 12B 25.6→**29.7** (**1.00**). Batched parity oracles green (11/11
585
+ applicable; the CPM extend-join golden failure PRE-EXISTS this work —
586
+ proven by stash/rerun — and is logged as an open item).
587
+ **Remaining before the `--batch` 32 default flip**: compiled decode
588
+ inside the B=1 step (e4b's last 7%), prompt cache for batched rows
589
+ (TTFT: serial hits the cache on repeat prompts, batch re-prefills —
590
+ e4b 126 vs 54 ms, 12B 246 vs 82 ms), then GATE-B1-SPEED ≥99% on all
591
+ three + GATE-B1-PARITY (bit-exact vs L1 goldens) + B=2 goldens intact.
592
+ - **Phase 3 — composition**: quantized KV under batching — **P1 LANDED
593
+ 2026-07-05**: src/model/batched-quant.ts (merge/extend/filter over
594
+ triples + BatchedQuantDecodeMaskCache), scheduler solo-conversion at the
595
+ serial chunk boundaries, gateway kv-batchability memo (all-full-attention
596
+ kvConfig batches; uniform/rotating stays serial; scheme-less gateway
597
+ refuses — never silently drop quantization, the optiq bug class). Gates
598
+ green: B=1 through the scheduler BIT-EXACT vs the cpm5 optiq golden
599
+ (decode steps, maxDiff 0); B=2 dynamic join — unpadded row BIT-EXACT vs
600
+ solo at every step, padded row within the calibrated envelope (5e-2;
601
+ bf16 same-harness shows ~9e-3 — the pre-existing reduction-order noise,
602
+ amplified by grid snapping; scripts/experiments/batched-quant-kl-profile
603
+ .ts). E2E: --batch 2 --kv-quant config on cpm5 = /stats active_rows 2,
604
+ 400 tok @ 240 tok/s aggregate, coherent output. NEXT: milestone 2
605
+ (batched rotating-quant for gemma configs), prompt-cache reuse for
606
+ batched rows, chunked prefill interleaving. Gate per feature: parity +
607
+ no aggregate-throughput regression at B=4.
608
+ - **Phase 3.2 — lone-request = serial (LANDED 2026-07-05)**, three moves:
609
+ 1. **Adopt-don't-copy**: a row joining an EMPTY batch keeps its solo
610
+ caches as the inners (pointer handoff; the merge copy now runs only
611
+ when a second row actually joins — incl. a new merge branch that
612
+ treats an adopted serial RotatingKVCache as the first row). The
613
+ lone row's caches stay SERIAL-CLASS, which unlocks the next two.
614
+ 2. **Compiled decode at B=1**: the scheduler replays the serial
615
+ engine's CompiledDecode step when B=1 ∧ unpadded ∧
616
+ supports(inners) ∧ uint32 pipeline register (same runner, same
617
+ traces, same MLX_BUN_COMPILED_DECODE kill switch; falls back +
618
+ disables on error like generate()). Gate: free-running greedy B=1
619
+ == serial generate() token-for-token on 12B with
620
+ CompiledDecode.stepsExecuted advancing (tests/batch-scheduler).
621
+ 3. **Prompt cache on the batch lane**: joiners take() the longest
622
+ usable prefix at admission (suffix-only prefill — multi-turn chat
623
+ TTFT); rows finishing NEVER-MERGED put() back (exact prompt+fed
624
+ accounting, offset-checked; reject/batch-drop paths poisoned; SSD
625
+ retain hooks ride every disposal path). Gate: second identical
626
+ request reports cached_tokens=prompt-1, step-0 argmax anchored,
627
+ KL≤1e-2 (tests/batch-scheduler "prompt-cache reuse").
628
+ **Measured (apple-m1-max/32GB, paired in-process A/B, best-of):
629
+ B=1-through-batch-lane / serial = cpm5 0.996, e4b 0.992 (was 0.93 —
630
+ compiled closed the last 7%), 12B 0.993 → GATE-B1-SPEED decode met on
631
+ all three.** All gated suites green (scheduler CPM+12B, quant gates 1+2,
632
+ full per-file model-free suite 0 fail).
633
+ Findings: (a) gate 2's padded-row KL is JOIN-STEP dependent (grid-snap
634
+ bin flips compound: K=5→4.5e-2, 6→3.5e-2, 7→1.5e-1, 8→1.3e-1; bf16
635
+ ≤9e-3 at every K; identical on pre-3.2 main; extend vs re-merge
636
+ byte-identical) — the gate now PINS the join step and calibrates there;
637
+ padded-row KL is an envelope, the contract is the unpadded row's
638
+ bit-exactness + model-free triple-surgery byte-identity. (b) v1 gap:
639
+ an entry take()n by a row that later MERGES is consumed, not re-put —
640
+ fix is a zero-copy view snapshot at merge (serial's boundary-snapshot
641
+ trick); noted for milestone 2. (c) monolithic `bun test` can jetsam on
642
+ a busy 32GB box (largestProcess=bun) — per-file loop is the fallback
643
+ gate; not a repo defect.
644
+ **Remaining before the `--batch` default flip (Josh's call)**:
645
+ milestone 2 batched rotating-quant, then flip.
646
+ - **Phase 3 milestone 2 — batched ROTATING-quantized KV (LANDED
647
+ 2026-07-05)**: src/model/batched-rotating-quant.ts —
648
+ BatchedRotatingQuantCache subclasses RotatingQuantizedKVCache (attention
649
+ dispatch untouched) and runs the mlx-lm BatchRotatingKVCache ring
650
+ mechanics over (packed, scales, biases) triples; RotatingQuantizedKVCache
651
+ gains temporalView(); scheduler #quantizeSolo converts rotating layers
652
+ (maybeQuantizeKv dispatch) and #mergeJoiner grows a rot-quant twin
653
+ branch; gateway #kvBatchable accepts configs naming rotating layers →
654
+ **gemma's whole kv_config batches** (every shipped kv_config now does).
655
+ Gates: model-free byte-identity per row vs the serial oracle through
656
+ ring wrap, B=1+B=2+filter (tests/batched-rotating-quant.test.ts); gemma
657
+ 12B B=2 dynamic join through the real scheduler — unpadded row KL-0 at
658
+ EVERY step, padded row ≤4e-3 (tests/batched-kv-quant-parity.test.ts).
659
+ **Findings (hard-won, 2026-07-05)**: (a) THE ROPE-ARRAY STEP-STABILITY
660
+ CONTRACT — a batched cache's ropeOffsetArr must be STABLE within a
661
+ decode step and refresh only at releaseRopeArr(): the monolith CAPTURES
662
+ it pre-update and ropes Q post-update (in-update dispose = use-after-
663
+ dispose), while the GENERATED specializations RE-READ it for Q
664
+ post-update (in-update refresh = K and Q roped one position apart →
665
+ O(10) KL garbage). (b) GENERATED-FILE GUARDS ACCEPT BATCHED SUBCLASSES:
666
+ the generated forwards' per-layer `instanceof` cache guards pass any
667
+ batched cache that subclasses a serial class — an all-quant gemma batch
668
+ therefore decodes through the GENERATED fast path (a feature — B=1
669
+ twin proved BIT-EXACT through it — but a contract: batched caches must
670
+ behave serially under re-reads). This is why the bug only appeared when
671
+ EVERY layer was configured: one bf16 layer anywhere fails the guard and
672
+ drops to the monolith. Diagnosis artifacts:
673
+ scripts/experiments/gemma-rotquant-kl-profile.ts (CONFIG_FILTER/
674
+ ROT_KEEP/ROT_DROP/ROT_IDX bisection) and gemma-twin-b1-repro.ts
675
+ (TWIN_MASK/TWIN_ROPE toggles — rope isolated in two runs once the
676
+ B=1 repro existed).
677
+ - **Layer 0 — the SSD tier becomes a property of the store (LANDED
678
+ 2026-07-05)**: PromptCache gains a structural ColdTier and runs the
679
+ two-tier take (RAM peek vs cold find → zero-copy restore + trim)
680
+ INSIDE take(), so the batch scheduler restores prefixes from disk at
681
+ admission with zero scheduler changes (gated E2E on cpm5: demote →
682
+ disk → batch-lane restore, cached=prompt−1, KL 1.5e-3); onPut drives
683
+ the write-behind snapshot for both lanes; NEW idle demotion
684
+ (--ssd-demote-idle, default 300 s with the tier) spills idle entries
685
+ and FREES their GPU memory — RAM drains between agent bursts, every
686
+ prefix stays reachable. Also fixes a latent flaw: the old serial flow
687
+ trimmed the RAM entry before deciding the SSD copy won. The economics
688
+ (Josh): the SSD competes with RECOMPUTE, not RAM — 12B prefill 168
689
+ tok/s ⇒ a 30k agent context is ~3 min of prefill vs ~1 s of NVMe
690
+ restore, and the ratio grows with context. Follow-ups noted: extract
691
+ the generic blob-store mechanics (fingerprint dirs, atomic writes,
692
+ scan/recovery, LRU cap) for a second client when one lands (adapters,
693
+ expert offload); memory-pressure-triggered demotion (vs idle-only).
694
+ - **Prefix sharing v1 (LANDED 2026-07-05)**: PromptCache.take() is
695
+ NON-CONSUMING — a hit serves zero-copy CLONES (cloneKvCaches views,
696
+ trimmed to the matched prefix, retain ref-counted so a demoted/evicted
697
+ donor can never unmap pages a clone still reads) and the donor entry
698
+ stays put. Safe by mlx's functional cache updates (updateAndFetch
699
+ reassigns fresh arrays; donation needs refcount 1 and the donor holds a
700
+ ref), so clones can never mutate donors. put() supersedes same-ns
701
+ prefix-ancestors AND exact duplicates when the new entry is trimmable
702
+ (untrimmable extensions preserve their prompt-boundary snapshots — the
703
+ drift-proof server). This kills the CANNIBALIZATION flaw: agent B
704
+ borrowing agent A's 2k system prompt used to consume-and-trim A's 10k
705
+ entry to serve it. Gates: 22 unit tests (donor persistence, clone
706
+ independence, supersede rules, ref-count ordering) + real-model
707
+ scheduler gate: B served from A's entry (cached=|SYS|, KL 1.8e-3), A's
708
+ NEXT TURN still hits its full entry. New-session and restart sharing
709
+ fall out of the (already non-consuming) SSD tier. **Scope, honestly**:
710
+ v1 shares the COMPUTE (one prefill serves N agents) and the durability;
711
+ concurrent batch rows still hold separate physical KV copies — one
712
+ shared physical prefix across rows is the paged/block-KV frontier item
713
+ (§9.4), and per §9.5 the disk tier's whole-entry duplication of shared
714
+ prefixes is the block-granularity revisit trigger.
715
+ - **Runtime isolation P1 (LANDED 2026-07-05, opt-in `--isolate`)**: the
716
+ engine child = the ENTIRE existing server on a unix domain socket; the
717
+ parent = a thin reverse proxy (src/serve/isolate.ts) — zero MLX calls,
718
+ crash isolation (502 + auto-respawn with crash-loop backoff), abort
719
+ propagation, GET-retry across respawn. Decision recorded in
720
+ runtime-isolation.md: proxy-the-whole-server instead of the original
721
+ gateway-as-IPC-client sketch (grammar WASM/vision arrays/sampler
722
+ closures don't serialize; the inter-process API is the /v1 surface
723
+ itself — no second protocol). Measured paired (M1 Max, cpm5): proxied
724
+ 197.2 vs direct 197.9 tok/s (−0.4%, noise), TTFT +2 ms, SSE per-token
725
+ granularity preserved (96 reads/96 tokens), parent 0.6 ms mid-decode
726
+ (criterion <50 ms). Gates: tests/isolate-proxy.test.ts (model-free fake
727
+ engine: passthrough, granularity, abort, crash→respawn) +
728
+ tests/isolate-e2e.test.ts (real cpm5 child). Prefill chunk loops also
729
+ gained macrotask yields (the last isolation-Phase-1 item). Not proxied
730
+ v1: /ws/chat (501). NEXT: P2 = child-per-model pool — multi-model
731
+ switching by SPAWN-OVERLAP (new child loads while the old one serves;
732
+ drain → demote-to-SSD → park; route by the request `model` field);
733
+ P3 = GPU-lease unification with quantize/finetune jobs.
734
+ - **Runtime isolation P2 — CHILD-PER-MODEL POOL (LANDED 2026-07-05; task
735
+ #14 multi-model switching)**: ModelPool in src/serve/isolate.ts — LRU
736
+ residency over engine children (--model-pool, default 1), request
737
+ routing by the JSON `model` field on the generation endpoints (STRICT
738
+ exact-/v1/models-id resolution: fuzzy strings like "gpt-4" keep
739
+ mlx-lm's ignored-field drop-in semantics), spawn-overlap switching (the
740
+ new model health-gates while the old serves), and graceful lossless
741
+ eviction: POST /admin/drain (engine-mode-only endpoint: gateway
742
+ quiesce + demoteIdle(0) → the whole prompt cache to that model's SSD
743
+ fingerprint dir) then exit. engineArgvForModel pins --model per child.
744
+ **Measured (M1 Max, cpm5⇄qwen0.8b, pool 1): switch 1.5 s · switch-back
745
+ 1.2 s · cached_tokens 103/104 on return** — the conversation's KV
746
+ survived full model eviction and restored from disk instead of
747
+ re-prefilling (tests/isolate-switch-e2e.test.ts). Model-free pool gates
748
+ (routing, drop-in ignore, cap-1 drain→demote→evict→respawn):
749
+ tests/isolate-proxy.test.ts. Defaults unaffected: without --isolate no
750
+ pool code exists in the request path.
751
+ - **Phase 4 — the frontier program opens** (§7 order).
752
+
753
+ ## 9. Decisions log (was: open questions)
754
+
755
+ 1. **Cap default 8 — FLIPPED 2026-07-05** (Josh; earlier "32" note
756
+ superseded by the sub-agents rationale, 4–8 agents + optiq's Mac-safe
757
+ 8). Landed after Phase 3 met every gate: lone-request = serial engine
758
+ (bits + speed + TTFT + prompt/SSD cache), quantized compositions
759
+ batch. `--batch 1` pins strict arrival-independent numerics;
760
+ kv-budget admission keeps 8 safe on small boxes.
761
+ 2. **Flag stays `--batch`** — no rename; semantics become the cap;
762
+ `--batch 1` = force-serial pin.
763
+ 3. **Perf kernel: deleted in Phase 1** (§6) — the optimization program
764
+ re-derives from the L1 baseline; no resurrection.
765
+ 4. Still open: PagedAttention-style block KV — Phase 3 follow-up or its own
766
+ design doc when prefix sharing under batching becomes the bottleneck.
767
+ 5. **Prefix sharing scope (Josh 2026-07-05)**: sharing must extend to the
768
+ DISK tier too — the whole-entry cold store duplicates a shared system
769
+ prompt across N agent conversations (blocks would dedupe; that is the
770
+ revisit trigger for block granularity). First-class use cases beyond
771
+ concurrent rows: SPINNING UP A NEW SESSION (fresh conversation, same
772
+ system prompt → instant prefix) and SERVER RESTART (re-attach without
773
+ re-prefill) — the shared prefix should be a durable object, not a
774
+ property of any one conversation's entry.