mlx-bun 0.0.7 → 0.0.9

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 (138) hide show
  1. package/README.md +112 -20
  2. package/docs/design/batching-v2-plan.md +515 -0
  3. package/docs/design/bucketing-stage.md +196 -0
  4. package/docs/design/compat-cli-surface-design.md +11 -11
  5. package/docs/design/diffusion-gemma-port.md +602 -0
  6. package/docs/design/docs-reorg-plan.md +1 -1
  7. package/docs/design/dreaming-nightly-pipeline.md +291 -0
  8. package/docs/design/dspark-speculative-decoding.md +106 -0
  9. package/docs/design/generic-model-support.md +381 -0
  10. package/docs/design/memory-inference-path.md +422 -0
  11. package/docs/design/memory-synthesis.md +287 -0
  12. package/docs/design/minicpm5-decode-megakernel.md +484 -0
  13. package/docs/design/mlx-lm-tool-parity-plan.md +535 -0
  14. package/docs/design/orpo-dynamic-lambda.md +41 -0
  15. package/docs/design/orpo-training.md +35 -5
  16. package/docs/design/parallel-slots.md +12 -1
  17. package/docs/design/parity-tier-dag.md +13 -5
  18. package/docs/design/spec-decode-larger-targets.md +229 -0
  19. package/docs/design/tauri-desktop-app.md +2 -2
  20. package/docs/design/the-dreaming-handoff.md +141 -0
  21. package/docs/design/the-dreaming-master-plan.md +446 -0
  22. package/docs/design/write-pipeline-entity.md +468 -0
  23. package/docs/investigations/curve-bisector-routing.md +1 -1
  24. package/docs/investigations/decode-roofline-lookagain.md +262 -0
  25. package/docs/investigations/dspark-handoff.md +58 -0
  26. package/docs/investigations/kernel-perf-review-2026-07.md +92 -0
  27. package/docs/planning/PRODUCT_ROADMAP.md +2 -2
  28. package/docs/planning/curve-sampler-research-plan.md +331 -0
  29. package/docs/planning/memory-docs-and-dag-plan.md +185 -0
  30. package/docs/planning/pi-integration-review.md +178 -0
  31. package/docs/planning/release-notes-v0.0.9.md +122 -0
  32. package/docs/planning/web-ui-pass-plan.md +213 -0
  33. package/docs/planning/website-readme-pass-plan.md +224 -0
  34. package/docs/reference/cli.md +255 -0
  35. package/docs/reference/embedding.md +7 -2
  36. package/docs/reference/library-api.md +22 -3
  37. package/docs/reference/memory.md +5 -5
  38. package/docs/reference/models.md +137 -0
  39. package/docs/reference/orpo-quickstart.md +6 -0
  40. package/docs/reference/server-api.md +187 -9
  41. package/docs/reference/server-config.md +40 -9
  42. package/docs/reference/training.md +25 -11
  43. package/package.json +1 -1
  44. package/src/anthropic.ts +22 -0
  45. package/src/assets/curve-designer.html +1 -1
  46. package/src/chat-template.ts +25 -7
  47. package/src/cli.ts +1029 -86
  48. package/src/config.ts +54 -3
  49. package/src/diffusion/diffusion-generate.ts +436 -0
  50. package/src/download.ts +99 -8
  51. package/src/embed.ts +77 -0
  52. package/src/eval/perplexity.ts +164 -0
  53. package/src/eval/runner.ts +19 -7
  54. package/src/eval/tasks/bfcl.ts +1 -1
  55. package/src/generate.ts +196 -19
  56. package/src/harness-pi.ts +43 -17
  57. package/src/index.ts +2 -0
  58. package/src/lora.ts +2 -2
  59. package/src/memory/article.ts +558 -0
  60. package/src/memory/chunk-validation.ts +150 -0
  61. package/src/memory/chunk.ts +305 -12
  62. package/src/memory/cluster.ts +344 -17
  63. package/src/memory/coverage.ts +134 -0
  64. package/src/memory/crosslink.ts +0 -0
  65. package/src/memory/db.ts +184 -45
  66. package/src/memory/entity.ts +203 -0
  67. package/src/memory/gate.ts +187 -0
  68. package/src/memory/history.ts +152 -0
  69. package/src/memory/ingest.ts +324 -11
  70. package/src/memory/model.ts +321 -0
  71. package/src/memory/normalize.ts +671 -0
  72. package/src/memory/parse.ts +22 -0
  73. package/src/memory/pipeline.ts +242 -30
  74. package/src/memory/prompts.ts +42 -0
  75. package/src/memory/query.ts +334 -0
  76. package/src/memory/reconcile.ts +734 -0
  77. package/src/memory/redirect.ts +108 -0
  78. package/src/memory/reindex.ts +325 -0
  79. package/src/memory/resolve.ts +307 -0
  80. package/src/memory/route.ts +272 -0
  81. package/src/memory/schedule.ts +6 -4
  82. package/src/memory/stages.ts +774 -0
  83. package/src/memory/synthesize.ts +1629 -20
  84. package/src/memory/tools.ts +446 -96
  85. package/src/memory/vault.ts +462 -35
  86. package/src/memory/wikify.ts +1057 -16
  87. package/src/mlx/ffi.ts +11 -0
  88. package/src/mlx/ops.ts +113 -0
  89. package/src/model/compiled-decode.ts +43 -8
  90. package/src/model/diffusion-gemma.ts +1026 -0
  91. package/src/model/factory.ts +35 -8
  92. package/src/model/flash-attention.ts +4 -2
  93. package/src/model/fused-decode-kernel.ts +14 -7
  94. package/src/model/fused-mlp-kernel.ts +169 -0
  95. package/src/model/fused-swiglu-kernel.ts +240 -0
  96. package/src/model/gemma4-base.ts +62 -6
  97. package/src/model/gemma4.ts +33 -2
  98. package/src/model/minicpm5.ts +106 -8
  99. package/src/model/qwen3.ts +276 -0
  100. package/src/model/steel-linear-kernel.ts +96 -0
  101. package/src/model/support.ts +45 -7
  102. package/src/model/universal/archs.ts +332 -0
  103. package/src/model/universal/dense.ts +576 -0
  104. package/src/model/universal/modules.ts +163 -0
  105. package/src/model/universal/rope.ts +366 -0
  106. package/src/pi-provider.ts +1 -1
  107. package/src/pi-terminal.ts +29 -8
  108. package/src/pi-web.ts +19 -2
  109. package/src/quantize/config-writer.ts +1 -1
  110. package/src/registry.ts +215 -7
  111. package/src/responses.ts +23 -0
  112. package/src/sampler.ts +220 -9
  113. package/src/serve/batch-scheduler.ts +272 -80
  114. package/src/serve/generation-gateway.ts +90 -18
  115. package/src/server.ts +643 -50
  116. package/src/spec/drafter.ts +81 -49
  117. package/src/spec/dspark/data-dflash.ts +156 -0
  118. package/src/spec/dspark/data.ts +201 -0
  119. package/src/spec/dspark/generate-dflash.ts +193 -0
  120. package/src/spec/dspark/generate.ts +290 -0
  121. package/src/spec/dspark/loss.ts +213 -0
  122. package/src/spec/dspark/module-dflash.ts +361 -0
  123. package/src/spec/dspark/module.ts +540 -0
  124. package/src/spec/dspark/sample.ts +112 -0
  125. package/src/spec/generate.ts +40 -19
  126. package/src/status-page.html +1 -1
  127. package/src/tokenizer.ts +7 -0
  128. package/src/train/diffusion-lora.ts +216 -0
  129. package/src/train/flash-cce.ts +70 -15
  130. package/src/train/fuse.ts +243 -0
  131. package/src/train/index.ts +3 -0
  132. package/src/train/job.ts +8 -0
  133. package/src/train/loss.ts +289 -30
  134. package/src/train/prefix-shared.ts +33 -5
  135. package/src/train/segmented.ts +67 -11
  136. package/src/train/trainer.ts +92 -28
  137. package/src/vision/diffusion-vision.ts +620 -0
  138. package/src/web/skills/memory/SKILL.md +40 -17
