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.
- package/docs/design/batching-perf-path.md +232 -0
- package/docs/design/omlx-adoption-map.md +90 -0
- package/docs/design/segmented-backward-training.md +15 -0
- package/docs/design/ssd-kv-cold-tier.md +158 -0
- package/docs/investigations/decode-roofline-lookagain.md +92 -6
- package/docs/investigations/kernel-perf-review-2026-07.md +105 -26
- package/docs/planning/release-notes-v0.0.10.md +57 -0
- package/docs/reference/server-config.md +3 -0
- package/docs/reference/training.md +6 -1
- package/package.json +1 -1
- package/src/cli.ts +49 -5
- package/src/generate.ts +8 -0
- package/src/kv-store.ts +240 -98
- package/src/mlx/array.ts +21 -0
- package/src/mlx/ops.ts +26 -0
- package/src/model/gemma4-base.ts +34 -1
- package/src/model/qwen3-delta.ts +48 -1
- package/src/model/qwen3_5.ts +11 -2
- package/src/prompt-cache.ts +55 -9
- package/src/registry.ts +4 -1
- package/src/serve/batch-scheduler.ts +41 -9
- package/src/serve/generation-gateway.ts +78 -19
- package/src/server.ts +135 -25
- package/src/ssd-cache.ts +213 -0
- package/src/train/flash-cce.ts +27 -24
- package/src/train/loss.ts +53 -11
- package/src/train/prefix-shared.ts +30 -7
- package/src/train/segmented.ts +150 -46
- package/src/train/trainer.ts +21 -11
|
@@ -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 (
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|