mlx-bun 0.0.9 → 0.0.10

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.
@@ -0,0 +1,232 @@
1
+ # Batching perf path — promote continuous batching out of compat mode
2
+
3
+ Status: P5 + two gate fixes LANDED (2026-07-02, same day); rest PLANNED.
4
+
5
+ **Headline result (M1 Max 32 GB, loaded machine, 4 concurrent × 128 tok,
6
+ median of 3, wall-clock aggregate; oMLX 0.4.5.dev1, same OptiQ snapshots):**
7
+
8
+ | Model | oMLX | mlx-bun `--batch 4` | mean TTFT (theirs/ours) |
9
+ | --- | --- | --- | --- |
10
+ | MiniCPM5-1B | 339.0 | **345.4** | 397 / **119 ms** |
11
+ | gemma-4-e4b | **89.8** | 87.1 (−3%) | 815 / **397 ms** |
12
+ | Qwen3.5-4B | **100.9** | 99.5 (−1%) | 848 / **368 ms** |
13
+
14
+ What landed (all bit-gated, `tsc` 0, suites green):
15
+ 1. **P5 — SSM batched path** (`SSMCache.mergeRows`/`filter` in
16
+ `src/model/qwen3-delta.ts`; "ssm" kind in `batch-scheduler.ts` merge/wrap/
17
+ filter; per-row RoPE via `ropeOffsetArr` in `qwen3_5.ts`; gate relaxed in
18
+ `generation-gateway.ts`, kill switch `MLX_BUN_BATCH_SSM=0`). Oracle:
19
+ `tests/qwen35-batched-parity.test.ts` — solo-prefill+merge batched greedy
20
+ is **token-for-token identical** to mlx-lm B=2 left-padded batch
21
+ (`goldens/apple-m1-max/batched-golden-qwen35.json`). No ssm_mask needed:
22
+ rows solo-prefill unpadded, decode feeds one real token per row.
23
+ 2. **Logits processors batch** (`generation-gateway.ts`): the per-row sampler
24
+ folds `makeLogitsProcessors` over a per-row device-side token history
25
+ (generate()'s pushHistory), so repetition/presence/frequency penalties +
26
+ logit_bias + min_p/XTC no longer route serial. Load-bearing discovery:
27
+ **Qwen3.5 ships a default repetition penalty in generation_config.json**,
28
+ which routed EVERY request serial under `--batch N` — the SSM port alone
29
+ changed nothing over HTTP until this landed.
30
+ 3. Diagnostic breadcrumb: `MLX_BUN_LANE_DEBUG=1` logs per-request lane
31
+ decisions + shape (server.ts) — how (2) was found.
32
+
33
+ Bench-method traps found while measuring — **both FIXED 2026-07-02**:
34
+ - `serve`/`bench` accepted `--model` but IGNORED it (positional-only fuzzy
35
+ query; auto-pick silently loaded the wrong model). Now: `--model
36
+ <path-or-query>` is an explicit override — a directory with config.json
37
+ loads via `scanSnapshot` (no registry dependency), anything else resolves
38
+ like the positional. Precedence: `--model` > positional > `--query` >
39
+ auto-pick.
40
+ - Event-loop starvation during generation was the SERIAL lane, not batch
41
+ (batch's drive loop yields per step — `/stats` answered in ~8 ms mid-
42
+ generation once measured properly): serial decode is an unbroken microtask
43
+ chain, so `/stats`//health/new accepts stalled for the whole generation
44
+ (measured 2.5 s on a 512-token run). Fixed with a ≥25 ms rate-limited
45
+ macrotask hop wrapped around onToken in `GenerationGateway.run`'s serial
46
+ branch — covers all four API surfaces, streaming and not. After: `/stats`
47
+ 10–44 ms mid-generation, decode unchanged (cpm5 serial 193 tok/s). Motivated by the oMLX head-to-head (M1 Max,
48
+ 2026-07-02): at 4 concurrent requests oMLX's continuous batching gets
49
+ 1.46–1.89× our aggregate throughput (Qwen3.5-4B 97.3 vs 66.5 tok/s;
50
+ MiniCPM5 328 vs 173; e4b 85 vs 52) and our mean TTFT queue-inflates to
51
+ 1.1–3.8 s. Single-stream we match or beat them (e4b 55.8 vs 53.7,
52
+ Qwen3.5 66.3 vs 63.9, TTFT 3–7× better everywhere); the gap is entirely
53
+ the serial lane going flat under parallel load. Full comparison + numbers:
54
+ the 2026-07-02 oMLX report (Claude artifact) and `~/.omlx` bench scripts.
55
+
56
+ Design stance: the batch lane grows the **same parity-tier DAG as the
57
+ serial lane** (docs/design/parity-tier-dag.md). `--l1 --batch N` stays
58
+ bit-exact mlx-lm-B=N compat mode forever (the oracle checkpoint); the
59
+ default/`--l3` batch path becomes the optimized one. `--batch` remains a
60
+ mode switch, not a load fallback.
61
+
62
+ ## 1. Current state
63
+
64
+ `src/serve/batch-scheduler.ts` (`BatchScheduler`) is a real
65
+ continuous-batching engine: iteration-level scheduling, pipelined decode
66
+ (`#step` via `ops.asyncEvalAll`, mlx-lm `GenerationBatch._step`
67
+ semantics), chunked joiner prefill (2048-token chunks) interleaved with
68
+ decode, per-row eviction (`filterKVRows` / `BatchedRotatingCache.filter`),
69
+ per-row failure containment, drain-on-serial-waiter, `clearCache` every
70
+ 256 steps. Bit-parity with mlx-lm B=N verified at B=2 across all 4 model
71
+ paths (`tests/batched-decode-parity.test.ts`, `tests/batched-rotating.test.ts`,
72
+ `tests/batch-scheduler.test.ts`).
73
+
74
+ `src/serve/generation-gateway.ts` (`GenerationGateway.willBatch`) routes;
75
+ one `AsyncMutex` is the GPU/`loraState` exclusion domain. The batch lane
76
+ is deliberately compat mode: it calls `model.forwardHidden` /
77
+ `logitsFromHidden` directly, bypassing `generate()` and every perf lever.
78
+
79
+ ### Levers the batch lane lacks, and why
80
+
81
+ | Lever | Serial mechanism | Why batch can't use it today |
82
+ | --- | --- | --- |
83
+ | Quantized KV (`kv_config`) | `maybeQuantizeKv` (`src/generate.ts:101`) | `QuantizedKVCache` (`src/model/gemma4-base.ts:419`) has no dynamic-B ops (no merge/filter/extend/temporalView over the packed/scales/biases triples); `willBatch` hard-gates `kvQuant` → serial |
84
+ | Perf-kernel fused decode SDPA | dispatched from `quantizedSdpa` (`gemma4-base.ts:1560`) | kernel M=1-specialized: `fused-decode-kernel.ts:262` rejects `B !== 1 \|\| L !== 1`, and requires empty mask (batched decode needs per-row left-pad validity) |
85
+ | Fused N-tiled SDPA (`--fused-decode`) | `quantizedSdpaTiled` | needs 2-D mask; batched masks are 4-D `[B,1,1,S]` (`buildBatchedDecodeMask`); quantized-only anyway |
86
+ | Compiled decode | `CompiledDecode.for()` (`generate.ts:518-525`) | trace is `[1,1]`; B changes every join/evict |
87
+ | Prompt-cache prefix reuse | `PromptCache.take/put` in `runGeneration` | bypassed by design in v1; every batched row solo-prefills (`cachedTokens: 0`) |
88
+ | Vectorized sampling | n/a | `#step` samples per row (B slice+sample+concat graphs per token) |
89
+
90
+ Two more gap sources specific to the benchmark:
91
+
92
+ - **Qwen3.5 never batches at all**: `SSMCache` (`src/model/qwen3-delta.ts`)
93
+ has no merge/filter, so the capability gate routes every request serial.
94
+ The Qwen3.5 concurrency loss is pure serial queueing. mlx-lm batches
95
+ Mamba-family state via `ArraysCache` — oracle exists.
96
+ - Joins re-merge the whole batch (O(B·S) slice storm, `#mergeJoiner`)
97
+ instead of mlx-lm's `extend`.
98
+
99
+ ## 2. Gap analysis: quantized KV + fused kernels at B>1
100
+
101
+ - `QuantizedKVCache` already stores `[B,H,S,D']` triples; missing is
102
+ dynamic-B surgery. New `src/model/batched-quant.ts`:
103
+ `BatchedQuantizedKVCache` = quantized twin of `BatchedRotatingCache`
104
+ (merge/extend/filter/extract/temporalView = the `mergeKVRows`/`filterKVRows`
105
+ B-axis ops from `src/model/batched-mask.ts:171,196`, applied to all three
106
+ triples). Left-pad must align to quantization group boundaries — pad in
107
+ whole 256-token STEP granules (both caches already grow in STEP=256).
108
+ - Conversion point mirrors `maybeQuantizeKv`: row solo-prefills bf16,
109
+ `toQuantized` per layer per `kv_config` before merge.
110
+ - **Scope cut**: shipped `kv_config.json` quantize full-attention layers
111
+ only ⇒ P1 = quantized full layers + bf16 `BatchedRotatingCache` sliding
112
+ layers (the serial `config` composition). Batched rotating-quant deferred.
113
+ - Correctness fallback: `quantizedSdpaUnfused` (`gemma4-base.ts:1273`) is
114
+ already B-generic with `mask.mode === "array"`; needs the batched mask
115
+ broadcast as `[B,1,1,1,N]` for the 5-D GQA score reshape.
116
+ - Perf kernel at B>1: (1) drop the `B !== 1` gate (keep `L !== 1`);
117
+ (2) grid gains a B axis (`[TG_THREADS, H·B, G]`), `SCORE_ROW` offsets
118
+ index `((b·KV + kv)·N + n)`; (3) replace the empty-mask requirement with
119
+ a per-row `leftPad` int32 `[B]` input — each row's n-loop starts at
120
+ `max(blockStart, leftPad[b])`, so pad bytes are never read.
121
+ - RoPE per-row positions already solved (`ropeOffsetArr`, the S1b.1 fix).
122
+
123
+ ## 3. Phases
124
+
125
+ ### P0 — Baseline + engine quick wins (no new numerics) [S–M, 2–4 days]
126
+ - Write `scripts/bench-serving-load.ts` (N concurrent vs live server,
127
+ aggregate tok/s + TTFT p50/p95). Re-measure vs oMLX **with `--batch 4`
128
+ actually on** to size the true engine gap vs the serial-queueing gap.
129
+ - Land `extend` join (full-attention twin + `BatchedRotatingCache.extend`);
130
+ oracle: extend `scripts/gen-batched-dynamic-golden.py` (mlx-lm `extend`).
131
+ - Vectorize the homogeneous-sampler fast path in `#step` (all-greedy →
132
+ one `ops.argmaxAxis` on `[B,V]`); mixed rows keep the loop.
133
+ - Exit: gated suites unchanged; B=4 baseline recorded; joins not O(B·S).
134
+
135
+ ### P1 — Quantized KV at B>1 [M–L, 1–1.5 wk]
136
+ - `src/model/batched-quant.ts` + scheduler wiring (layer kind
137
+ `"full" | "rot" | "qfull"`); solo-prefill bf16 → `toQuantized` → extend.
138
+ - Batched mask 5-D broadcast; validate `quantizedSdpaUnfused`.
139
+ - `willBatch`: `kvQuant` stops being a serial gate when quant layers are
140
+ full-attention-only.
141
+ - Exit (parity ladder; no external oracle — mlx-lm quant batching NYI):
142
+ (1) B=1-row batched-quant **bit-exact vs serial `--kv-quant config`**;
143
+ (2) row-isolation within the calibrated bf16-reduction envelope;
144
+ (3) KL vs batched-bf16 ≤ serial quant-vs-bf16 KL; 6-task eval unchanged;
145
+ (4) `--l1 --batch N` suites untouched.
146
+
147
+ ### P2 — Perf kernel at B>1 + fallback matrix [M, ~1 wk]
148
+ - Kernel edits per §2; dispatch when every row supported, else unfused.
149
+ - Fallback matrix documented in server-config.md.
150
+ - Batched frozen-oracle envelope (B=2/4 `freeze-perf-oracle` variant,
151
+ ≥56/64 per row, `tests/perf-kernel-oracle.test.ts` pattern).
152
+ - Exit: dispatch counter proves engagement; envelope green; measured
153
+ uplift at B=4 vs P1 unfused on a clean machine; if <5%, ship the
154
+ fallback matrix only and close honestly.
155
+
156
+ ### P3 — Admission, prompt cache, adapters, defaults [M–L, 1–1.5 wk]
157
+ - KV-budget admission: `projectKvBytes(model, promptLen, maxTokens)`
158
+ beside `fit()`; `--kv-budget`, `--prompt-concurrency`;
159
+ `/stats.batch.{pending_rows,kv_bytes,kv_budget_bytes}`.
160
+ - Prompt-cache reuse under batching: `take` at admit, forward uncached
161
+ tail; on finish/evict `extract(row)` → solo caches → `put`. Quantized
162
+ entries are non-trimmable mid-group — match on full-prefix or STEP
163
+ boundaries. Real `prefillMs/decodeMs`; `BatchStats.cachedTokens` real.
164
+ - Single-adapter batches: adapter-spec = batch compatibility key
165
+ (join if match, drain if not; `loraState.active` set once per epoch).
166
+ - Default review: if batch lane at 1 live row is within ~2% of serial,
167
+ propose flipping the shipped default to `--batch 4` (Josh decision,
168
+ numerics change vs serial default); else README recommends `--batch 4`
169
+ for agent workloads.
170
+ - Exit: TTFT p50 <500 ms / p95 <1.5 s at 4 concurrent short prompts;
171
+ `cached_tokens>0` on repeats under `--batch 2`; oversized-request
172
+ queueing test (no OOM); B=4 aggregate within 15% of oMLX on cpm5 + e4b.
173
+
174
+ ### P4 — Burst decode [REVISED after reading + porting oMLX's source]
175
+ - **oMLX-style event-loop bursting: BUILT AND REFUTED (2026-07-02).** Read
176
+ their actual implementation (`omlx/engine_core.py _step_burst` — full
177
+ source ships in `/Applications/oMLX.app/Contents/Resources/omlx/`): it is
178
+ NOT a GPU trick — it runs K `scheduler.step()` calls per
179
+ `run_in_executor` hand-off with adaptive time budgets (100 ms single /
180
+ 30 ms concurrent, 64-step cap) because each per-token hand-off ping-pongs
181
+ the GIL with asyncio/uvicorn (~1 ms/token; their comment: 80 vs 74 tok/s).
182
+ Ported faithfully to `BatchScheduler.#drive` and measured: **regression**
183
+ — cpm5 B=4 345→289, batch-lane B=1 149→121, TTFT +~100 ms (SSE flushes
184
+ wait out the budget). Bun's `setImmediate` hop costs microseconds — no
185
+ GIL to dodge — so the port kept the cost and lost the win. Reverted;
186
+ numbers restored (cpm5 B=4 349.1). Breadcrumb comment left at the drive
187
+ loop. This also explains their ~21-token SSE bursts (measurement trap in
188
+ the comparison report).
189
+ - **Still open (different mechanism, not refuted): device-side step
190
+ chaining** — generalize `generateInner`'s pipeline to depth k (k chained
191
+ step graphs fed device-side, one host sync per k tokens). That attacks
192
+ OUR real per-token cost (FFI graph-build + sync readback), which is not
193
+ what oMLX's burst attacks. Exit unchanged: bit-exact vs depth-1, cpm5
194
+ single-stream gap vs oMLX (178 vs ~214) is the target; no regression at
195
+ k=1.
196
+
197
+ ### P5 — SSM batched path (Qwen3.5) [DONE 2026-07-02 — see header]
198
+ - Landed in one day, much cheaper than the ~1 wk estimate: `ssm_mask` under
199
+ left-padding turned out unnecessary (our join model solo-prefills unpadded,
200
+ so SSM state never sees a pad token; mlx-lm's masked-pad prefill is
201
+ equivalent — proven by the token-exact oracle match). Batch ops are plain
202
+ B-axis concat/take on the two state slots.
203
+ - Bonus fix that the exit benchmark forced out: logits processors now batch
204
+ (per-row fold; Qwen3.5's default repetition penalty had been routing every
205
+ request serial). Exit numbers in the header table.
206
+
207
+ Ordering: P1→P2 strictly ordered; P4 and P5 independent (P5 first if the
208
+ Qwen3.5 headline matters most). Total ~5–6 wk serial, ~4 wk with P4/P5
209
+ parallel.
210
+
211
+ ## 4. Risks
212
+
213
+ - **No external oracle for quant-batched** — mitigated by the P1 parity
214
+ ladder; `--l1 --batch N` stays the always-reachable bit-exact checkpoint.
215
+ - **Metal kernel at B>1**: leftPad start-index never touches pad bytes;
216
+ no atomics (deterministic); gate = batched frozen-oracle envelope +
217
+ dispatch assertions + A/B vs unfused.
218
+ - **Memory**: B× quant caches + prompt cache + weights on 24 GB — P3
219
+ admission is the backstop; mirror mlx-lm's
220
+ `trim_to(total − activeBatchBytes)` so LRU and live batch share one
221
+ ceiling. GPU OOM is uncatchable — queueing test before default changes.
222
+ - **Ring-wrap at B>1 short-context-verified only**: land a long-context
223
+ bf16 batched golden before any P3 default flip. Quantized rotating out
224
+ of scope.
225
+ - **Default flip changes numerics** vs today's serial default — explicit
226
+ Josh decision with doc trail.
227
+ - **Benchmark hygiene**: quotable numbers via `bench-serving-load.ts` on a
228
+ clean machine; TTFT at the SSE first-token event, not server-side
229
+ (oMLX's own server log over-reports by excluding ~180 ms/req cache
230
+ bookkeeping — measure them wall-clock only).
231
+ - **Regression fence**: all existing gated batch suites + the serial suite
232
+ green unchanged at every phase.
@@ -0,0 +1,90 @@
1
+ # oMLX adoption map — every feature, where it slots, status
2
+
3
+ Status: LIVING (started 2026-07-02). oMLX (github.com/jundot/omlx, Apache
4
+ 2.0) is the systematic port source: read its actual implementation before
5
+ building (full source ships locally in
6
+ `/Applications/oMLX.app/Contents/Resources/omlx/`), port the IDEA into our
7
+ architecture, and keep it only if OUR benchmarks improve (the burst-decode
8
+ refutation below is the cautionary tale). Attribution: note derived code in
9
+ THIRD_PARTY_LICENSES.md; idea ports get a source comment.
10
+
11
+ ## Where oMLX sits in the fidelity-tier model
12
+
13
+ It doesn't slot at L1 or L2 — it decomposes. The tiers are NUMERICS
14
+ contracts (parity-tier-dag.md); oMLX is a Python appliance ON mlx-lm:
15
+
16
+ - Its stock forwards are mlx-lm's numerics = our existing **L1 oracle**
17
+ (no new oracle value; mlx-lm itself stays the reference).
18
+ - Its own numeric inventions (oQ, custom kernels, DFlash, MTP) are
19
+ oracle-less = **L3-class** here, and get our L3 gating (KL/eval/envelope
20
+ + kill switch) that they don't have upstream.
21
+ - Its product surface (SSD cache, EnginePool, menu bar, MCP, grammar) is
22
+ **tier-agnostic serving layer** — keyed by the effective tier/scheme,
23
+ never part of the decode-numerics DAG.
24
+
25
+ ## Scoreboard
26
+
27
+ ### Ported, kept (benchmarked wins)
28
+ | Feature | Theirs | Ours | Result |
29
+ | --- | --- | --- | --- |
30
+ | Continuous batching parity | scheduler on mlx-lm BatchGenerator | `--batch N` lane + P5 SSM port + per-row logits processors (2026-07-02) | cpm5 345 vs 339 (win), e4b −3%, Qwen3.5 −1%, TTFT 2–3× better (batching-perf-path.md) |
31
+ | SSD KV cold tier | `cache/paged_ssd_cache.py` (content-hashed blocks) | `src/ssd-cache.ts` + kv-store v2 (whole-entry spill, zero-copy mmap) | restart TTFT 12.1s→0.24s vs their 1–3s; 0% decode overhead vs their ~20% (ssd-kv-cold-tier.md) |
32
+
33
+ ### Ported, refuted (do not re-add without new evidence)
34
+ | Feature | Why it doesn't transfer |
35
+ | --- | --- |
36
+ | Adaptive burst decode (`engine_core.py _step_burst`) | Amortizes Python GIL/asyncio ping-pong (~1ms/token). Bun has no GIL; faithful port REGRESSED cpm5 B=4 345→289 + TTFT +100ms. Reverted with breadcrumb (batching-perf-path.md P4). |
37
+
38
+ ### Queue (roughly by leverage; ★ = Josh explicitly wants)
39
+ 1. **Structured output / JSON-schema constrained decoding** (`api/grammar.py`)
40
+ — serving layer. The biggest API-surface hole vs both oMLX and agent
41
+ frameworks. We own the sampler loop; a token-mask grammar engine slots
42
+ beside the existing sampler extensions. Serial lane first, batch lane
43
+ via the per-row processor fold (already built).
44
+ 2. **★ Menu bar app** (`apps/omlx-mac/`, native SwiftUI + Sparkle) — product
45
+ layer. Their repo is the structural reference; our signed/notarized
46
+ single binary is the sidecar. Supersedes tauri-desktop-app.md's Electron
47
+ ambivalence: native SwiftUI is the received-well shape.
48
+ 3. **Multi-model serving / EnginePool** (`engine_pool.py`: LRU + pinning +
49
+ load/unload API) — serving layer. Fits our single-USER framing as model
50
+ SWITCHING (one active, LRU-evict the rest), not concurrent tenants.
51
+ 4. **oQ-style sensitivity-driven quantization** (`oq.py`,
52
+ docs/oQ_Quantization.md) — L3-class, lands in `convert` beside
53
+ `--target-bpw`: calibration-measured per-layer sensitivity, boosts on
54
+ non-expert tensors only, batched expert GPTQ. Gate: perplexity + 6-task
55
+ eval vs our knapsack at equal bpw. (arXiv-lens candidate.)
56
+ 5. **DFlash serving wiring** (`engine/dflash.py` + their published -DFlash
57
+ drafts) — L3-class; unblocks DSpark (docs/design/dspark-speculative-decoding.md).
58
+ Their integration pattern: separate engine class, ctx-length fallback
59
+ routing, tape-replay cache rollback.
60
+ 6. **Vision feature cache** (`cache/vision_feature_cache.py`) — serving
61
+ layer; encoder features for repeated-image agent turns. Natural sibling
62
+ of the SSD tier (same keying discipline).
63
+ 7. **MCP tool execution in serve** (`--mcp-config`, `mcp_routes.py`) —
64
+ serving layer. Note overlap: Pi already owns tool orchestration for our
65
+ UIs — scope this to bare-API consumers; don't duplicate Pi.
66
+ 8. **Prefill progress observability** (`prefill_progress.py`) — /stats +
67
+ web UI surfacing for long prompts. Pairs with the serial-lane hop fix.
68
+ 9. **Rerank endpoint** (`/v1/rerank`) — needs a reranker model family
69
+ first; park until a Qwen3-Reranker-class port is wanted.
70
+ 10. **Document ingestion (MarkItDown)** — product layer; ours would go
71
+ through the chat UI / Pi attachments, not the server.
72
+ 11. **Admin one-click benchmark with prefix-cache-hit testing** — fold into
73
+ bench-serving-load.ts as a `--cache-hit-ratio` mode instead of a UI.
74
+
75
+ ### Explicitly not porting
76
+ - **Audio (STT/TTS/STS)** — audio tower deferred (project scope).
77
+ - **Their SSE burst streaming** — side effect of the GIL workaround;
78
+ per-token streaming is strictly better UX at zero cost for us.
79
+ - **Python-side scheduler details** (GIL executors, collector reaping) —
80
+ runtime-specific.
81
+
82
+ ## Porting discipline (learned this week)
83
+ 1. Read their real source first (local app bundle) — the README lies less
84
+ than the code.
85
+ 2. Port the idea into OUR architecture; never transliterate Python.
86
+ 3. Benchmark before/after on THIS machine; keep only wins (burst decode
87
+ died here). Wall-clock metrics only — their own logs over-report.
88
+ 4. Numeric features get L3 gates + kill switches; serving features get
89
+ the effective-scheme keying; nothing touches L1/L2 contracts.
90
+ 5. Verify the served model id (/v1/models) before trusting any bench.
@@ -184,6 +184,21 @@ quant. The trivial case to validate the machinery on a real model.
184
184
 
