mlx-bun 0.0.4 → 0.0.5

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 (50) hide show
  1. package/README.md +166 -93
  2. package/docs/design/adapters-end-to-end.md +50 -46
  3. package/docs/design/compat-cli-surface-design.md +10 -8
  4. package/docs/design/docs-reorg-plan.md +14 -1
  5. package/docs/design/hlg-sampling.md +12 -2
  6. package/docs/design/memory-system.md +1 -1
  7. package/docs/design/optimization_plan.md +4 -2
  8. package/docs/design/orpo-future-enhancements.md +298 -0
  9. package/docs/design/orpo-training.md +1199 -0
  10. package/docs/design/parallel-slots.md +34 -26
  11. package/docs/design/runtime-isolation.md +1 -1
  12. package/docs/design/segmented-backward-training.md +58 -39
  13. package/docs/design/tauri-desktop-app.md +127 -0
  14. package/docs/{segmented-backward-handoff.md → investigations/segmented-backward-handoff.md} +1 -1
  15. package/docs/investigations/starter-model-port-handoff.md +32 -13
  16. package/docs/investigations/steel-flash-cce-handoff.md +431 -0
  17. package/docs/planning/IDEAS.md +29 -0
  18. package/docs/planning/PRODUCT_ROADMAP.md +147 -27
  19. package/docs/planning/chunk-task-roadmap.md +1 -1
  20. package/docs/planning/journal.md +161 -0
  21. package/docs/reference/distribution.md +61 -5
  22. package/docs/reference/library-api.md +29 -14
  23. package/docs/reference/orpo-quickstart.md +91 -0
  24. package/docs/reference/server-api.md +105 -7
  25. package/docs/reference/server-config.md +12 -5
  26. package/docs/reference/training.md +292 -0
  27. package/package.json +1 -1
  28. package/src/eval/ifeval.ts +138 -0
  29. package/src/eval/ultrafeedback.ts +60 -0
  30. package/src/lora.ts +12 -7
  31. package/src/mlx/metal-kernel.ts +18 -4
  32. package/src/model/flash-attention.ts +32 -16
  33. package/src/model/fused-geglu-kernel.ts +111 -0
  34. package/src/model/gemma4-base.ts +39 -1
  35. package/src/model/gemma4.ts +196 -21
  36. package/src/model/minicpm5.ts +44 -3
  37. package/src/server.ts +3 -3
  38. package/src/train/dataset.ts +18 -3
  39. package/src/train/flash-cce.ts +1162 -0
  40. package/src/train/job.ts +26 -2
  41. package/src/train/lora-params.ts +53 -4
  42. package/src/train/loss.ts +941 -0
  43. package/src/train/optimizer.ts +10 -3
  44. package/src/train/prefix-shared.ts +435 -0
  45. package/src/train/rank.ts +36 -0
  46. package/src/train/segmented.ts +1148 -3
  47. package/src/train/steel-qmm-header.ts +1461 -0
  48. package/src/train/trainer.ts +509 -52
  49. package/src/web/app.html +21 -5
  50. /package/{docs → src/assets}/curve-designer.html +0 -0