@@ -0,0 +1,515 @@
1
+ # Batching v2 — state map, debt, and the gap plan
2
+
3
+ Status: **PLAN** (written 2026-07-01, read-only audit — no code changed)
4
+ Scope: the `--batch N` continuous-batching engine (`src/serve/batch-scheduler.ts`,
5
+ `src/serve/generation-gateway.ts`, `src/model/batched-mask.ts`,
6
+ `src/model/batched-rotating.ts`, the `server.ts` wiring).
7
+ Predecessor docs: [parallel-slots.md](parallel-slots.md) (the design, still
8
+ accurate on the primitives), PLAN.md Phase 18 (history),
9
+ [server-config.md](../reference/server-config.md) (user-facing modes doc).
10
+
11
+ Mandate (Josh): batching is "kinda implemented, but is not great… make sure we
12
+ have all the code really well done, and documented and exposed via the flags."
13
+
14
+ **Standing decisions honored throughout (do not relitigate):**
15
+ - `--batch N` is a **mode switch**, not a load-dependent fallback. Josh decided
16
+ this explicitly (determinism: results must not depend on concurrency, and the
17
+ drop-in-for-`mlx_lm.server` promise needs a predictable path). Auto-batching
18
+ "when >1 request arrives" was considered and **rejected** — an idle-vs-loaded
19
+ server would produce different numerics for the same request. This plan keeps
20
+ `--batch` as the UX and documents the rejection.
21
+ - The batched lane is **compat mode** (bit-parity with mlx-lm B=N, bf16, no
22
+ perf flags) — see server-config.md "compat mode" section. All work below
23
+ preserves that guarantee; anything that can't be oracle-gated is L3 and
24
+ deferred (item f).
25
+
26
+ **In-flight work this plan routes around (2026-07-01):**
27
+ - **Same-ms seed collision fix** — `server.ts:1120` defaults
28
+ `seed: req.seed ?? (Date.now() & 0xffffffff)`; two batched rows admitted in
29
+ the same millisecond sample identical RNG streams. A fix is IN FLIGHT by
30
+ another agent. Noted as context; **not planned here**.
31
+ - **`src/model/gemma4-base.ts` is modified in another agent's working tree**
32
+ (and `src/model/universal/` is a new in-flight subsystem). Items below that
33
+ would naturally touch cache classes are designed to live in NEW files
34
+ (`BatchedKVCache` beside `BatchedRotatingCache`) precisely so they don't
35
+ collide; coordinate before touching gemma4-base.ts.
36
+
37
+ ---
38
+
39
+ ## 1. State map (verified against code, not docs)
40
+
41
+ ### 1.1 Architecture as built
42
+
43
+ ```
44
+ handler (server.ts) GenerationGateway (generation-gateway.ts)
45
+ promptIdsFor → options → RequestShape ──▶ willBatch(shape)?
46
+ onToken closure (StopMatcher + ├─ no → AsyncMutex → serialRun
47
+ tool router + thinking splitter + │ (= runGeneration: PromptCache
48
+ SSE send / collector) │ take/put + generate())
49
+ └─ yes → BatchScheduler.submit
50
+ (batch-scheduler.ts)
51
+ BatchScheduler #drive loop (detached, Bun-async, no threads):
52
+ acquire mutex (held first-admit → batch-empty)
53
+ ├─ #admit each pending row: SOLO prefill (one forward, whole prompt)
54
+ │ → sample token 0 → re-MERGE whole batch per layer
55
+ │ (full layers: temporalView + per-row slice + mergeKVRows → new KVCache;
56
+ │ rot layers: BatchedRotatingCache.merge)
57
+ ├─ #step: forward [B,1] (full layers wrapped per-step in
58
+ │ BatchedDecodeMaskCache; rot caches persistent), per-row sample on
59
+ │ [1,V] slices, SYNC readback [B], clearCache()
60
+ ├─ #emit per row (EOS → "stop" w/o onToken; onToken false → "stop";
61
+ │ generated ≥ maxTokens → "length")
62
+ └─ #applyFilter(keep): full layers filterKVRows → new KVCache;
63
+ rot layers filter() in place
64
+ ```
65
+
66
+ - **Scheduler loop shape**: iteration-level (continuous) scheduling — admit
67
+ between steps, evict after each step. Correct design; the loop-body
68
+ *mechanics* carry the debt (§2).
69
+ - **Admission today**: count-only — `#running.length < #maxBatch`
70
+ (batch-scheduler.ts:138). No byte projection, no KV budget. The server's
71
+ per-request `context_over_budget` 400 (server.ts:~1780, from `fit()`) checks
72
+ each request against **single-sequence** max-safe-context; N rows can jointly
73
+ exceed the budget (documented as limitation #2 in server-config.md).
74
+ - **Eviction**: per-step `#applyFilter` (batch-scheduler.ts:327-355) —
75
+ full-attention layers rebuild a fresh `KVCache` from `filterKVRows`
76
+ (takeAxis on B); rotating caches filter in place including padding
77
+ reduction. Matches mlx-lm `filter` semantics (cache.py:1016-1033) except
78
+ ours doesn't do the `min_left_pad` shift-left for full layers (mlx-lm
79
+ reduces padding after filter; ours keeps stale left pad until the next
80
+ merge — correct but wasteful width).
81
+ - **Join**: `#admit` re-merges the ENTIRE batch (batch-scheduler.ts:193-254):
82
+ per layer, temporalView of the running batch, B per-row slices, re-concat
83
+ with the new row. O(B·S) traffic per join **plus** ~2B slice kernels per
84
+ layer. mlx-lm instead `extend`s (cache.py:1035-1078): pad self+other once,
85
+ one concat — and prefills joiners as a right-padded *batch* (see item a/d).
86
+
87
+ ### 1.2 The `willBatch` gate — exact predicate today
88
+
89
+ `generation-gateway.ts:113-127`: batches iff **all** of
90
+
91
+ ```
92
+ batch > 1
93
+ AND model is not DiffusionGemmaModel (non-AR)
94
+ AND !hasVision (offset-0 prefill + bidir mask)
95
+ AND !hasAdapters (loraState is one global field)
96
+ AND !hasRepetitionPenalty (no per-row logits processors)
97
+ AND !hasLogitsExtras (min_p ∥ xtc_probability ∥ logit_bias
98
+ ∥ presence ∥ frequency — one family gate)
99
+ AND !wantsLogprobs (non-stream logprobs/top_logprobs capture)
100
+ AND !userSeed (body.seed !== undefined)
101
+ AND !kvQuant (request carries kvConfig/kvBits —
102
+ i.e. any explicit --kv-quant)
103
+ ```
104
+
105
+ `RequestShape` is built **twice**, near-identically: chat (server.ts:1813-1826)
106
+ and completions (server.ts:2085-2096, `hasVision: false`). mlx-lm's gate for
107
+ comparison (server.py:371-374, 685-686): `draft_model is None AND all caches
108
+ hasattr 'merge' AND args.seed is None`. **We have no cache-capability check**
109
+ — see debt D1.
110
+
111
+ ### 1.3 Which caches support which dynamic-B ops
112
+
113
+ | Cache | merge | filter | extend | extract | batched mask | verified vs |
114
+ | --- | --- | --- | --- | --- | --- | --- |
115
+ | `KVCache` (full attn) | ✅ `mergeKVRows` (batched-mask.ts:171) | ✅ `filterKVRows` (:196) | ❌ (join = re-merge) | ✅ implicit (per-row slice in `#admit`) | ✅ `BatchedDecodeMaskCache` wrapper | mlx-lm `BatchKVCache` dynamic golden (gated) |
116
+ | `RotatingKVCache` (sliding) | ✅ `BatchedRotatingCache.merge` (batched-rotating.ts:273) | ✅ `.filter` (:233) | ❌ | ✅ `.temporalView` (:207) | ✅ built-in `makeMask` incl. ring-roll | mlx-lm `BatchRotatingKVCache` model-free golden (FAST, incl. ring-wrap) |
117
+ | `SSMCache` (Qwen3.5 gated-DeltaNet, qwen3-delta.ts:184) | ❌ | ❌ | ❌ | ❌ (no `temporalView`) | ❌ (`makeMask` returns empty; comment says "B=1 single-stream") | — |
118
+ | `QuantizedKVCache` / rotating-quant | n/a — excluded by the `kvQuant` gate (bf16-only v1) | | | | | no oracle exists (L2/L3, item f) |
119
+
120
+ **⇒ D1 (bug): a Qwen3.5 server with `--batch N` errors every batched
121
+ request.** The scheduler's constructor classifies `SSMCache` as `"full"`
122
+ (batch-scheduler.ts:105, `instanceof RotatingKVCache ? "rot" : "full"`), then
123
+ `#admit` casts it to `KVCache` and calls `temporalView()`
124
+ (batch-scheduler.ts:200-201) → TypeError; `#drive` catches and rejects the row,
125
+ so **every** batched request 500s. `willBatch` has no family/capability gate
126
+ (only DiffusionGemma is excluded). And **server-config.md:151-153 claims the
127
+ opposite** ("full-attention (CPM), sliding-window (Gemma), and hybrid
128
+ gated-DeltaNet (Qwen3.5) — all batch"). The doc claim is false today. Note an
129
+ oracle for the real fix exists: mlx-lm's `ArraysCache` implements
130
+ `filter/extend/extract/finalize/merge` (cache.py:632-702), which is how mlx-lm
131
+ batches Mamba-family state.
132
+
133
+ ### 1.4 Test coverage matrix
134
+
135
+ | Behavior | Test | Tier | Gate quality |
136
+ | --- | --- | --- | --- |
137
+ | decode mask math (`buildBatchedDecodeMask`), `mergeKVRows`/`filterKVRows` pure | `tests/batched-decode-mask.test.ts` | FAST (no model) | unit, exact |
138
+ | rotating merge/decode/mask/filter incl. **ring-wrap** | `tests/batched-rotating.test.ts` | FAST (fixture) | **oracle-gated** — replays `scripts/gen-rotating-golden.py` vs mlx-lm `BatchRotatingKVCache`, per-step mask + keys |
139
+ | batched prefill+decode forward, 4 models | `tests/batched-decode-parity.test.ts` | GATED `MLX_BUN_TEST_BATCH_DECODE=1` + weights | **oracle-gated** — bit-exact vs mlx-lm B=2 goldens (`scripts/gen-batched-golden.py`) |
140
+ | dynamic-B join/leave ({A,B}→+C→−A) | same file, CPM | GATED | **oracle-gated** — token-for-token vs `scripts/gen-batched-dynamic-golden.py` (mlx-lm merge/extract/filter) |
141
+ | scheduler orchestration (admit/step/evict/join) | `tests/batch-scheduler.test.ts` | GATED | **KL-gated** teacher-forced (tol 1e-2) CPM; Gemma 12B greedy vs mlx-lm B=2 golden |
142
+ | server wiring: Option-B engage, `/stats`, concurrent completion, SSE fan-out, serial-lane coexist (user-seed drain, no deadlock) | `tests/batch-serving.test.ts` | GATED (live in-process server) | **smoke** — coherence assertions, no numeric oracle |
143
+ | prefill batched mask (training side) | `tests/train-batch{,-e2e}.test.ts` | FAST / gated | oracle (per-row loss == solo) |
144
+
145
+ **Not tested anywhere:** row-failure containment (one row's error vs the
146
+ batch), serial-lane starvation/fairness under sustained batchable load,
147
+ KV admission (doesn't exist), batched Qwen3.5 (would fail, §1.3),
148
+ ring-wrap **end-to-end** on a real Gemma forward (model-free math is gated;
149
+ the long-context forward golden is the known open L1 follow-up),
150
+ batched-lane usage-timing stats (currently hardwired zeros).
151
+
152
+ ### 1.5 What the flags/docs currently say (accurate except one claim)
153
+
154
+ `--batch <n>` (cli.ts:750-757; `--decode-concurrency` accepted as alias),
155
+ `--kv-quant` interplay (Option B: unset+batch ⇒ bf16; explicit ⇒ those requests
156
+ serial, warned at server.ts:1065-1070), `/stats.batch`
157
+ {configured, batched, active_rows} (server.ts:1437-1441). server-config.md's
158
+ "Execution modes", lane-picker table, compatibility matrix, compat-mode
159
+ rationale, and known-limitations list are **verified accurate against code**
160
+ — except the Qwen3.5 batching claim (D1) and it doesn't mention the curve/
161
+ signal endpoints' lock bypass (D3, which is a bug to fix, not to document).
162
+
163
+ ---
164
+
165
+ ## 2. Code-quality assessment
166
+
167
+ **Verdict:** the *primitives* are excellent — oracle-gated, well-commented,
168
+ disposal-disciplined (`batched-mask.ts`, `batched-rotating.ts` are among the
169
+ cleanest files in the repo). The *orchestration* (`batch-scheduler.ts`,
170
+ ~360 lines, readable) is correct-by-gate but **naive as an engine**: it has
171
+ none of the serial loop's hard-won loop hygiene, its failure domain is the
172
+ whole batch, and the server grew a second mutual-exclusion domain that two
173
+ endpoints silently don't participate in. Specific debt, ranked:
174
+
175
+ - **D1 — Qwen3.5 batched = guaranteed request failure + false doc claim.**
176
+ batch-scheduler.ts:105 + :200-201 vs qwen3-delta.ts:184 (`SSMCache`, no
177
+ `temporalView`); server-config.md:151-153. Fix now: a cache-capability gate
178
+ (mirror mlx-lm server.py:371-374) in `willBatch` — batchable iff every
179
+ `makeCache()` element is `KVCache | RotatingKVCache` — plus the doc
180
+ correction. The real SSM batch path is item (h), later. *(confidence 95)*
181
+
182
+ - **D2 — `#admit` materializes full-prompt logits.**
183
+ batch-scheduler.ts:173-174 runs `logitsFromHidden(h)` on the **whole**
184
+ `[1,Lp,H]` hidden, then slices the last position. The lm_head matmul
185
+ computes `[1,Lp,V]` at eval — at Gemma's V=262k and an 8k prompt that is a
186
+ ~4.3 GB bf16 transient *per admit* (plus the wasted GEMM). The serial path
187
+ slices `hLast` FIRST (generate.ts:466-468) precisely to avoid this. One-line
188
+ reorder + the gated suites re-run. *(confidence 95)*
189
+
190
+ - **D3 — two mutual-exclusion domains; curve `/generate` and `/signal` bypass
191
+ the gateway.** The old serialized queue (`enqueue`, server.ts:946-951) used
192
+ to be THE GPU lock; generation moved to the gateway's `AsyncMutex`, but
193
+ `/generate` (curve designer, server.ts:1663 — `enqueue(() =>
194
+ runGeneration(...))`) and `/signal` (server.ts:1608 — `enqueue` + raw
195
+ `model.forward`) still run GPU work (and `PromptCache.take/put`) under
196
+ `enqueue` only ⇒ they can run **concurrently with batched decode steps**
197
+ (GPU contention + the prompt-cache "single owner" invariant from
198
+ parallel-slots.md broken). Adapter mount/unmount (server.ts:1578, 1589) sit
199
+ on `enqueue` too — mount is registry-level, but unmount disposing adapter
200
+ arrays concurrently with a serial-lane generation using them is the same
201
+ class of race. The `runGeneration` doc comment "Must be called inside the
202
+ queue" (server.ts:960) is stale. Fix: one lock — expose
203
+ `gateway.runExclusive(fn)` and route curve/signal/adapter-unmount through
204
+ it; fix the comment. *(confidence 90 on curve//signal; 80 on unmount)*
205
+
206
+ - **D4 — the batched loop lacks the serial loop's pipelining + clearCache
207
+ cadence** (= reports/kernel-perf-review-2026-07-01.json backlog #6, area 7
208
+ verdict). Three sub-items, all in `#step`/`#admit`:
209
+ 1. **No `asyncEval` pipelining**: `#step` builds one step's graph then
210
+ synchronously reads all B tokens (`toFloat32`, batch-scheduler.ts:286)
211
+ — every step eats a full dispatch+readback bubble. Serial overlaps step
212
+ n's readback with step n+1's compute (generate.ts:482, 539); mlx-lm's
213
+ `GenerationBatch._step` does exactly this batched (`mx.async_eval`
214
+ next, `mx.eval` current — generate.py:1367-1373).
215
+ 2. **`clearCache()` EVERY step** (batch-scheduler.ts:293) — trashes the
216
+ buffer pool each token, so every step re-allocates its transients.
217
+ Serial clears every 256 tokens (generate.ts:571); mlx-lm batched every
218
+ 512 steps (generate.py:1778-1779).
219
+ 3. **No prefill chunking in `#admit`** — the whole prompt goes through one
220
+ forward (batch-scheduler.ts:172), so a long prompt is an unbounded
221
+ transient (serial chunks at `prefillChunkSize` with per-chunk eval +
222
+ clearCache, generate.ts:440-456; mlx-lm at `prefill_step_size=2048`).
223
+ Worse, admits run to completion before the next `#step`, so **every
224
+ running row stalls for the entire prefill of every joiner**
225
+ (head-of-line blocking; mlx-lm interleaves one decode step with one
226
+ prefill chunk per loop iteration — generate.py:1760-1844).
227
+ *(confidence 90; perf claims need bench numbers per house rules)*
228
+
229
+ - **D5 — whole-batch failure domain.** Any throw in `#step` — including a
230
+ row's own `onToken` (awaited inline, batch-scheduler.ts:299; an SSE
231
+ controller error qualifies) — is caught in `#drive` (:153-158) and
232
+ **rejects every row and drops the entire batch KV**. mlx-lm removes the
233
+ offending uid and keeps the batch (server.py:913-919). Also: `#admit` has
234
+ no try/finally around the prefill, so `solo` caches + `ids` leak if the
235
+ forward throws (:170-173); onToken should be wrapped per-row so one
236
+ consumer's exception evicts one row. *(confidence 90)*
237
+
238
+ - **D6 — serial-lane starvation under sustained batchable load.** The
239
+ scheduler holds the mutex while `running ∪ pending ≠ ∅` and **keeps
240
+ admitting** new batchable arrivals (batch-scheduler.ts:135-145); a queued
241
+ serial-lane request (vision, adapter, seed…) waits for the batch to empty,
242
+ which under continuous traffic is *never*. mlx-lm's drain sets
243
+ `drain_batch=True` and **stops admitting** until the incompatible request
244
+ has run (server.py:826-830). Fix: gateway signals "serial waiter present" →
245
+ scheduler pauses admission (finish running rows, release, FIFO mutex hands
246
+ over, then resume). Single-user appliance softens this, but agent fan-out
247
+ is exactly the batching use case, so it's real. *(confidence 85)*
248
+
249
+ - **D7 — zeroed stats + duplicated request-shape/sampling logic.**
250
+ - Batched stats hardwire `prefillMs/decodeMs/…Tps = 0`
251
+ (generation-gateway.ts:170) → SSE `usage` timing and any logging report
252
+ zeros for batched requests; `cachedTokens` 0 is documented, the timings
253
+ are just missing accounting.
254
+ - `RequestShape` construction duplicated chat vs completions
255
+ (server.ts:1813 vs 2085) — extract one `shapeFor()` helper.
256
+ - `maxTokens` fallback drift: gateway `?? 512` (generation-gateway.ts:160),
257
+ chat finish `?? 1024` (server.ts:1904), completions finish `?? 512`
258
+ (server.ts:2098) — all dead in practice (server defaults 65_536 at
259
+ :1116) but a trap for library callers; unify.
260
+ - The `#emit` EOS/length/onToken contract mirrors `generate()` by comment
261
+ only (batch-scheduler.ts:306-316) — drift risk; a shared contract test
262
+ (same fixture through both lanes) would pin it.
263
+ - Full-layer `filter` keeps stale left-padding (no `min_left_pad`
264
+ shift-left, cf. cache.py:1027-1033) — width waste, not correctness.
265
+
266
+ None of this contradicts the gates that exist: the numerics ARE oracle-proven.
267
+ The debt is engine mechanics, failure domains, and the second lock.
268
+
269
+ ---
270
+
271
+ ## 3. Gap plan (implementation-ready, per item)
272
+
273
+ ### (a) `extend` join op — stop re-merging the running batch
274
+
275
+ **Port target:** mlx-lm `BatchKVCache.extend` (cache.py:1035-1078) and
276
+ `BatchRotatingKVCache.extend` (cache.py:1369-1416). Semantics: right-justify
277
+ both caches to a common `_idx`/width (one `mx.pad` each), concat on B, sum
278
+ left-padding adjustments. One pad+concat per layer per join, vs today's
279
+ per-row slice storm + full rebuild.
280
+
281
+ **Design:** introduce **`BatchedKVCache`** (new file or in batched-mask.ts) —
282
+ the full-attention twin of `BatchedRotatingCache`: owns
283
+ `keys/values/leftPad/offset`, methods `merge` (subsumes `mergeKVRows`),
284
+ `filter` (subsumes `filterKVRows` + adds the min-left-pad shift),
285
+ **`extend`**, `extract(row)` (the per-row slice `#admit` currently inlines),
286
+ `makeMask` + `ropeOffsetArr` (subsumes the per-step `BatchedDecodeMaskCache`
287
+ wrapper rebuild and the scheduler's shared `#fullLeftPad` field —
288
+ batch-scheduler.ts:93, a state smell). `BatchedRotatingCache.extend` follows
289
+ the same pad-to-common-idx recipe in temporal order (both sides are kept
290
+ temporal by our merge, so no dynamic-roll needed for the N=1-update scope —
291
+ verify against the golden, don't assume). Scheduler `#admit` becomes:
292
+ solo-prefill → `inners[layer].extend(soloRow)` — no re-merge, no wrapper churn.
293
+ **Does not touch gemma4-base.ts** (in-flight ownership).
294
+
295
+ **Oracle:** extend `scripts/gen-batched-dynamic-golden.py` to drive the join
296
+ through mlx-lm's real `extend` (it already drives merge/extract/filter);
297
+ assert the same {A,B}→+C→−A per-row trajectories token-for-token, CPM L1 +
298
+ a Gemma variant for the rotating extend. Existing gated suites must stay
299
+ green unchanged (numerically `extend ≡ re-merge`; the golden proves it).
300
+
301
+ **Effort: M.**
302
+
303
+ ### (b) Prompt-cache reuse under batching
304
+
305
+ **mlx-lm behavior (the model to follow):** on insert,
306
+ `prompt_cache.fetch_nearest_cache` → pass the pre-warmed per-sequence cache
307
+ into the batch (server.py:751-767); on row finish, extract the row's cache and
308
+ `insert_cache` it back keyed by all tokens (server.py:900-907).
309
+
310
+ **Design (parallel-slots.md's sole-owner rule, kept):** all `PromptCache`
311
+ take/put happen **inside the scheduler loop** (plus the serial lane, which
312
+ already does — the two are mutex-exclusive, and D3's fix closes the remaining
313
+ holes).
314
+ - `#admit`: `promptCache.take(promptIds, "")` (batched lane is always
315
+ adapter-less, so namespace ""); on hit, the entry's caches ARE the solo
316
+ caches — forward only the uncached tail (the trim/`cachedTokens` logic
317
+ exists in generate.ts; lift the prefix-trim helper rather than reimplement —
318
+ memory: eval-reuse-serving-prompt-path). `BatchStats.cachedTokens` becomes
319
+ real.
320
+ - Row finish (`#finish`) or eviction: `extract(row)` (from item a) →
321
+ `restoreState` into fresh solo `KVCache`/`RotatingKVCache`s →
322
+ `promptCache.put(rowTokens, caches, "")`. Cost: O(S_row) copy per finished
323
+ row, off the step hot path (do it after `#applyFilter`).
324
+ - Byte interplay with (c): mirror mlx-lm's
325
+ `prompt_cache.trim_to(total − activeBatchBytes)` (server.py:795-799) so the
326
+ LRU + live batch share one ceiling.
327
+
328
+ **Gate:** batch-serving test asserting `cached_tokens > 0` on an identical
329
+ repeat under `--batch 2`; parity assertion that a warm-start row's trajectory
330
+ equals the cold row's (same-B, bit-exact — prefix KV is identical by
331
+ construction); serial suite untouched.
332
+
333
+ **Effort: M–L** (the prefix-trim + rotating-cache restore corners are where
334
+ the time goes; RotatingKVCache entries past the window are non-trimmable —
335
+ mirror the serial path's existing handling).
336
+
337
+ ### (c) KV-budget admission (B×S_max) + `--prompt-concurrency`
338
+
339
+ **Fact-check first:** mlx-lm does NOT byte-budget admission — it caps counts
340
+ (`--decode-concurrency` 32, `--prompt-concurrency` 8), optionally swaps KVCache
341
+ → RotatingKVCache via `max_kv_size`, and byte-trims only the *prompt cache*
342
+ against `--prompt-cache-bytes` minus live batch bytes. So byte-budget admission
343
+ is OUR addition (rung 2 of parallel-slots.md), consistent with the byte-capped
344
+ `PromptCache` precedent and the existing `fit()` admission (server.ts:1012).
345
+
346
+ **Design:**
347
+ - Projected row bytes = Σ_layers 2·bytes_bf16(H_kv·D·S_cap) where
348
+ S_cap = min(promptLen+maxTokens, window) for rotating layers and
349
+ promptLen+maxTokens for full layers (a `projectKvBytes(model, promptLen,
350
+ maxTokens)` helper next to `fit()`, which already knows per-layer shapes).
351
+ - Budget = explicit `--kv-budget <GB>` else derived: `fit()`'s usable bytes −
352
+ weights − prompt-cache cap − headroom. Admission: admit while
353
+ Σ projected(running+candidate) ≤ budget; otherwise leave in `#pending`
354
+ (FIFO — no reordering, keeps fairness and determinism of arrival order).
355
+ `maxTokens` default 65_536 makes naive projection admit ~1 row — so
356
+ projection uses a decode-allowance term (e.g. min(maxTokens, 4096) beyond
357
+ prompt) with the true backstop being re-check-on-growth: on each STEP-chunk
358
+ cache growth, re-evaluate; if over budget, stop admitting (never evict a
359
+ running row). Document the estimate honestly.
360
+ - **`--prompt-concurrency`: accept for drop-in** (we already accept
361
+ `--decode-concurrency`), mapped to "max joiners admitted per loop
362
+ iteration" — which becomes meaningful exactly when (d)'s interleaved
363
+ chunked admit lands. Until then accept + warn like `--decode-concurrency`'s
364
+ semantic note in cli.ts:744-748. `/stats.batch` grows
365
+ `{pending_rows, kv_bytes, kv_budget_bytes}`.
366
+
367
+ **Gate:** unit tests on the projection math (pure); a gated live test that
368
+ M oversized requests are admitted ≤ budget-implied width and all complete
369
+ (queueing, not OOM). Perf numbers only via bench-serving-load on a clean
370
+ machine (Josh-gated).
371
+
372
+ **Effort: M.**
373
+
374
+ ### (d) Pipelining + clearCache cadence + chunked/interleaved admit
375
+ (the backlog-#6 transplant — pairs with fixing D2, D4)
376
+
377
+ 1. **Slice-before-head in `#admit`** (D2): `hLast = h.slice(last)` →
378
+ `logitsFromHidden(hLast)`. One-line; do first.
379
+ 2. **Chunked prefill in `#admit`**: loop `prefillChunkSize` (reuse the serial
380
+ constant) with `evalAll(cache.state)` + `clearCache()` per chunk
381
+ (generate.ts:440-456 pattern).
382
+ 3. **Interleave admit with steps**: `#drive` admits at most one prefill
383
+ *chunk* (per joiner, up to `--prompt-concurrency` joiners) between decode
384
+ steps instead of running each admit to completion — bounds running-row
385
+ stall to one chunk. (This restructures `#admit` into a resumable
386
+ per-joiner state; mlx-lm's `_currently_processing` is the reference,
387
+ generate.py:1760-1844.)
388
+ 4. **Pipelined decode**: mirror mlx-lm's `GenerationBatch._step`
389
+ (generate.py:1320-1373): keep step n's sampled `[B]` token array on
390
+ device, `asyncEvalAll` it, build step n+1's graph from the **unread**
391
+ array, then read step n. Eviction moves after the read: rows evicted at
392
+ step n have already contributed a step-n+1 KV write — harmless, `filter`
393
+ drops the row (mlx-lm does exactly this). EOS rows' extra write is also
394
+ how mlx-lm behaves, so parity holds by construction.
395
+ 5. **clearCache cadence**: every 256 steps (serial's constant; mlx-lm uses
396
+ 512 — either is defensible, pick 256 for house consistency and comment
397
+ the divergence).
398
+
399
+ **Gate:** numerics unchanged ⇒ the teacher-forced KL suite and the mlx-lm
400
+ goldens must pass **unchanged** (pipelining is scheduling, not math);
401
+ batch-serving smoke; A/B throughput via `scripts/bench-serving-load.ts`
402
+ (quotable numbers clean-machine only).
403
+
404
+ **Effort: M** (item 3 is the bulk; 1/2/5 are S alone).
405
+
406
+ ### (e) Gate widening — which serial-only features can batch safely
407
+
408
+ Ranked by value/risk. Each needs per-row state listed; each lands with its own
409
+ oracle before the `willBatch`/`hasLogitsExtras` split changes.
410
+
411
+ 1. **min_p + XTC — S, low risk, do first.** Pure per-row *samplers*; the
412
+ batched lane already builds a per-request sampler closure
413
+ (generation-gateway.ts:150-156 → `makeSampler(options)`), which already
414
+ implements min_p/XTC for serial. Per-row state: none beyond the closure
415
+ (XTC needs `xtcSpecialTokens`, already in options). Change: move
416
+ `minP`/`xtc*` out of `hasLogitsExtras` into the batchable set. Gate:
417
+ teacher-forced determinism (same seed ⇒ same tokens, batched == serial,
418
+ per row — sampling happens on the `[1,V]` row slice so it's the SAME code
419
+ path); note the seed-collision fix must land first for two same-ms hot
420
+ rows to be meaningfully independent (in flight).
421
+ 2. **logprobs / top_logprobs — M, low-medium risk.** Non-stream only
422
+ (server.ts:1797-1805 keeps streams capture-free). The scheduler's sampler
423
+ already computes `toLogprobs` per row; extend `RowSampler` to optionally
424
+ return extras (the serial `sampleStep` extras shape, generate.ts) and have
425
+ `#emit` pass them to `onToken` — the handler's `LogprobsCollector` then
426
+ works unchanged. Per-row state: none (readback per emitted token, rows
427
+ that asked for it only). mlx-lm computes logprobs for every batched row
428
+ natively, so behavior-parity is clean.
429
+ 3. **penalties (repetition/presence/frequency) + logit_bias — M, medium
430
+ risk.** Needs per-row token history + per-row logits processors applied to
431
+ the `[1,V]` slice before sampling — exactly mlx-lm's per-row
432
+ `logits_processors` loop (generate.py:1338-1349, TokenBuffer). We have the
433
+ processors (sampler.ts, serial); per-row state: a bounded history array
434
+ (context sizes 20 by default) per row, filtered on eviction. logit_bias is
435
+ stateless (easiest of the family) — could ship with tier 1 if the family
436
+ gate is split per-feature. Gate: teacher-forced vs serial with identical
437
+ forced trajectories (processors are deterministic given history).
438
+ 4. **LoRA same-adapter-set batching — L, defer.** Design already decided
439
+ (group-by-adapter + drain, parallel-slots.md Fallbacks); needs adapter-set
440
+ as a batch-compatibility KEY in the scheduler, not just a boolean gate.
441
+ Not before (a)–(d).
442
+
443
+ Never batch: vision (offset-0 bidirectional prefill), user seed
444
+ (reproducibility ⇒ solo — matches mlx-lm), explicit kv-quant (item f),
445
+ DiffusionGemma (non-AR).
446
+
447
+ ### (f) Quantized-KV batched — L3 novel extension (design sketch ONLY, deferred)
448
+
449
+ No ancestor does it (mlx-lm's `to_quantized` raises on batch caches; optiq
450
+ doesn't batch) ⇒ no bit-exact oracle, per the three-level fidelity tree this
451
+ is KL + 6-task + benchmark gated. Sketch: `BatchedQuantizedKVCache` mirroring
452
+ (a)'s `BatchedKVCache` over (packed, scales, biases) triples — merge/filter/
453
+ extend are the same B-axis surgery per component; attention falls to
454
+ `quantizedSdpaUnfused` with the `[B,1,N,S]` bool mask (the fused decode kernel
455
+ requires `mask.mode===""` and the tiled path 2-D causal — both disqualified,
456
+ known perf debt from the modes analysis). Value: memory-density (4-bit KV ×
457
+ batching compound, ~4× rows per GB) on the 24 GB machine. **Do not start**
458
+ until (a)–(d) are landed and a concrete density need exists; then it begins
459
+ with a KL harness (batched-quant vs batched-bf16 at same B), never a
460
+ trajectory diff.
461
+
462
+ ### (g) Docs + flags
463
+
464
+ - **server-config.md**: fix the Qwen3.5 batching claim (D1) — with the
465
+ capability gate landed the honest row is "hybrid-cache models (Qwen3.5)
466
+ route to the serial lane; batched SSM is a later port (mlx-lm ArraysCache
467
+ is the oracle)". Update the known-limitations list as (a)–(e) land (each
468
+ item literally deletes a numbered limitation: #1 prompt cache, #2
469
+ admission, #5 extend). Add `--kv-budget`, `--prompt-concurrency`,
470
+ `/stats.batch.{pending_rows,kv_bytes}` when they exist. Add one line
471
+ documenting the auto-batch rejection ("mode, not load-fallback —
472
+ determinism", the standing decision).
473
+ - **parallel-slots.md**: refresh the STATUS block (S2 admission half, extend,
474
+ prompt-cache reuse) to point HERE for the v2 execution plan; it remains the
475
+ primitive-design record.
476
+ - **`--batch` stays the UX.** No smarter default. `--decode-concurrency`
477
+ alias kept; `--prompt-concurrency` accepted per (c). No new mode flags.
478
+ - **STATUS.md / PLAN.md Phase 18**: check off items as they land (house
479
+ rule: write conclusions down).
480
+
481
+ ### (h) (surfaced by this audit, not in the original list) SSM batched path
482
+
483
+ Port mlx-lm's ArraysCache batch ops for `SSMCache` (conv/recurrent state are
484
+ `[B,…]` tensors; merge/filter/extend are trivial B-axis concat/take — no
485
+ padding subtleties since there's no sequence axis; the mask is the harder bit:
486
+ Qwen3.5's linear layers need an `ssm_mask` under left-padding). Oracle exists
487
+ (mlx-lm batches these models). Schedule after (a); until then the capability
488
+ gate (D1 fix) keeps Qwen servers correct-but-serial.
489
+
490
+ ---
491
+
492
+ ## 4. Sequencing
493
+
494
+ Order minimizes risk (correctness first, then the engine, then features),
495
+ respects in-flight work (seed fix in flight — untouched; gemma4-base.ts owned
496
+ elsewhere — all new cache code goes in new/batched-* files), and gives every
497
+ step a gate.
498
+
499
+ | # | Item | Effort | Test gate | Notes |
500
+ | --- | --- | --- | --- | --- |
501
+ | 1 | **Hotfix bundle**: D2 slice-before-head; D1 capability gate + server-config.md correction; D3 route curve/`/signal`(/adapter-unmount) through `gateway.runExclusive`; stale comment server.ts:960 | **S** | gated batch suites unchanged; fast suite; new unit: willBatch(Qwen)=false | no numerics change anywhere |
502
+ | 2 | **D5/D6 failure + fairness**: per-row onToken containment (evict row, keep batch); `#admit` try/finally; drain-on-serial-waiter | **S–M** | new gated test: throwing onToken evicts one row, siblings complete; drain test (serial completes under sustained batchable load) | mlx-lm `remove` semantics |
503
+ | 3 | **(d) engine hygiene**: chunked+interleaved admit, pipelined decode, clearCache cadence | **M** | KL suite + mlx-lm goldens UNCHANGED; bench-serving-load A/B (clean machine, Josh-gated for quotables) | closes perf-review backlog #6 |
504
+ | 4 | **(a) `BatchedKVCache` + `extend`** (full + rotating) | **M** | extended dynamic golden (mlx-lm `extend` driven); existing suites green | new files only — no gemma4-base.ts conflict |
505
+ | 5 | **(c) KV-budget admission + `--prompt-concurrency`** | **M** | projection unit tests; gated queueing test; `/stats` fields | depends on 3 (interleaved admit) for prompt-concurrency semantics |
506
+ | 6 | **(b) prompt-cache reuse under batching** | **M–L** | `cached_tokens>0` gated test; warm==cold row parity; serial suite | depends on 4 (`extract`) + 5 (byte interplay) |
507
+ | 7 | **(e) gate widening**: e1 min_p/XTC (S) → e2 logprobs (M) → e3 penalties/logit_bias (M) | **S+M+M** | per-feature: batched==serial teacher-forced/deterministic-seed tests | e1 waits for the seed fix to merge |
508
+ | 8 | **(h) SSM batched path** (Qwen3.5) | **M–L** | mlx-lm B=N golden for a Qwen3.5 model (ArraysCache oracle) | un-does the item-1 gate for Qwen; doc row flips back |
509
+ | 9 | **(g) docs pass** | **S** | doc-vs-code re-audit (this file's §1 as the checklist) | rolling — update per landing, final sweep here |
510
+ | 10 | **(f) quantized-KV batched** | **L** | KL + 6-task + density benchmark — design doc first | DEFERRED — needs a concrete density case |
511
+
512
+ D7's small items (shared `shapeFor()`, `maxTokens` fallback unification, real
513
+ batched timing stats, filter min-left-pad shift, shared emit-contract test)
514
+ slot into whichever of steps 2–4 touches the neighboring code; none justify a
515
+ standalone step.