185
185
  ## 5. The segmentation strategy (the part to get right)
186
186
 
187
+ > **2026-07-02 CORRECTION — this section's cost model is WRONG, measured.**
188
+ > The full-attention isolation planner was built and A/B'd @8K on real chunk
189
+ > rows (scripts/experiments/seg-isolation-smoke.ts, MLX_BUN_SEG_MEM_LOG=1
190
+ > phase probes): ZERO peak win (18.09 vs 18.02 GB seg2). The premise "sliding
191
+ > layers are O(L·window) in the backward" is false — mlx's sdpa BACKWARD
192
+ > materializes the O(L²) scores for EVERY layer (~3.5 GB/layer @8K e4b; the
193
+ > sliding window is only an additive mask; a sliding PAIR costs +7.1 GB ≈
194
+ > sliding+full's +7.15). The worst segment is therefore set by LAYER COUNT
195
+ > alone and `segment_size` is the whole knob: **seg1 = 14.59 GB @8K, +3% step
196
+ > time, loss identical — fits the 24 GB M4 Pro today.** The head vjp adds
197
+ > ~3 GB (full-[M,V] responseOnlyCe — kernel-review backlog #8). Going below
198
+ > ~14 GB needs that head fix plus an O(L)-memory attention backward (flash
199
+ > track), not smarter segmentation. Section kept for the (still-correct)
200
+ > donor/boundary constraints; ignore its sliding-vs-full peak arithmetic.
201
+
187
202
  NOT a naive "divide by N". Compute the split points from the model:
188
203
 
189
204
  **Constraints & inputs (all readable from the model/config):**
@@ -0,0 +1,158 @@
1
+ # SSD cold tier for the prompt/KV cache
2
+
3
+ Status: **LANDED P1–P3 (2026-07-02, same day)** — P4 hardening extras remain.
4
+
5
+ **Measured (M1 Max, MiniCPM5, 13.7k-token prefix, kv_config quant):**
6
+ - Restart TTFT: **12,083 ms (full prefill) → 236 ms** restored — 2% of full
7
+ prefill, 13,675 tokens back via a 3 ms zero-copy mmap. Beats oMLX's 1–3 s
8
+ claim and both exit criteria (<2.5 s, ≤10%).
9
+ - Steady-state decode: **0% overhead** (192.0 vs 192.3 tok/s, flag off/on) —
10
+ nothing runs on the token loop, as designed (vs oMLX's measured ~20%).
11
+ - Correctness: SSD-restored + trimmed continuation is **identical to the RAM
12
+ tier's cache-hit output** (control experiment); divergence from a fresh
13
+ full prefill is the pre-existing bf16 prefix-reuse property both tiers
14
+ share, not introduced by persistence.
15
+
16
+ What landed: kv-store v2 (`src/kv-store.ts` — all five cache kinds incl.
17
+ quantized + SSM, invalidation metadata, per-tensor hashes, atomic writes;
18
+ `restoreState` added to both quantized cache classes), `src/ssd-cache.ts`
19
+ (SsdCacheStore per D4), tier integration (`src/prompt-cache.ts` spill hook +
20
+ retain thunks; `src/server.ts` tiered take with post-restore trim,
21
+ debounced write-behind snapshots under the gateway lock, `/stats.ssd_cache`),
22
+ flags `--ssd-cache <dir>` / `--ssd-cache-max <GB>` / `--ssd-cache-verify`.
23
+ Tests: `tests/kv-store.test.ts` (fork-compare token-identical for bf16,
24
+ quantized, SSM), `tests/ssd-cache.test.ts` (restart recovery, quarantine,
25
+ corruption, eviction, supersede). One deviation from the plan below: find()
26
+ returns PARTIAL prefix matches and the server trims after restore
27
+ (divergent-tail conversations — full-entry-only missed the real pattern). Inspired by oMLX's paged SSD cache
28
+ (Apache 2.0 — `omlx/cache/paged_ssd_cache.py`, `boundary_snapshot_store.py`;
29
+ we port ideas, not code — add a `THIRD_PARTY_LICENSES.md` note if any code
30
+ ends up derived). Their headline: coding-agent TTFT 30–90 s → 1–3 s by
31
+ restoring long prefixes from SSD across requests AND restarts. Their
32
+ measured costs (independent tests): ~2–3× cold TTFT and ~20% steady-state
33
+ decode overhead — this design exists to get the win without those costs.
34
+ Flag-gated, off by default.
35
+
36
+ **Head start**: `src/kv-store.ts` already implements page-aligned KV
37
+ persistence (`MLXBUNKV1`, 16 KiB-aligned tensors) with zero-copy COW mmap
38
+ reload (`MmapFile.open(path, "cow")` → `MlxArray.fromPointer` — exactly
39
+ the Metal page-alignment constraint, proven by expert-offload), including
40
+ mid-rotation `RotatingKVCache` restore (`restoreState`,
41
+ `src/model/gemma4-base.ts:880`). `scripts/experiments/cold-start.ts` is a
42
+ working sub-1 s cold-restore harness. The SSD tier is wiring +
43
+ quantized-cache support + an index/eviction/recovery layer.
44
+
45
+ ## Current state / gaps
46
+
47
+ - `src/prompt-cache.ts`: byte-capped LRU RAM tier (default 2 GB,
48
+ `--prompt-cache`), prefix-entry based, adapter-namespaced, serial lane
49
+ only, race-free under the gateway lock. **A restart loses everything.**
50
+ - `kv-store.ts` gaps: (1) no quantized kinds — `saveKvCache` throws for
51
+ `QuantizedKVCache`/`RotatingQuantizedKVCache` and neither has
52
+ `restoreState`, yet serving defaults to the model's `kv_config.json`
53
+ scheme, so the default production cache can't persist today; quantized
54
+ state is 6 arrays/layer (packed u32 + scales/biases bf16 × K/V);
55
+ (2) no invalidation metadata / checksums / atomic writes; (3) latent
56
+ bug: `loadKvCache` checks `caches.length !== model.layers.length` but
57
+ `makeCache()` returns donor layers only — wrong for KV-shared e4b
58
+ (verify in Phase 1).
59
+ - Versioning primitive exists: `configFingerprint`
60
+ (`src/model/fingerprint.ts`) — sha256 over graph-shaping config incl.
61
+ `kvQuant`.
62
+
63
+ ## Design decisions
64
+
65
+ **D1 — Whole-prefix-entry spill, not paged blocks.** oMLX pages for
66
+ multi-user shared-prefix dedup; we are single-user with a prefix-entry
67
+ cache that grows monotonically per conversation. Paging is exactly where
68
+ their overheads come from: per-block SHA256 + save-queue bookkeeping
69
+ *during decode* (their ~20% tax) and block-granular safetensors restore
70
+ with copies (their 2–3× cold TTFT). Ours: **zero work on the token loop**
71
+ (prefix tokens ARE the key), spill/persist only at request boundaries and
72
+ evictions, restore = one zero-copy mmap with lazy page fault-in (~5 GB/s
73
+ NVMe; 2 GB entry ≈ 0.4 s amortized into the first forward). Partial-prefix
74
+ reuse works without paging: restore + `Cache.trim()`. Revisit paging only
75
+ for multi-user batch dedup (v2, out of scope).
76
+
77
+ **D2 — One store, two tiers.** `PromptCache` grows a cold tier behind the
78
+ same `take`/`put` API:
79
+ - RAM→SSD: `put()`'s evict loop spills (snapshot bytes → async writer →
80
+ dispose GPU arrays) instead of just disposing.
81
+ - SSD→RAM: `take()` prefers a strictly-longer SSD prefix; restored entry
82
+ carries its `MmapFile`, unmapped on entry dispose (extensions copy into
83
+ fresh anonymous buffers via concat-grow anyway).
84
+ - **Restart persistence needs write-behind at request completion**, not
85
+ just spill-on-evict: after `promptCache.put(...)` in `runGeneration`
86
+ (`src/server.ts:1019`), a debounced, coalesced-per-ns async snapshot.
87
+ Byte extraction (the only GPU-thread work) runs on the idle serial lane
88
+ between requests; file I/O is async. (oMLX's boundary-snapshot idea at
89
+ whole-entry granularity.)
90
+
91
+ **D3 — Extend the existing format to `MLXBUNKV2`**, not safetensors
92
+ (safetensors offsets aren't page-aligned; oMLX pays a copy via `mx.load`).
93
+ Add: `"qkv"`/`"rotating-qkv"` kinds (six tensor slots + groupSize/bits;
94
+ slice quantized buffers to `[.., :offset, :]` — don't persist 256-step
95
+ slack), header fields `formatVersion`/`modelId`/`configFingerprint`/`ns`/
96
+ `tokenizerHash`/`createdAt`, per-tensor checksums + header checksum.
97
+ Integrity: header checksum always verified on scan; full verification
98
+ opt-in (`--ssd-cache-verify`) because eager reads destroy lazy fault-in;
99
+ default trusts atomic writes (tmp + fsync + rename). Corrupt/incompatible
100
+ → delete + log, never fatal. `loadKvCache` becomes model-generic
101
+ (accept `{ makeCache(): Cache[] }`, donor-count check).
102
+
103
+ **D4 — Files are the database.**
104
+ `<ssd-dir>/<configFingerprint>/<nsHash>/<uuid>.mlxkv`. Startup recovery =
105
+ header-only directory scan → in-memory `SsdIndex`
106
+ (`{ns, tokens, path, bytes, mtimeMs}[]`); tokens live in the header JSON.
107
+ LRU = file mtime (`utimes()` on hit); byte cap enforced at write time by
108
+ oldest-mtime eviction; disk-full → drop write, warn once, keep serving.
109
+
110
+ **D5 — Flags & interactions.** `--ssd-cache <dir>` +
111
+ `--ssd-cache-max <GiB>` (default 32) → `ServerOptions.ssdCacheDir/MaxBytes`.
112
+ Requires the RAM tier (`--prompt-cache 0` + `--ssd-cache` = startup
113
+ error). `--memory-budget` unchanged (restored caches are clean file-backed
114
+ pages until dirtied; admission already assumes worst-case bf16). `/status`
115
+ gains `ssd: { entries, bytes, max_bytes, restores, spills, restore_ms_last }`.
116
+ v1 is serial-lane only (where `PromptCache` lives); batch-lane interplay is
117
+ docs/design/batching-perf-path.md P3's problem.
118
+
119
+ ## Phases
120
+
121
+ - **P0 — Baseline bench (½ d)**: `scripts/experiments/ssd-cache-bench.ts`
122
+ grown from `cold-start.ts`: ≥16k-token prefix on the standard snapshot,
123
+ kv_config quant active; measure full-prefill TTFT / warm RAM-hit TTFT /
124
+ steady-state decode. Exit: baseline table committed.
125
+ - **P1 — kv-store v2 (2–3 d, highest uncertainty)**: `restoreState` on
126
+ both quantized cache classes; all four kinds in save/load; header v2;
127
+ atomic writes; model-generic load. Exit: round-trip tests all kinds
128
+ (incl. wrapped ring) + **greedy decode parity** — N tokens continued
129
+ from a restored cache bit-exact vs never-persisted; `tsc` 0.
130
+ - **P2 — `src/ssd-cache.ts` store (1–2 d)**: `SsdCacheStore
131
+ { scan, find(prompt, ns), store (async, extraction split from I/O),
132
+ evictToCap, remove, stats }`. Exit: unit tests — restart recovery,
133
+ incompatible-fingerprint quarantine, corrupt-header deletion, mtime cap
134
+ eviction, disk-full soft-fail.
135
+ - **P3 — Tier integration + flags (2–3 d)**: cold tier in `PromptCache`,
136
+ debounced end-of-request snapshots, CLI/`/status`. **Exit (headline)**:
137
+ (1) restart TTFT on a 16k prefix **< 2.5 s and ≤ 10% of full-prefill**
138
+ (beat oMLX's 1–3 s; a true-cold miss runs the unchanged prefill path);
139
+ (2) steady-state decode overhead **< 2%** vs flag off (vs their ~20%);
140
+ (3) flag absent ⇒ zero behavior change.
141
+ - **P4 — Hardening + docs (1–2 d)**: kill-during-write crash test,
142
+ adapter-ns isolation, kv-quant-scheme-change invalidation, README/help,
143
+ license note. Exit: suite green; on/off × cold/warm/restart matrix
144
+ recorded.
145
+
146
+ Total ~7–10 working days.
147
+
148
+ ## Risks
149
+
150
+ - **mmap lifetime vs mlx laziness**: unmap only in entry `dispose()` after
151
+ successor state is evaluated; COW mapping already covers buffer donation.
152
+ - **Byte-extraction stall on multi-GB entries**: runs between requests on
153
+ the idle lane; measure in P3; chunk with yields if needed.
154
+ - **COW dirty-page accounting**: extended restored caches migrate to
155
+ anonymous memory — probe with `phys_footprint` (expert-offload
156
+ discipline) before making footprint claims.
157
+ - **Stale-token poisoning**: `configFingerprint` + `tokenizerHash` + ns;
158
+ chat-template drift degrades to a shorter matched prefix (safe).
@@ -1,5 +1,11 @@
1
1
  # Decode perf "look again" — adversarial re-exam of the M=1 floor claim (2026-07-01)
2
2
 
3
+ > **2026-07-02 CORRECTION — the host-graph-tax claim is itself overturned; see
4
+ > §7.** The fix-plan items 1a–1d were built on "host graph build is serial with
5
+ > the GPU"; the overlap spike (decode-overlap-probe.ts) shows the pipelined loop
6
+ > ALREADY hides the build. The §3 wall times and %-of-roofline stand; the
7
+ > §4(b)/(c) decomposition does not — the whole gap is GPU-side.
8
+
3
9
  **Machine:** Apple M1 Max, 32 GB (MacBookPro18,2), `sysctl` verified. All eval-DB
4
10
  rows are also M1 Max — same box. **All numbers below are directional (session,
5
11
  loaded-machine caveat); quotable numbers remain benchmark.sh-gated.** GPU idle
@@ -225,12 +231,33 @@ is suspect, profile on the other laptop before building anything.**
225
231
  1d. **12B short-context: compile the concat phase too** (or accept 1b as
226
232
  the fix). The current segmented form compiles nothing below the 1024
227
233
  window and never compiles the 8 full-attention layers. ~+9% @600.
228
- 2. **26B GPU overhead (~4 ms/tok over floor):** profile gather-qmm expert
229
- reads (each expert slice ~3.3 MiB → the ~180 GB/s small-buffer regime);
230
- candidates: expert-contiguous layout so 8 experts read as fewer/larger
231
- spans, the open perf-kernel enablement (review #7, +1–3%), router-chain op
232
- fusion. Potential **+15–25% 26B decode** if expert reads reach big-GEMV
233
- bandwidth. Effort M (profile first).
234
+ 2. **26B GPU overhead (~4 ms/tok over floor):** ~~profile gather-qmm expert
235
+ reads~~ **PROFILED 2026-07-02** (moe-expert-read-profile.ts, exact 26B
236
+ shapes; real decode @600 re-measured 21.0–22.6 ms/tok ✓). The gap is real
237
+ and located, but BOTH cheap candidates are refuted: expert-contiguous
238
+ layout does nothing (a chain with ONE expert repeated ×8 — 1/8 the unique
239
+ bytes — runs at the same speed: not bandwidth/locality-bound), and merging
240
+ gate+up stacks to cut dispatches is SLOWER (9.6 vs 8.1 ms). Root cause:
241
+ **mx.gather_qmm at M=1 is compute-bound** (~99 GB/s effective vs the qmv
242
+ kernel's ~180 on identical bytes; upstream python mlx 0.31.2 reproduces
243
+ 6.18 vs 4.01 ms) — it lacks a qmv-class fast path, so each per-expert
244
+ [1,2816]→[704] product runs on mostly-idle GEMM tiles. The router chain
245
+ adds a secondary ~1.6–2.6 ms of tiny-op dispatches.
246
+ **Custom-kernel attempt (same day): built, correct, SHELVED.** A gather-qmv
247
+ Metal kernel (scripts/experiments/moe-qmv-kernel.ts + moe-qmv-parity.ts)
248
+ passes bounded-divergence on all three 26B dispatch patterns, but five
249
+ structural variants (occupancy/staging/bank-conflict/mask-trick sweeps —
250
+ post-mortem table in the kernel header) all measure SLOWER than gather_qmm
251
+ (best 12.5 vs 8–10 ms on the 30-layer chain), and the killer measurement is
252
+ structural: **mx.fast.metal_kernel dispatches cost ~60–95 µs each in a
253
+ dependent chain, nearly independent of the work inside** — at 3
254
+ dispatches/layer × 30 layers that fixed cost alone eats the entire ~4 ms
255
+ prize. Remaining routes, in order: (a) UPSTREAM — give mlx's gather_qmm an
256
+ M=1 qmv-class path in C++ (no dispatch overhead; natural companion to the
257
+ quantized_matmul M=2/3 bug report), (b) a fused whole-MLP kernel (gate+up+
258
+ gelu in one dispatch, down in a second → 60 dispatches/step) with a
259
+ qmv-class body — a dedicated fused-decode-v2-style session starting from
260
+ the post-mortem map.
234
261
  3. **e4b GPU overhead (1.8–3.7 ms):** dispatch-count reduction on the
235
262
  per-layer-input machinery (the ~1–2 MiB gate/proj GEMVs and altup
236
263
  elementwise chains; batch the 42 per-layer gate GEMVs into one [42·width]
@@ -247,7 +274,66 @@ is suspect, profile on the other laptop before building anything.**
247
274
  bandwidth wall (weight read shared across K tokens) — unchanged priority,
248
275
  now with the roofline to prove why nothing else can.
249
276
 
277
+ ## 7. The overlap spike (2026-07-02) — fix 1b REFUTED; §4(c) decomposition corrected
278
+
279
+ Fix 1b asked: can the host graph build hide under the GPU (build-two-ahead /
280
+ off-thread dispatch)? `decode-overlap-probe.ts` answers two ways — the
281
+ decisive one is DIRECT REMOVAL: take the per-step host build out of the loop
282
+ entirely (pre-build all N step graphs up front, chained through the lazy
283
+ argmax outputs — identical graph structure to production — then run
284
+ dispatch+read with zero interleaved build) and see if decode gets faster.
285
+
286
+ M1 Max, greedy, uncompiled, all arms token-IDENTICAL to baseline:
287
+
288
+ | arm | CPM5 @132 (spin 2 ms) | e4b @600 (spin 4 ms) |
289
+ |---|---|---|
290
+ | baseline (pipelined) | 4.05 ms/tok (graph 0.86, dispatch 3.13) | 19.02→18.06 across passes (graph ~3.3–3.6, dispatch ~14) |
291
+ | **prebuilt (host build REMOVED)** | **4.11 (+0.07)** | **19.03 (+0.01)** |
292
+ | prebuilt-1buf (also no per-step dispatch boundaries) | 4.33 | 19.73 |
293
+ | serial (evalAll, no pipeline) | **+1.75 ms** | **+3.36 ms** |
294
+ | spin-build / spin-pre / spin-post (+W injected host work) | **+0.4 ms** (not +2) | **+1.0–1.4 ms** (not +4) |
295
+ | chain-2 / chain-3 (deeper pipeline) | ±0.01 ms | +0.02 / −0.96 (== pass drift) |
296
+
297
+ - **Removing the work gains nothing**: with the entire 0.9 ms (CPM5) /
298
+ 3.6 ms (e4b) per-token build lifted out of the loop, decode speed is
299
+ unchanged (+0.07 / +0.01 ms, inside pass-to-pass drift). Even the one-buffer
300
+ floor — no host work AND no per-step dispatch boundaries — is no faster.
301
+ **The build already overlaps; wall = GPU step time.**
302
+ - The injection arms agree from the other direction: extra host work is
303
+ ABSORBED (dispatch shrinks 3.13→1.6 exactly as `gpu − host` predicts) — the
304
+ GPU is busy with step n while the host builds step n+1.
305
+ - The serial anchor costs MORE than the build itself (CPM5 +1.75 vs 0.86;
306
+ e4b +3.36 vs 3.6): the pipelined loop hides the build AND the per-step
307
+ launch gap. The loop shape is already right; mlx-lm's identical shape gets
308
+ the identical hiding.
309
+ - Deeper pipelining (chain-2/3) buys exactly nothing — there is no residual
310
+ host term to hide.
311
+
312
+ **Corrections to this doc:**
313
+ - §4(c) "host JS loop — the single biggest NON-physics term" is WRONG as an
314
+ additive claim. The "host graph ms" column was measured as time-in-JS-calls,
315
+ but that window runs concurrently with the GPU; "GPU ms" (= the dispatch
316
+ wait) is `gpu − graph`, so summing them reconstructs the wall without any
317
+ recoverable host term. The true GPU step time ≈ the measured wall itself.
318
+ - §6.1a (CPM5 CompiledDecode "+20–25%"): premise gone. Any compiled-decode win
319
+ is GPU-side (fewer/denser dispatches from C++ replay), not host removal —
320
+ re-bill as profile-first, expected small. This also re-explains §4(d):
321
+ e4b @600 compiled wall 18.9→18.1 means compiled made the GPU ~0.8 ms faster
322
+ (net of concat tax), not "host −2.4, GPU +1.7".
323
+ - §6.1b (this spike): answered, REFUTED — nothing to build.
324
+ - §6.1c/1d (SharedKv segmented-compile plumbing ~L effort, 12B concat-phase
325
+ compile): the host-tax share of their billing is gone; only GPU-side effects
326
+ remain. De-prioritize pending a GPU-side profile.
327
+ - §6 items 2 (26B expert reads), 3 (e4b dispatch count), 4 (backlog #4
328
+ contiguity), 5 (CPM5 KV-path), 6 (spec decode) are UNAFFECTED — they were
329
+ always GPU-side, and they are now the entire recoverable gap.
330
+ - 12B/26B not re-probed (structurally the same loop; 12B was at-wall under
331
+ either reading; 26B's 3.7 ms graph ≪ its ~18 ms step has the same slack).
332
+
250
333
  ## Probes added (scripts/experiments/, tsc-clean)
334
+ - `decode-overlap-probe.ts` — direct-removal (prebuilt / prebuilt-1buf) +
335
+ spin-injection + serial-anchor + chain-depth arms for the overlap question
336
+ (§7).
251
337
  - `decode-roofline-bw.ts` — machine bandwidth: raw read, decode-shaped qmv
252
338
  GEMV (multi-copy, one-eval chaining), bf16 GEMV, dispatch chain.
253
339
  - `decode-roofline-step.ts` — factory-generic pipelined decode split