@@ -0,0 +1,431 @@
1
+ # Steel flash-CCE — handoff
2
+
3
+ Porting MLX's quantized `steel` GEMM verbatim into our flash-CCE ORPO head, then
4
+ fusing the ORPO-specific epilogue (softcap + online-softmax logp / softmax−onehot
5
+ coeff + dh) on top — to get **MLX-GEMM speed AND the `[M,V]`-free memory property**
6
+ in one kernel. MLX ships no fused "quantized-matmul + softcap + response-logp" op, so
7
+ we build the composition; the heavy GEMM is theirs, verbatim.
8
+
9
+ ## TL;DR status
10
+
11
+ | Piece | State |
12
+ |---|---|
13
+ | **Steel GEMM port** (verbatim MLX BlockMMA/QuantizedBlockLoader/qmm_t) | ✅ done, compiles, matches `quantizedMatmul` 1e-5, **101ms == MLX 106ms** on e4b |
14
+ | **Forward** (steel GEMM + softcap + online-softmax → logp) | ✅ **in production** (`flashCceForward` default), parity PASS e4b/cpm/M=200, **180ms** (5× the old 848ms) |
15
+ | **Backward** (steel GEMM ×2 + softmax−onehot coeff + dh accumulate) | ✅ **IN PRODUCTION** (`flashCceBackward` default, `BWD_STEEL_SOURCE`/`bwdSteelKernel`). Parity PASS e4b (dh 0.40%) + cpm (dh 0.28%); **754ms** @ e4b M=512 — phase-2 W dequant now uses MLX's fused `QuantizedBlockLoader` (HTw=group_size, reduction_dim=0), **5× the old SG's 3687ms exact** (was 1851ms with the scalar dequant). Peak **0.928 GB flat @ M=8192** (`[M,V]`-free preserved). Old SG kept as `MLX_BUN_CCE_BWD_NOSTEEL=1` fallback. |
16
+ | Old shipping backward | the **OLD hand-rolled SG kernel** (`[M,V]`-free, correct, slow 3687ms) — now the fallback only |
17
+
18
+ **So: BOTH directions are now fast + live (steel fwd 180ms, steel bwd 754ms, both
19
+ `[M,V]`-free + exact). M1 + the phase-2 dequant optimization DONE.** Backward is now
20
+ ~2.1× the ~360ms ideal (down from ~5×). Everything below (M2 prefix-share, M3 e4b probe,
21
+ M3-composition segmented+prefix, M4A integration tests) is also DONE — see sections.
22
+
23
+ ## Why this exists (the journey, so you don't repeat it)
24
+
25
+ - Our hand-rolled simdgroup_matrix GEMM runs at **~8% of M1 Max peak**; MLX's `steel`
26
+ quantized GEMM runs at **~46%**. We spent a long time micro-optimizing the
27
+ hand-rolled kernel (token tiling, h-coalescing, dequant staging, bank-conflict
28
+ padding) — all marginal, because the gap is the **BlockMMA register-tiling +
29
+ K-pipeline**, not staging. The decisive move was: **stop hand-rolling, copy MLX
30
+ verbatim.** It works and hits their speed.
31
+ - **MLX's `quantizedMatmul` does the whole head ~3.8× faster than our old kernel AND
32
+ exact** (fwd 151 + bwd 330 = 481ms). That's the `orpoFusedCe` head, already in the
33
+ repo. The ONLY reason to keep flash-CCE: it's a **single kernel** → `[M,V]`-free,
34
+ no per-chunk host-loop allocator churn.
35
+ - **Memory caveat (important, was over-claimed then corrected):** the head is NOT the
36
+ memory wall at long seq — the layer activations are. Avoiding the full `[M,V]`
37
+ (8.6 GB at e4b/8192) IS essential, but BOTH flash-CCE (+0.17 GB) and a properly
38
+ streamed fused head (+0.27 GB) achieve it. flash-CCE backward peak is **0.93 GB flat
39
+ at M=8192** (weights-dominated; kernel scratch ~5 MB; linear in M, flat in V). So
40
+ option 2 (steel backward) is justified for **SPEED + single-kernel allocation
41
+ flatness**, not because the fused head "blows up" (it doesn't, if streamed right).
42
+
43
+ ## Files
44
+
45
+ - `src/train/steel-qmm-header.ts` — **the verbatim MLX steel port** (auto-assembled
46
+ from MLX `quantized.h` + `steel/{gemm/mma.h,gemm/loader.h,gemm/transforms.h,
47
+ utils/integral_constant.h,utils/type_traits.h}`). Exports `STEEL_QMM_HEADER` (the
48
+ MSL header string). Contains: `BlockMMA`, `MMATile`, `BaseMMAFrag`, `tile_matmad`,
49
+ `BlockLoader`, `QuantizedBlockLoader`, `dequantize`, `get_pack_factor`, `qmm_t_impl`.
50
+ - `src/train/flash-cce.ts` — the head. `FWD_STEEL_SOURCE` + `fwdSteelKernel(gs,bits)`
51
+ (the **live forward**, dispatched by `useSteel` default). The old SG/scalar/lane
52
+ forward kernels + the current backward (`BWD_SG_SOURCE` / `bwdSgKernel`, the
53
+ hand-rolled persistent-acc[80] kernel) are still there.
54
+ - `src/train/loss.ts` — `makeFlashCceHeadVjp` (CustomVjp: fwd→Σlogp stash lse+blockMax,
55
+ bwd→flashCceBackward). `ChunkCtx.flash`. Routed via `orpoFlashCe` config flag.
56
+ - `src/train/trainer.ts` / `job.ts` — `orpoFlashCe` / `orpo_flash_ce` knob.
57
+ - `tests/train-orpo-fused-ce.test.ts` — flash-head fwd-parity + end-to-end training
58
+ tests (MLX_BUN_TEST_TRAIN=1, MiniCPM5).
59
+ - **Validated experiment scripts** (preserved, portable imports):
60
+ - `scripts/experiments/qmm-port-test.ts` — steel GEMM correctness vs quantizedMatmul.
61
+ - `scripts/experiments/qmm-port-speed.ts` — steel GEMM speed vs MLX (101 vs 106ms).
62
+ - `scripts/experiments/steel-fwd-test.ts` — forward epilogue (logp) vs reference.
63
+ - `scripts/experiments/steel-coeff-test.ts` — **backward phase-1** (logit→coeff) PASS.
64
+ - `scripts/experiments/steel-bwd-test.ts` — **full backward dh** (phase-1+2) PASS 1.6e-5.
65
+ - `scripts/experiments/bwd-peak.ts` — clean isolated backward peak (0.93 GB @ M=8192).
66
+ - `scripts/experiments/fwd-gemm-bench.ts` — MLX QM baseline (fwd 151 / bwd 330ms).
67
+
68
+ ## The architecture (what's proven)
69
+
70
+ Tile constants: **BM=8 tokens, BN=32 vocab, BK=32 hidden, WM=1, WN=4** (4 simdgroups
71
+ along N), `BK_padded = BK + 16/sizeof(T)`. The `WM=1,WN=4` choice is THE unlock:
72
+ BlockMMA's output tile is then split so **each simdgroup owns a different N-slice** —
73
+ which for the dh GEMM means each simdgroup owns a different **H-slice**, i.e. exactly
74
+ the persistent `acc[80]` layout we already proved works (160 regs/lane). So steel's
75
+ BlockMMA and the `[M,V]`-free persistent accumulator ARE compatible.
76
+
77
+ **Forward** (`FWD_STEEL_SOURCE`, live): grid `[128, NBLK, ⌈M/32⌉]` (BM=32 there, not 8),
78
+ per (token-block z, vocab-block y) threadgroup loop BN vocab-tiles → steel K-loop →
79
+ `Ctile.store` to threadgroup `Ls` → softcap + online-softmax → `[M,NBLK]` partials →
80
+ MLX merge → logp. `load_safe` for the last token-block (M%BM); vocab aligned for e4b/cpm.
81
+
82
+ **Backward** (proven in `steel-bwd-test.ts`, BM=8/WM=1/WN=4): per (token-block, vocab-
83
+ block) threadgroup, persistent `dmma = BlockMMA<float,float,8,H,32,1,4,false,false>`
84
+ Ctile = the dh accumulator. Loop vocab-tiles: phase-1 `lmma` BlockMMA logit → `Ls` →
85
+ coeff epilogue (`g·(onehot−softmax(logit−lse))·sech²`) → threadgroup `coeffS[8,32]`;
86
+ dequant W non-transposed → `Wd[32,H]`; `dmma.mma(coeffS, Wd)` accumulates `coeff@W`.
87
+ After the loop `dmma.store_result(dh,...)`.
88
+
89
+ ## Gotchas (hard-won — read before touching the header/kernels)
90
+
91
+ 1. **String.raw escaping**: when writing the header to a `.ts` string, do NOT escape
92
+ backslashes (mangles macro `\` line-continuations). Only fails if content has a
93
+ backtick or `${` (the MLX code has neither).
94
+ 2. **MLX preamble**: `mx.fast.metal_kernel` already provides `METAL_FUNC` + `using
95
+ namespace metal` (redef warning, harmless) but NOT `STEEL_CONST`/`MLX_MTL_CONST`/
96
+ `SIMD_SIZE`/`Int<>`/`pointer_element_t` — we add the macros + `type_traits.h` +
97
+ `integral_constant.h` ourselves.
98
+ 3. **`#pragma METAL internals`**: `type_traits.h` and `integral_constant.h` need it
99
+ (struct inheritance / `is_integral`). Include them **verbatim with their own
100
+ self-managed enable/disable pragmas** — do NOT strip + wrap manually (type_traits'
101
+ trailing `disable` will turn it off before integral_constant compiles → "derived
102
+ classes not supported").
103
+ 4. **W dequant convention** (in MANUAL dequant, e.g. phase-2 `Wd`): use
104
+ `perWord = 32/bits` (4 elems per uint32 at 8-bit), NOT `get_pack_factor<bits,8>=1`
105
+ (that's MLX's byte-based packing for the QuantizedBlockLoader's internal uint8
106
+ addressing). Mixing these gave a 100%-wrong dh.
107
+ 5. **`BlockMMA.mma` reads `Bs[BK,BN]` full-width** from threadgroup — so you can't wrap
108
+ it for a full-H persistent accumulator (would need `W[32,2560]`=320KB tg). Either
109
+ keep H small (test) or tile H with manual `tile_matmad` (production).
110
+ 6. Watchdog: per-threadgroup vocab slice must stay bounded (`VOCAB_BLOCK=8192`).
111
+
112
+ ## NEXT STEPS — the plan (in order)
113
+
114
+ ### 1. H-tiling in the steel backward phase-2 (the one real remaining piece)
115
+ The test stages the full `W[32,H]`; at e4b H=2560 that's 320 KB > 32 KB threadgroup.
116
+ Production must tile H:
117
+ - dh accumulator = `MMATile<float, 1, H/32>` per simdgroup (= its H-quarter; 80 frags
118
+ @ e4b = the proven `acc[80]` register budget).
119
+ - per vocab-tile: loop H in `BKh` chunks → dequant `W[32, BKh]` → threadgroup (small) →
120
+ load coeff A-frags `MMATile<1,4>` + W B-frags `MMATile<4, BKh/8>` → `tile_matmad(
121
+ D_sub, A, B_sub, D_sub)` into the right sub-frags of the persistent D.
122
+ - The intricate bit is the manual sub-frag indexing into D (BlockMMA does this
123
+ internally; here we do `tile_matmad` by hand because D persists across vocab-tiles).
124
+ - Validate against `steel-bwd-test.ts` (extend it to H=2560, compare to QM(coeff,W,t=F)).
125
+
126
+ ### 2. Vocab-blocking grid + atomic dh
127
+ - Grid `[128, NBLK, ⌈M/BM⌉]`; each (token-block, vocab-block) computes its block's dh
128
+ contribution, **atomic-add** into device `dh` (across the NBLK blocks) — same pattern
129
+ the current `BWD_SG_SOURCE` already uses (`atomicOutputs`, `initValue:0`).
130
+ - Need `lse` (have it, forward returns it) + `blockMax` (optional, for the block-skip)
131
+ + `gv` (cotangent) passed in — same inputs as the current backward.
132
+
133
+ ### 3. Wire into `flashCceBackward`
134
+ - Add `BWD_STEEL_SOURCE` + `bwdSteelKernel(gs,bits)` (header = STEEL_QMM_HEADER + defines).
135
+ - Dispatch: `useSteel` default when H,V,blockV %32 (mirror the forward's gate).
136
+ - Keep the old `bwdSgKernel` as fallback (H%32!=0) + the float4 scalar as last resort.
137
+ - The CustomVjp (`makeFlashCceHeadVjp`) already calls `flashCceBackward` — no change there.
138
+
139
+ ### 4. Validate + benchmark
140
+ - Parity: `E4B=1 M=512 bun scripts/experiments/flash-cce-parity.ts` (+ cpm, + M=200).
141
+ Expect dh ~0.4% (bf16-class) vs autograd.
142
+ - Bench vs baselines: fused head **481ms** (speed), current flash-CCE (memory parity).
143
+ Target: steel backward in the same class as the forward (~hundreds of ms, vs 3375).
144
+ - Memory: `scripts/experiments/bwd-peak.ts` should stay ~0.93 GB @ 8192 (flat).
145
+ - **CAP ANY MEMORY PROBE well under 32 GB** and eval+free per iteration — a naive
146
+ full-graph host-loop allocation swapped the machine to 23 GB once. Single-kernel
147
+ flash probes are safe by construction.
148
+
149
+ ### 5. (optional) softcap + filter/block-skip parity in the steel backward
150
+ - Softcap sech² is in the coeff epilogue already (steel-bwd-test uses cap=0; cap>0 path
151
+ is written, validate it).
152
+ - The Apple-CCE coeff filter + blockMax skip are **opt-in, default-off** approximations
153
+ (data-dependent; flat-softmax probes showed dh sensitivity). They live in the OLD
154
+ backward; decide whether to port them to steel (probably not needed — steel is fast
155
+ enough exact).
156
+
157
+ ## Decision context (for when you pick the shipping config)
158
+
159
+ - **Now**: `orpoFlashCe` = steel forward (fast) + old SG backward (slow but `[M,V]`-free).
160
+ Trains, memory-flat. `orpoFusedCe` = MLX QM both ways (481ms, exact, `[chunk,V]`
161
+ transient).
162
+ - **After option 2 lands**: `orpoFlashCe` = steel fwd + steel bwd = fast AND `[M,V]`-free
163
+ AND single-kernel — strictly best; removes the speed/memory tradeoff.
164
+ - The one genuinely-unanswered question: does the fused head's host-loop chunked backward
165
+ churn MLX's allocator at real e4b/8192 training? An end-to-end training-step peak
166
+ measurement (model + activations + head, with segmented/checkpointed backward) decides
167
+ whether the single-kernel property matters in practice. Worth measuring once.
168
+
169
+ ---
170
+
171
+ # END-STATE ROADMAP — "CPM5 test run with everything → e4b overnight @ 8192"
172
+
173
+ **The actual goal.** Two deliverables Josh runs himself (we wire, we do NOT kick off
174
+ the overnight run — ground rule):
175
+ - **(A)** A short **MiniCPM5** test training session using ALL optimizations — proves
176
+ the whole stack composes and trains (loss decreases).
177
+ - **(B)** An **e4b overnight** ORPO run at seq **8192** — the real target.
178
+
179
+ "ALL of our stuff" = flash-CCE head (fwd+bwd at MLX speed, `[M,V]`-free) + prefix-sharing
180
+ (single fwd/bwd, prompt shared) + segmented/checkpointed backward (layer-activation
181
+ memory at long seq) + LoRA.
182
+
183
+ ## What's already wired (so you don't redo it)
184
+ - flash-CCE **forward**: live + default (`flashCceForward`).
185
+ - flash-CCE head CustomVjp (`makeFlashCceHeadVjp`) ↔ `orpoFlashCe` flag ↔ trainer.
186
+ - flash threaded into BOTH non-segmented (`fusedLogpMeanB1`) and **segmented**
187
+ (`fusedLogpMeanFromHidden`) head paths.
188
+ - Segmented backward exists (`SegmentedBackwardOrpo` / `…Gemma4`), `segmentSize` flag.
189
+ - ORPO B=1 path (`branchLogpMean`→fused/flash) + tests (MiniCPM5 flash training PASSES).
190
+ - prefix-sharing math (`orpoLossPrefixShared`, MiniCPM5, bit-exact, parity script).
191
+
192
+ ## Milestones (dependency order)
193
+
194
+ ### M1 — flash-CCE backward → production (the steel backward) ✅ DONE (2026-06-18)
195
+ H-tiled phase-2 (Design C: persistent `MMATile<1,H/32>` accumulator + per-H-tile temp
196
+ BlockMMA over bounded `Wd[32,128]` + lane-local frag accumulation) → vocab-blocking grid
197
+ + atomic dh → `BWD_STEEL_SOURCE`/`bwdSteelKernel` wired into `flashCceBackward` (`useSteel`
198
+ gate: H,V,blockV %32 + H%128). Parity PASS e4b (dh 0.40%) + cpm (dh 0.28%); 2.0× the old
199
+ SG (1851 vs 3687ms e4b M=512 exact); peak 0.928 GB flat @8192. Design validated first in
200
+ `scripts/experiments/steel-bwd-htile-test.ts` (H=2560, PASS 1.1e-4 rel). Both flash
201
+ directions now fast. KEY INSIGHT that avoided the "intricate manual tile_matmad": a TEMP
202
+ BlockMMA per H-tile whose Ctile is accumulated into the persistent D via `frag += frag`
203
+ (lane-local — the per-lane element layout is identical, so no get_coord math needed).
204
+
205
+ ### M2 — prefix-sharing → trainer (single fwd/bwd) ✅ DONE (2026-06-18)
206
+ - `orpoPrefixShared` trainer flag (+ `orpo_prefix_shared` job field) dispatches to
207
+ `orpoLossPrefixShared` (MiniCPM5) / `orpoLossPrefixSharedGemma` (Gemma4 — the sibling
208
+ already existed) via `splitPrefixBatch`, falling back to two-forward `orpoLoss` when a
209
+ row's chosen/rejected prompts differ. Guarded against `segmentSize>0` (composition is
210
+ M3). End-to-end CPM5 training test PASSES (loss decreases).
211
+ - **Composed with the flash head**: new `branchLogpMeanGathered` gathers each branch's
212
+ `[M,H]` response hiddens → `fusedRespLogpMean(flash)` per branch (reuses the analytic
213
+ per-branch VJP — cleaner than splitting one call's non-uniform length-normalized
214
+ cotangent). Validated: flash-routed prefix-share matches whole-vocab prefix-share to
215
+ **0.018%** (bf16-class) in `prefix-shared-parity.ts`. So prefix-share is now `[M,V]`-free
216
+ end to end (one model fwd + per-branch flash fwd/bwd).
217
+ - **e4b caveat unchanged**: e4b's bf16 length-sensitivity (~2–14%) is a numerical-parity
218
+ nicety, not correctness. MiniCPM5 is bit-exact (forward 0%, grads ~1% at P=512). NOTE:
219
+ for the e4b OVERNIGHT at seq 8192, prefix-share can't be used until M3 composes it with
220
+ the segmented backward — the overnight uses **segmented + flash** (validated config shape
221
+ below), with prefix-share an M3 follow-on.
222
+
223
+ ### M4A — CPM5 smoke test of the complete new system ✅ DONE (2026-06-18)
224
+ Automated end-to-end tests in `tests/train-orpo-fused-ce.test.ts` (run with
225
+ `MLX_BUN_TEST_TRAIN=1`), all PASS — each trains CPM5 20 iters, asserts finite +
226
+ decreasing loss + no OOM/NaN:
227
+ - **flash head** (steel fwd+bwd) + LoRA.
228
+ - **prefix-share + flash** (the single-concat-forward + `[M,V]`-free head).
229
+ - **segmented + flash** (the e4b-overnight config shape: gradient-checkpointed layer
230
+ activations + `[M,V]`-free head). These exercise every new piece composing in a real
231
+ ORPO step. (prefix-share + segmented together = M3, not yet composed.)
232
+
233
+ ### M3 — long-sequence memory @ e4b/8192 (the layer activations, NOT the head)
234
+ - Compose segmented/checkpointed backward (`segmentSize>0`) + flash head + prefix-share.
235
+ The head is ~free in memory (0.93 GB flat); the wall is the transformer activations,
236
+ which segmented backward already handles (peak 10.9→3.3 GB @2048 per the notes).
237
+ - **THE measurement that actually decides "does e4b train at 8192"**: a real end-to-end
238
+ training-step peak (model + activations + head) with segmented backward on. Do this
239
+ EARLY — it tells us the segment size needed to fit 8192 in 32 GB, and whether the head
240
+ choice (flash vs fused) matters at all. (Cap probes; never swap the machine.)
241
+
242
+ ### M3/M4B — e4b @ 8192 memory probe + overnight config ✅ PROBED (2026-06-18)
243
+ `scripts/bench-orpo.ts` sweeps the new-system configs (`flash head` / `segmented + flash`
244
+ / `prefix + flash`); `ONLY_NEW=1` skips the legacy baselines, `CONFIG="<substr>"` runs one,
245
+ and it now prints peak GB + a finite-loss check. The **`segmented + flash` row IS the
246
+ overnight config shape**.
247
+
248
+ **MEASURED on the M1 Max 32 GB (e4b, segmented+flash, SEG=2, `MLX_BUN_PERF_KERNEL=0
249
+ MLX_BUN_FUSED_GELU=0`):**
250
+ | SEQ | peak GB | ms/step |
251
+ |---|---|---|
252
+ | 1024 | 8.66 | 20.7k |
253
+ | 2048 | 10.17 | 40.4k |
254
+ | 4096 | 11.68 | 80.8k |
255
+ | **8192** | **16.14** | **175k** (loss 0.093→0.091 finite, decreasing) |
256
+
257
+ Footprint is **linear** in seq (~+1.5 GB / 1024 tokens — segmenting + flash attention keep
258
+ it linear, not quadratic; the head is flat 0.93 GB). **e4b @ 8192 fits in 16.14 GB with
259
+ ~16 GB headroom — the historical "e4b OOMs ≥2048" ceiling is BROKEN.** SEG=2 is the sweet
260
+ spot: SEG=8 @ 8192 hit **33.47 GB (swapped) and was slower** — bigger segments hold more
261
+ activations, so small segments win here. The run command:
262
+ ```
263
+ MODEL=<e4b-snapshot> CONFIG="segmented + flash" SEQ=8192 SEG=2 ITERS=3 \
264
+ MLX_BUN_PERF_KERNEL=0 MLX_BUN_FUSED_GELU=0 bun scripts/bench-orpo.ts
265
+ ```
266
+
267
+ **VALIDATED overnight config (segmented + flash + LoRA):**
268
+ ```
269
+ method:"orpo", orpoFlashCe:true (implies fused), segmentSize:2, orpoChunkSize:512,
270
+ maxSeqLen:8192, env MLX_BUN_PERF_KERNEL=0 + MLX_BUN_FUSED_GELU=0, rank/scale/lr + real
271
+ dpo data per the run, LoRA targets default. Peak ~16 GB, ~2.9 min/step on the M1 Max.
272
+ ```
273
+ Prefix-share is NOT in the overnight (segmented + prefix-share = the M3-composition below);
274
+ the overnight uses segmented + flash. **Josh starts the overnight** (ground rule).
275
+
276
+ ### M3-composition — segmented backward + prefix-share together ✅ LANDED MiniCPM5 (2026-06-18)
277
+ New `SegmentedBackwardOrpoPrefix` (`src/train/segmented.ts`): streams ONE concat
278
+ `[prompt; chosen; rejected]` through the segmented backward with `PrefixSharedCache`
279
+ (block-sparse mask) + `setMiniCpmPrefixPlan` active during the streaming forward AND every
280
+ segment's `mlx_vjp` recompute; the head VJP gathers chosen/rejected response positions
281
+ (`branchLogpMeanGathered`, the `[M,V]`-free per-branch flash head) and scatter-adds its dh
282
+ into the `[1,T,H]` boundary that seeds the segmented backward. Trainer composes it (old
283
+ guard removed) with a two-forward-segmented fallback on prompt mismatch. Validated
284
+ (`scripts/experiments/prefix-shared-segmented-parity.ts`): grads **2.27%** bf16-class
285
+ (single-segment diagnostic 1.16% → it's segment-boundary reassociation + flash-head 0.4%
286
+ stacking, not a bug), loss **0.052%**, peak **32% lower** (3.64→2.47 GB @ P=512; grows to
287
+ 59% lower @ P=1024) — prompt-encode-once now holds at long seq with flat ~2.5 GB activation.
288
+ Test #6 in `tests/train-orpo-fused-ce.test.ts` (PASS). **e4b (Gemma4) DEFERRED** — trainer
289
+ throws a clear error; the logical-position sliding-window prefix mask + donor-KV threading
290
+ across segmented donor boundaries is the remaining follow-on (the overnight uses
291
+ segmented+flash without prefix-share, which works).
292
+
293
+ ### M4 — runnable configs + the two runs
294
+ - **(A) MiniCPM5 test config**: short seq, small iters, `orpoFlashCe` (or prefix-share
295
+ mode) + segmented on, LoRA. Run `trainLora`; assert loss decreases. This is the
296
+ "everything composes" gate — basically the existing flash training test scaled up.
297
+ - **(B) e4b overnight config**: seq 8192, `segmentSize` tuned from M3, flash head,
298
+ prefix-share (or two-forward if e4b variance is a problem), LoRA, full dataset
299
+ (`scripts/curate-ultrafeedback.ts` / the dpo data). **Josh runs this** — we hand him a
300
+ validated config + command, we do not start it.
301
+
302
+ ## THE POINT: the runs are INTEGRATION TESTS of the NEW system
303
+ Josh does NOT care about training quality/results. The CPM5 run and the e4b run exist to
304
+ **exercise everything we just built — steel backward + flash head + prefix-sharing — end
305
+ to end in a real ORPO training step** and confirm the new code path RUNS (loss is finite,
306
+ decreases a bit, no crash/NaN/OOM). So we FINISH all the new pieces, wire them ALL the
307
+ way through, then run. Do NOT ship the old slow backward as a crutch — the steel backward
308
+ is part of what's being tested.
309
+
310
+ ## Order of execution (build the new system, then test it)
311
+ 1. **M1 — steel backward → production** (H-tile phase-2, vocab-block + atomic dh, wire
312
+ `bwdSteelKernel` into `flashCceBackward`, parity e4b/cpm). The new backward IS under
313
+ test; it must be in the path.
314
+ 2. **M2 — prefix-sharing → trainer + compose with flash head** (gather `[Rc+Rr]` → one
315
+ flash-head call → split by branch). MiniCPM5 first.
316
+ 3. **M4(A) — CPM5 test run = the smoke test of the complete new system**: flash fwd+bwd
317
+ (steel) + prefix-share + segmented + LoRA, all on. Run `trainLora` a handful of steps;
318
+ assert finite + decreasing loss + no OOM. THIS is the test — it validates every new
319
+ piece composes in a real step. If it passes, the new system works.
320
+ 4. **M3 — e4b @ 8192 dry-run**: same new system on e4b, seq 8192, segmented on. First a
321
+ memory/step-peak check (cap it; the head is flat 0.93 GB, the wall is activations →
322
+ tune `segmentSize`), then a few real steps to confirm it RUNS. (e4b prefix-share bf16
323
+ variance is fine — we're not checking quality; if it NaNs, fall back to two-forward.)
324
+ 5. **M4(B) — e4b overnight**: hand Josh a validated config + command for the longer run
325
+ (we wire + validate a few steps; **Josh starts the overnight** — ground rule).
326
+
327
+ **Risk register**: M1 mechanical (scoped — architecture+correctness already proven). M2/e4b
328
+ bf16 variance = non-blocking for a RUN test (two-forward fallback; quality irrelevant). M3
329
+ e4b/8192 memory = the one real unknown, but it's an activations/segmentSize question, not
330
+ a head question. The runs test integration; the pieces are individually validated.
331
+
332
+ ---
333
+
334
+ # GLOSSARY — every named piece, what it is, status, where
335
+
336
+ Status legend: ✅ done+live · 🟢 done, not production-wired · 🟡 partial · 🔬 proven in a
337
+ test only · 📦 pre-existing (not ours this arc) · 🪦 dead end.
338
+
339
+ ## A. The "head" = how we turn response hiddens `[M,H]` into ORPO log-probs
340
+ (M = number of response tokens; V = vocab; H = hidden.) These are *alternative
341
+ implementations of the same math* (`logp_t = target_logit − logsumexp_v`).
342
+ - **Full-logits head** 📦 — the naive reference: materialize `[M,V]` logits, logsumexp.
343
+ Correct, huge memory. Only used as the parity oracle.
344
+ - **Chunked head** (`orpoChunkSize`) 📦 — token-chunked + per-chunk `Checkpoint`; bounds
345
+ `[M,V]`→`[chunk,V]`. Older, autograd through the head.
346
+ - **Fused linear-CE head** (`orpoFusedCe`, `fusedRespLogpMean`) 📦✅ — a `CustomVjp` with
347
+ an ANALYTIC softmax−onehot backward (no autograd through the head), using **MLX
348
+ `quantizedMatmul`** for both GEMMs, token-chunked. Fast (**481ms** e4b), exact,
349
+ `[chunk,V]` transient. The speed baseline we're trying to match.
350
+ - **Vocab-blocked head** (`makeVocabBlockedHeadVjp`) 📦 — pure-MLX online-softmax over
351
+ vocab blocks; `[chunk, vocabBlock]` only. Slow-ish; the MLX-only CCE.
352
+ - **flash-CCE head** (`orpoFlashCe`, `makeFlashCceHeadVjp`) — **OURS**, the Metal-kernel
353
+ head: the vocab loop lives INSIDE one Metal kernel, so neither `[M,V]` nor a
354
+ dequantized `[V,H]` ever touches HBM → `[M,V]`-free, single kernel. Forward ✅ live,
355
+ backward 🟡 (old slow kernel live; steel kernel 🔬 proven, not wired). `flashCceForward`
356
+ + `flashCceBackward` in `src/train/flash-cce.ts`.
357
+
358
+ ## B. flash-CCE kernel internals (`src/train/flash-cce.ts`) — OURS
359
+ The flash head dispatches to one of several kernel implementations of fwd/bwd:
360
+ - **`FWD_STEEL_SOURCE` / `fwdSteelKernel`** ✅ — the NEW steel-GEMM forward (default). 180ms.
361
+ - `FWD_SG_SOURCE` / `fwdSgKernel` 🪦-ish — old hand-rolled simdgroup_matrix forward (848ms).
362
+ - `FWD_SOURCE` / `fwdKernel` — scalar fallback (H%32≠0).
363
+ - `FWD_LANE_SOURCE` 🪦 — lane-sliced (2.6× slower, dead end, kept gated).
364
+ - **`BWD_SG_SOURCE` / `bwdSgKernel`** ✅(live) — CURRENT production backward: hand-rolled
365
+ simdgroup_matrix, persistent `acc[80]` dh accumulator, phase-1 (logit→coeff) +
366
+ phase-2 (dh=coeffᵀ@W). Correct, `[M,V]`-free, **slow (3375ms)**.
367
+ - `BWD_SOURCE` / `bwdKernel` — float4 scalar backward fallback.
368
+ - **STEEL BACKWARD** 🔬 — the NEW one: `scripts/experiments/steel-bwd-test.ts`, validated
369
+ dh 1.6e-5, NOT yet a production kernel (`BWD_STEEL_SOURCE` is M1's job).
370
+
371
+ ## C. The steel port (`src/train/steel-qmm-header.ts`) — OURS (verbatim from MLX)
372
+ MLX's quantized `steel` GEMM, copied verbatim so we run at MLX speed (~46% peak vs our
373
+ hand-rolled 8%). The `STEEL_QMM_HEADER` string contains:
374
+ - **`BlockMMA`** — the tiled matmul (BM×BN output, BK contraction, WM×WN simdgroups, the
375
+ K-pipeline). The thing that makes MLX fast.
376
+ - **`MMATile` / `BaseMMAFrag` / `tile_matmad`** — the 8×8 simdgroup-matrix fragment
377
+ abstraction BlockMMA is built on (used directly for the persistent dh accumulator).
378
+ - **`QuantizedBlockLoader`** — loads packed quantized weights and dequantizes them
379
+ straight into the threadgroup matmul tile (fused dequant, no separate pass).
380
+ - **`BlockLoader`** — loads the dense input (X / coeff) into the tile.
381
+ - **`qmm_t_impl`** — MLX's full quantized-matmul orchestration (we use it for the
382
+ forward; the backward composes BlockMMA + tile_matmad ourselves).
383
+ - Tile params we use: **BM=8/32, BN=32, BK=32, WM=1, WN=4** (the `WM=1,WN=4` is the key
384
+ that makes BlockMMA's output tiling match our `[M,V]`-free per-simdgroup H-slice accum).
385
+
386
+ ## D. The epilogue (what we FUSE on top of the steel GEMM) — OURS
387
+ MLX has no "quant-matmul + softcap + response-logp" op, so we add:
388
+ - **Softcap** — `cap·tanh(raw/cap)` (Gemma final-logit cap; e4b cap=30, cpm none) + its
389
+ `sech²` chain-rule term in the backward.
390
+ - **Online softmax** — running `(max, sumexp)` over vocab blocks → `lse` (forward).
391
+ - **Gather / target capture** — pick the target token's logit per row.
392
+ - **softmax−onehot coeff** — the backward gradient `coeff = g·(onehot − softmax)·sech²`.
393
+ - **MLX merge** — combine the `[M,NBLK]` per-vocab-block partials → `logp`/`lse` in MLX.
394
+
395
+ ## E. Backward approximations (opt-in, default-off, data-dependent) — OURS
396
+ Both are Apple-CCE-style skips that proved unreliable on flat/synthetic softmax, so they
397
+ are OFF by default and live in the OLD backward; probably NOT needed once steel is fast.
398
+ - **Coeff filter** (`MLX_BUN_CCE_BWD_FILTER_EPS`) — skip phase-2 chunks whose whole coeff
399
+ block < eps.
400
+ - **blockMax vocab-block skip** (`MLX_BUN_CCE_BWD_BLOCK_EPS`) — skip whole cold
401
+ (token-block, vocab-block) programs using the forward's `blockMax`.
402
+
403
+ ## F. Model-level optimizations (above the head)
404
+ - **Prefix-sharing** (`src/train/prefix-shared.ts`, `orpoLossPrefixShared`,
405
+ `blockSparseMask`, `PrefixSharedCache`) 🟢 — OURS (earlier this arc). One forward over
406
+ `[prompt ; chosen ; rejected]` with a block-sparse mask (chosen & rejected each attend
407
+ the shared prompt but NOT each other) → prompt encoded ONCE, single fwd/bwd for the
408
+ pair. Bit-exact for MiniCPM5; e4b has a bf16 length-sensitivity caveat. Built + parity
409
+ script, NOT wired into the trainer.
410
+ - **Segmented backward** (`src/train/segmented.ts`, `SegmentedBackwardOrpo*`,
411
+ `segmentSize`) 📦✅ — gradient checkpointing for the TRANSFORMER LAYER activations (the
412
+ real memory wall at long seq; orthogonal to the head). Composes with the flash head
413
+ (`fusedLogpMeanFromHidden` carries the `flash` flag).
414
+
415
+ ## G. Wiring / config layer
416
+ - **`makeFlashCceHeadVjp`** (`loss.ts`) — wraps flashCceForward/Backward as a `CustomVjp`
417
+ (fwd→Σlogp, stash lse+blockMax; bwd→dh×cotangent). OURS.
418
+ - **`ChunkCtx`** (`loss.ts`) — the head config struct: `{fused, flash, chunkSize,
419
+ vocabBlock, sink}`.
420
+ - **`orpoLoss` / `branchLogpMean`** (`loss.ts`) — the ORPO loss entry; B=1 routes to the
421
+ fused/flash head, B>1 to full-logits.
422
+ - **Trainer flags** (`trainer.ts` / `job.ts`): `orpoFusedCe`, `orpoFlashCe`, `segmentSize`,
423
+ `orpoChunkSize`, `orpoLambda`. CLI: `--orpo-flash-ce` etc.
424
+
425
+ ## H. Baselines / numbers to remember
426
+ - MLX `quantizedMatmul` raw GEMM (the speed reference): e4b fwd **151ms**, bwd **330ms**.
427
+ - Fused head total: **481ms** exact.
428
+ - Our steel forward: **180ms** (raw GEMM 101ms; +epilogue). Old SG forward: 848ms.
429
+ - Our backward: old SG **3375ms** (live); steel target ~hundreds of ms (M1).
430
+ - flash-CCE backward peak: **0.93 GB flat @ M=8192** (weights-dominated; kernel ~5MB;
431
+ linear in M, flat in V).
@@ -8,6 +8,35 @@ work in flight lives in [PLAN.md](../../PLAN.md).
8
8
 
9
9
  ## Positioning: what mlx-bun is becoming
10
10
 
11
+ ### The finite-matrix local AI appliance
12
+
13
+ `mlx-bun` can be a local AI product in a way generic LLM runtimes cannot. On
14
+ MLX, the hardware universe is Apple Silicon Macs: a finite list of real shipped
15
+ chip/RAM/bandwidth combinations. The supported model universe is curated, and
16
+ the runtime usually knows the task mode: chat, tool use, vision, eval,
17
+ training, adapter routing, or background automation.
18
+
19
+ That means the default path can be a table of measured choices instead of a
20
+ pile of user-facing knobs:
21
+
22
+ ```text
23
+ physical Mac SKU + model checksum + task mode -> optimized execution plan
24
+ ```
25
+
26
+ The plan can choose the model, context budget, KV policy, adapter policy,
27
+ kernel route, compile route, and memory envelope before the user has to care.
28
+ The user runs `mlx-bun`; the system gives them the most useful local assistant
29
+ their Mac can comfortably run. Power users still get the stick-shift controls,
30
+ but the product default is curated.
31
+
32
+ This is also why optimization work belongs in the product vision, not just the
33
+ lab notebook. Loop unrolling, branch deletion, DAG extraction, custom kernels,
34
+ compiled graph tables, Cut Cross Entropy, Liger-style fused heads, and
35
+ ORPO-trained adapters matter when they move a real device/model profile from
36
+ "cute demo" to "leave it running because it is useful."
37
+
38
+ ### Local in the deeper sense
39
+
11
40
  Two phrases that capture the thesis — especially **mlx-bun × Lucien**:
12
41
 
13
42
  1. **"A truly local model in a deeper sense."**