mlx-bun 0.0.4

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 (210) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +362 -0
  3. package/THIRD_PARTY_LICENSES.md +41 -0
  4. package/bin/mlx-bun.mjs +32 -0
  5. package/docs/curve-designer.html +303 -0
  6. package/docs/design/adapters-end-to-end.md +201 -0
  7. package/docs/design/compat-cli-surface-design.md +227 -0
  8. package/docs/design/docs-reorg-plan.md +152 -0
  9. package/docs/design/hlg-sampling.md +340 -0
  10. package/docs/design/memory-system.md +339 -0
  11. package/docs/design/optimization_plan.md +344 -0
  12. package/docs/design/parallel-slots.md +515 -0
  13. package/docs/design/runtime-isolation.md +247 -0
  14. package/docs/design/segmented-backward-training.md +508 -0
  15. package/docs/investigations/chunk-finetune-experiment.md +84 -0
  16. package/docs/investigations/curve-bisector-routing.md +123 -0
  17. package/docs/investigations/curve-runs/bisector-route.json +98 -0
  18. package/docs/investigations/curve-runs/bisector-trace.json +1393 -0
  19. package/docs/investigations/curve-runs/curve-terrain.json +1179 -0
  20. package/docs/investigations/curve-runs/curve-terrain.svg +42 -0
  21. package/docs/investigations/curve-terrain.html +105 -0
  22. package/docs/investigations/expert-offload-single-user-moe.md +292 -0
  23. package/docs/investigations/hlg-ab.html +34 -0
  24. package/docs/investigations/hlg-explorer.html +238 -0
  25. package/docs/investigations/hlg-figs/cliff.svg +1 -0
  26. package/docs/investigations/hlg-figs/cross.svg +1 -0
  27. package/docs/investigations/hlg-figs/edges.svg +1 -0
  28. package/docs/investigations/hlg-figs/k20-grids.svg +1 -0
  29. package/docs/investigations/hlg-figs/k20-peaks.svg +1 -0
  30. package/docs/investigations/hlg-figs/lw-curve.svg +1 -0
  31. package/docs/investigations/hlg-figs/terrain.svg +1 -0
  32. package/docs/investigations/hlg-report.html +262 -0
  33. package/docs/investigations/hlg-runs/hlg-ab.json +149 -0
  34. package/docs/investigations/hlg-runs/hlg-blind-prompts.md +87 -0
  35. package/docs/investigations/hlg-runs/hlg-blind.json +107 -0
  36. package/docs/investigations/hlg-runs/hlg-cliff.json +197 -0
  37. package/docs/investigations/hlg-runs/hlg-cross.json +505 -0
  38. package/docs/investigations/hlg-runs/hlg-edge.json +388 -0
  39. package/docs/investigations/hlg-runs/hlg-eval1.json +25 -0
  40. package/docs/investigations/hlg-runs/hlg-frontier.json +86 -0
  41. package/docs/investigations/hlg-runs/hlg-grid.json +242 -0
  42. package/docs/investigations/hlg-runs/hlg-grid2.json +343 -0
  43. package/docs/investigations/hlg-runs/hlg-lw-coarse.json +110 -0
  44. package/docs/investigations/hlg-runs/hlg-map.json +383 -0
  45. package/docs/investigations/hlg-runs/run0-original.log +159 -0
  46. package/docs/investigations/hlg-runs/runA-recommended.log +131 -0
  47. package/docs/investigations/hlg-runs/runAA-cliff-cpm5.log +26 -0
  48. package/docs/investigations/hlg-runs/runAB-grid2-e4b.log +18 -0
  49. package/docs/investigations/hlg-runs/runAC-peak-e4b-partial.log +25 -0
  50. package/docs/investigations/hlg-runs/runAD-ab-samples.log +8 -0
  51. package/docs/investigations/hlg-runs/runAE-edge-e4b.log +51 -0
  52. package/docs/investigations/hlg-runs/runAF-logit-calibration.log +10 -0
  53. package/docs/investigations/hlg-runs/runAG-eval-config2.log +16 -0
  54. package/docs/investigations/hlg-runs/runB-gain-variants.log +192 -0
  55. package/docs/investigations/hlg-runs/runC-pivots.log +162 -0
  56. package/docs/investigations/hlg-runs/runD-oetf.log +132 -0
  57. package/docs/investigations/hlg-runs/runE-eotf.log +162 -0
  58. package/docs/investigations/hlg-runs/runF-shaper-working.log +86 -0
  59. package/docs/investigations/hlg-runs/runG-shaper-sweep.log +101 -0
  60. package/docs/investigations/hlg-runs/runH-wsweep.log +176 -0
  61. package/docs/investigations/hlg-runs/runI-asweep.log +176 -0
  62. package/docs/investigations/hlg-runs/runJ-knobmap.log +49 -0
  63. package/docs/investigations/hlg-runs/runK-frontier.log +19 -0
  64. package/docs/investigations/hlg-runs/runL-widemap.log +65 -0
  65. package/docs/investigations/hlg-runs/runM-frontier-emb.log +15 -0
  66. package/docs/investigations/hlg-runs/runN-verify.log +116 -0
  67. package/docs/investigations/hlg-runs/runO-frontier-seed5000.log +15 -0
  68. package/docs/investigations/hlg-runs/runP-grid-AxSm.log +15 -0
  69. package/docs/investigations/hlg-runs/runQ-grid-tgW.log +15 -0
  70. package/docs/investigations/hlg-runs/runR-blind-transcript.log +79 -0
  71. package/docs/investigations/hlg-runs/runS-cross-coherence.log +33 -0
  72. package/docs/investigations/hlg-runs/runT-cross-cpm5.log +33 -0
  73. package/docs/investigations/hlg-runs/runU-cross-12b.log +33 -0
  74. package/docs/investigations/hlg-runs/runV-lw-e4b.log +22 -0
  75. package/docs/investigations/hlg-runs/runW-lw-12b.log +22 -0
  76. package/docs/investigations/hlg-runs/runX-lw-cpm5.log +22 -0
  77. package/docs/investigations/hlg-runs/runY-cliff-e4b.log +26 -0
  78. package/docs/investigations/hlg-runs/runZ-cliff-12b.log +26 -0
  79. package/docs/investigations/hlg-sampling-investigation.md +970 -0
  80. package/docs/investigations/lab-build-journal.md +157 -0
  81. package/docs/investigations/pi-builtin-investigation.md +308 -0
  82. package/docs/investigations/starter-model-port-handoff.md +323 -0
  83. package/docs/planning/IDEAS.md +26 -0
  84. package/docs/planning/PRODUCT_ROADMAP.md +426 -0
  85. package/docs/planning/ResearchTopics.md +119 -0
  86. package/docs/planning/chunk-task-roadmap.md +77 -0
  87. package/docs/planning/journal.md +155 -0
  88. package/docs/reference/distribution.md +121 -0
  89. package/docs/reference/embedding.md +168 -0
  90. package/docs/reference/library-api.md +153 -0
  91. package/docs/reference/server-api.md +224 -0
  92. package/docs/reference/server-config.md +243 -0
  93. package/docs/segmented-backward-handoff.md +148 -0
  94. package/package.json +68 -0
  95. package/src/anthropic.ts +491 -0
  96. package/src/chat-template.ts +186 -0
  97. package/src/cli.ts +1075 -0
  98. package/src/config.ts +229 -0
  99. package/src/curve-sampler.ts +122 -0
  100. package/src/dataset/generators.ts +1087 -0
  101. package/src/dataset/index.ts +12 -0
  102. package/src/dataset/job.ts +81 -0
  103. package/src/dataset/llm.ts +71 -0
  104. package/src/dataset/registry.ts +528 -0
  105. package/src/download.ts +283 -0
  106. package/src/eval/README.md +86 -0
  107. package/src/eval/kl-prompts.ts +29 -0
  108. package/src/eval/kl.ts +294 -0
  109. package/src/eval/quality-db.ts +92 -0
  110. package/src/eval/runner.ts +102 -0
  111. package/src/eval/score.ts +49 -0
  112. package/src/eval/tasks/bfcl.ts +500 -0
  113. package/src/eval/tasks/gsm8k.ts +85 -0
  114. package/src/eval/tasks/hashhop.ts +109 -0
  115. package/src/eval/tasks/humaneval.ts +252 -0
  116. package/src/eval/tasks/ifeval.ts +354 -0
  117. package/src/eval/tasks/mmlu.ts +145 -0
  118. package/src/evaldb.ts +199 -0
  119. package/src/expert-offload-build.ts +77 -0
  120. package/src/expert-offload.ts +61 -0
  121. package/src/expert-trace.ts +67 -0
  122. package/src/fit.ts +254 -0
  123. package/src/generate.ts +451 -0
  124. package/src/harness-pi.ts +177 -0
  125. package/src/hf-push.ts +628 -0
  126. package/src/index.ts +22 -0
  127. package/src/jobs/db.ts +139 -0
  128. package/src/jobs/index.ts +29 -0
  129. package/src/jobs/job-entry.ts +106 -0
  130. package/src/jobs/runner.ts +304 -0
  131. package/src/jobs/sse.ts +114 -0
  132. package/src/jobs/types.ts +58 -0
  133. package/src/kv-store.ts +185 -0
  134. package/src/lora.ts +314 -0
  135. package/src/md-modules.d.ts +7 -0
  136. package/src/mlx/array.ts +237 -0
  137. package/src/mlx/autograd.ts +267 -0
  138. package/src/mlx/checkpoint.ts +116 -0
  139. package/src/mlx/compile.ts +158 -0
  140. package/src/mlx/custom-vjp.ts +140 -0
  141. package/src/mlx/ffi.ts +433 -0
  142. package/src/mlx/metal-kernel.ts +130 -0
  143. package/src/mlx/ops.ts +707 -0
  144. package/src/mmap.ts +90 -0
  145. package/src/model/batched-mask.ts +204 -0
  146. package/src/model/batched-rotating.ts +300 -0
  147. package/src/model/compiled-decode.ts +681 -0
  148. package/src/model/factory.ts +27 -0
  149. package/src/model/fingerprint.ts +33 -0
  150. package/src/model/flash-attention.ts +674 -0
  151. package/src/model/fused-decode-kernel.ts +305 -0
  152. package/src/model/fused-geglu-kernel.ts +87 -0
  153. package/src/model/gemma4-base.ts +1616 -0
  154. package/src/model/gemma4.ts +1012 -0
  155. package/src/model/generated/gemma4-12b.ts +526 -0
  156. package/src/model/generated/gemma4-26b.ts +500 -0
  157. package/src/model/generated/gemma4-e4b.ts +757 -0
  158. package/src/model/generated/index.ts +18 -0
  159. package/src/model/minicpm5.ts +278 -0
  160. package/src/model/qwen3-delta.ts +224 -0
  161. package/src/model/qwen3_5.ts +429 -0
  162. package/src/model/support.ts +46 -0
  163. package/src/native-pack.ts +152 -0
  164. package/src/pi-provider.ts +95 -0
  165. package/src/pi-terminal.ts +264 -0
  166. package/src/pi-web.ts +924 -0
  167. package/src/preflight.ts +123 -0
  168. package/src/prompt-cache.ts +113 -0
  169. package/src/quantize/allocator.ts +627 -0
  170. package/src/quantize/calibration.ts +180 -0
  171. package/src/quantize/config-writer.ts +132 -0
  172. package/src/quantize/index.ts +57 -0
  173. package/src/quantize/job.ts +136 -0
  174. package/src/quantize/quantizer.ts +462 -0
  175. package/src/quantize/safetensors-writer.ts +171 -0
  176. package/src/quantize/sensitivity.ts +234 -0
  177. package/src/registry.ts +336 -0
  178. package/src/responses.ts +774 -0
  179. package/src/safetensors.ts +158 -0
  180. package/src/sampler.ts +517 -0
  181. package/src/serve/batch-scheduler.ts +362 -0
  182. package/src/serve/generation-gateway.ts +160 -0
  183. package/src/server.ts +1891 -0
  184. package/src/spec/drafter.ts +261 -0
  185. package/src/spec/generate.ts +221 -0
  186. package/src/status-page.html +497 -0
  187. package/src/tokenizer.ts +38 -0
  188. package/src/tool-call.ts +242 -0
  189. package/src/train/dataset.ts +461 -0
  190. package/src/train/export.ts +32 -0
  191. package/src/train/forward.ts +229 -0
  192. package/src/train/index.ts +43 -0
  193. package/src/train/job.ts +148 -0
  194. package/src/train/lora-params.ts +194 -0
  195. package/src/train/loss.ts +342 -0
  196. package/src/train/merge.ts +195 -0
  197. package/src/train/optimizer.ts +176 -0
  198. package/src/train/rank.ts +101 -0
  199. package/src/train/segmented.ts +520 -0
  200. package/src/train/trainer.ts +600 -0
  201. package/src/tui.ts +161 -0
  202. package/src/vision/embedder.ts +168 -0
  203. package/src/vision/preprocess.ts +247 -0
  204. package/src/vision/prompt.ts +158 -0
  205. package/src/vision/siglip.ts +599 -0
  206. package/src/web/app.html +2471 -0
  207. package/src/web/skills/web-research/SKILL.md +44 -0
  208. package/src/web/skills.ts +59 -0
  209. package/src/web-tools.ts +426 -0
  210. package/src/weights.ts +91 -0
@@ -0,0 +1,508 @@
1
+ # Segmented backward: long-context LoRA training that beats the reference
2
+
3
+ **Status: Phase A (MiniCPM5) + Phase B (e4b) BOTH WORK — 2026-06-16.** MiniCPM5:
4
+ bit-exact, no leak, chunk-eval 95.10 (beats the 91.70 baseline). e4b: forward
5
+ bit-exact, grads bf16-class, trains **all 42 layers at 8K (17.5 GB)** where
6
+ `mlx_lm.lora --grad-checkpoint` OOMs trying the same (verified — mlx-lm must drop
7
+ to its default 16 trainable layers, 25.7 GB, to fit 8K). §1–8 are the original
8
+ design dossier; **§9 = Phase A results, §10 = Phase B (e4b) results — read those
9
+ first for current state.**
10
+
11
+ ## 0. The one-paragraph version
12
+
13
+ mlx-bun (and Python mlx) gradient checkpointing does NOT stream the backward —
14
+ it holds all 42 layers' recompute activations at once, so e4b's training backward
15
+ spikes to **23 GB live @2048** (resting is only 6.95 GB) and **crashes at 4096+**.
16
+ We proved a fix — **segmented backward** — that forces per-segment streaming:
17
+ forward saves detached boundary activations at segment edges, then backprop runs
18
+ segment-by-segment via a surrogate-loss `value_and_grad`. Only one segment's
19
+ activations are ever live. On an activation-dominated toy it beat per-layer
20
+ checkpointing (4.26 vs 5.97 GB) with **bit-exact gradients**. Extrapolated to e4b
21
+ this lands at **~10 GB @8K** — fitting on the 32 GB M1 Max, where the optiq/mlx-lm
22
+ reference cannot (it crashes on e4b @2048+). Next: wire it into the trainer,
23
+ MiniCPM5 first (clean), then e4b (handle KV-sharing + per-layer-input + the
24
+ O(L²) full-attention layers + mixed-precision quant in the segmentation plan).
25
+
26
+ ---
27
+
28
+ ## 1. What is already done and CORRECT (do not redo)
29
+
30
+ All committed-worthy, validated this session:
31
+
32
+ - **Chunk fine-tune experiment (the original goal) — DONE on MiniCPM5-1B.**
33
+ Baseline 11.89/100 → fine-tuned **91.70/100** on lucien's 25-case chunk holdout
34
+ (valid-JSON rate 12% → 100%). Full mlx-bun loop: train → save adapter (hot-swap
35
+ format) → `AdapterManager.mount` → generate. Writeup:
36
+ `docs/investigations/chunk-finetune-experiment.md`. Scripts: `scripts/chunk-eval.ts`,
37
+ `scripts/chunk-finetune.ts`, `scripts/chunk-filter.ts`. Adapter:
38
+ `~/.cache/mlx-bun-finetunes/minicpm5-chunk-final`.
39
+
40
+ - **`ops.sdpa` is CORRECT** (NOT the inverse of an earlier wrong claim). Validated
41
+ vs autograd ground truth (`scripts/sdpa-vs-manual.ts`): dQ/dK/dV all 0.00%. Use
42
+ it for training. It's O(L²) memory in the backward (mlx autographs the fused op).
43
+
44
+ - **Flash dK kernel FIXED.** The dKV Metal kernel emitted dK with its (Tkv,D) axes
45
+ transposed in the buffer; fix in `flashBackward` (`src/model/flash-attention.ts`):
46
+ `reshape(ops.contiguous(transposeAxes(dK,[0,1,3,2])), [B,Hkv,Tkv,D])`. Validated
47
+ vs autograd: dK 0.06% non-GQA, 0.12% GQA D=256 (e4b geom). flash is now correct +
48
+ O(L) memory. (But flash is ~30× slower per iter and crashes on e4b at 42-layer
49
+ scale — see §6. Default training attn is ops.sdpa; flash is opt-in via
50
+ `MLX_BUN_TRAIN_ATTN=flash`.)
51
+
52
+ - **Combined-eval fix in the trainer.** `trainer.ts` used to eval `value` (forward)
53
+ separately via `value.toFloat32()` before `globalNorm(grads)` — that frees the
54
+ forward and forces the backward to recompute+hold everything, crippling
55
+ checkpointing. Now one `evalAll([value, ...grads])` (Python's `mx.eval(v,g)`
56
+ pattern). Verified no MiniCPM5 regression. Toy: separate-eval 0.3% reduction →
57
+ combined-eval 16%.
58
+
59
+ - **Per-step memory instrumentation.** `MLX_BUN_MEM_LOG=1` logs
60
+ `PEAK(live) / active / cache` per step in `trainer.ts`.
61
+
62
+ ## 2. The diagnosis (why naive checkpointing fails) — measured, not guessed
63
+
64
+ Measurement methodology (the key unlock, courtesy of the active-vs-cache insight):
65
+ `mlx_get_peak_memory` = **peak LIVE (active)**, NOT active+cache (proved: toy
66
+ active-after 8.66 + cache-after 6.61 = 15.27 > peak 14.90, impossible if peak were
67
+ total). MLX holds freed buffers in a **cache** (not returned to OS) so RSS overstates
68
+ live memory; use `activeMemory()` / `cacheMemory()` / `clearCache()` to separate
69
+ them (`src/mlx/ffi.ts`). Caveat from the MoE expert-offload work
70
+ (`docs/investigations/expert-offload-single-user-moe.md`): `clearCache()` does NOT
71
+ reliably return device-buffer RAM to the OS — only the mmap clean-page path does.
72
+
73
+ Facts established:
74
+ - **Forward memory is FLAT** (~8 GB at any seq) — the forward streams fine. The
75
+ whole per-token cost is in the BACKWARD.
76
+ - **e4b @2048 (ops.sdpa + grad-ckpt): peak-live 23.2 GB, resting 6.95 GB.** The
77
+ 16 GB spike is per-layer MLP/hidden activations held across ALL 42 layers — the
78
+ checkpointed backward does NOT stream.
79
+ - **Checkpointing works and matches Python** (toy: both remove exactly 2.28 GB).
80
+ The earlier "checkpoint is 0%/broken" claim was a measurement artifact
81
+ (separate-eval + peak-vs-live confusion). It's NOT broken — it just doesn't
82
+ stream, and neither does Python's (optiq crashes on e4b @2048+ too).
83
+ - **e4b @4096 crashes immediately at ~7 GB RSS** (not gradual OOM). A 28 GB
84
+ `set_memory_limit` forcing cache eviction/reuse did NOT help → it's a genuine
85
+ backward-eval allocation failure, not cache non-reuse.
86
+ - Memory scales LINEARLY in seq: `peak(L) ≈ 7.7 GB + 7.6 MB/token`. @4096 ≈ 38 GB
87
+ (> 32 → crash). At the IDEAL streamed rate (~0.2–0.5 MB/tok) e4b@8K ≈ 10 GB.
88
+
89
+ ## 3. The fix: segmented backward (PROVEN)
90
+
91
+ Reference implementation + proof: **`scripts/ckpt-mem-test.ts`** (`SEG=n` mode).
92
+
93
+ Algorithm:
94
+ 1. **Forward, saving boundaries.** Run the layer stack; at each segment edge (group
95
+ of `n` layers) materialize (`eval`) and **detach** (`stopGradient`) the hidden
96
+ state into a boundary leaf. Discard intra-segment interiors. Boundaries are
97
+ small (one [B,L,hidden] per edge) and cheap.
98
+ 2. **Compute the loss** from the final hidden (LM head + response-only CE) and its
99
+ gradient w.r.t. the final hidden — this `dh_out` seeds the last layer segment.
100
+ 3. **Backward, reverse over segments.** For each segment k (last→first), build a
101
+ `value_and_grad` over `[boundary_leaf_k, ...segment_LoRA_params]` whose loss is
102
+ the surrogate `sum(stop_gradient(dh_out) ⊙ segment_forward(boundary_leaf, params))`
103
+ (the optiq chunked-CE surrogate, generalized to the whole backward). It returns
104
+ `[dh_in, ...dLoRA_segment]`. `dh_in` becomes `dh_out` for segment k−1; accumulate
105
+ `dLoRA`. Only segment k's activations are live during its `value_and_grad`.
106
+
107
+ Why it's exact: `d/dx sum(stop_grad(dh)⊙y) = (dy/dx)ᵀ dh = dLoss/dx`, and likewise
108
+ for params — this is reverse-mode AD done in segments. Verified **0.000%** vs the
109
+ full `value_and_grad`.
110
+
111
+ Proof numbers (activation-dominated toy, `L=8192 D=1024 LAYERS=24`, the e4b regime):
112
+
113
+ | method | peak | grads |
114
+ |---|---|---|
115
+ | full, no checkpoint | 6.81 GB | — |
116
+ | per-layer `Checkpoint` | 5.97 GB | — |
117
+ | **segmented (SEG=4)** | **4.26 GB** (active-live 2.52 ≈ resting 1.68 + 1 segment) | **0.000%** |
118
+ | segmented (SEG=2) | 4.66 GB | 0.000% |
119
+
120
+ Segmented beats per-layer checkpointing and approaches the ideal floor. **Optimal
121
+ granularity exists** (SEG=4 < SEG=2): a few layers/segment keeps kernel fusion while
122
+ still streaming. NOT all-at-once (spikes), NOT each-tiny-op (loses fusion).
123
+
124
+ Recompute cost: each segment's backward recomputes its own layers' forward once →
125
+ total recompute overhead ≈ **1× forward, independent of segment count**. So segment
126
+ count is a knob for PEAK, with ~fixed time cost (plus minor per-`value_and_grad`
127
+ and boundary-materialization overhead). The wall-clock target (finish the chunk
128
+ task in budget) is mostly set by this fixed 1× recompute, not the segment count.
129
+
130
+ ## 4. Integration plan
131
+
132
+ ### Phase A — MiniCPM5-1B first (clean real-model proof)
133
+ Plain 24-layer Llama: no KV-sharing, no per-layer-input, no sliding window, uniform-ish
134
+ quant. The trivial case to validate the machinery on a real model.
135
+
136
+ 1. **Model:** add `Gemma4Model`/`MiniCPM5Model` method `runLayerRange(h, aIdx, bIdx,
137
+ cache, ...) -> h'` that runs layers `[aIdx, bIdx)` on hidden `h` (the existing
138
+ `forwardLayers` loop body, parameterized by range). Keep `forwardLayers` as the
139
+ non-segmented path.
140
+ 2. **Trainer:** add a `segmented` training path (flag `TrainConfig.segments` or auto;
141
+ gate alongside the existing `gradCheckpoint`). It:
142
+ - runs the segmented forward (save detached boundaries),
143
+ - computes response-only CE loss + `dFinalHidden` (already memory-bounded),
144
+ - runs the reverse per-segment surrogate `value_and_grad`, threading the LoRA
145
+ params for each segment as that `value_and_grad`'s argnums, accumulating grads,
146
+ - also handles the embedding + (e4b) per-layer-input grads from the first
147
+ segment's `dh_in`.
148
+ 3. **Validate:** grads bit-match the current trainer (response-only CE, same seed);
149
+ measure peak drop with `MLX_BUN_MEM_LOG=1`. Expect a clear reduction even though
150
+ MiniCPM5 already fits — it's the template.
151
+
152
+ ### Phase B — e4b (the goal). Extra boundary state to thread across segments:
153
+ - **KV-shared donor cache.** Layers [0,24) are donors (own caches); [24,42) are
154
+ sharers reusing the last same-type donor's K/V. Concretely (verified in
155
+ `gemma4.ts`: `numDonors = numHiddenLayers − numKvSharedLayers = 24`,
156
+ `previousKvs`/`cacheIndex`): all sliding sharers reuse **donor 22**'s K/V, all
157
+ full sharers reuse **donor 23**'s K/V. So when a sharer falls in a later segment
158
+ than its donor, **save that donor's K/V (rope-applied) as a detached boundary**
159
+ and thread it into the sharer's segment forward AND its recompute. K/V are tiny
160
+ ([B,2,L,256] ≈ 1 KB/tok per donor), so this is cheap. (Note: `runCheckpointedLayer`
161
+ already threads shared K/V as checkpoint outputs — reuse that plumbing.)
162
+ - **Per-layer-input** (`computePerLayerInputs`): [B,L,42,256] computed once from the
163
+ embedding, sliced per layer. Compute it in the forward, **detach + save as a
164
+ boundary**, slice per layer inside each segment. Its gradient (back to the embed /
165
+ projection) accumulates from the per-segment grads.
166
+ - **Sliding vs full attention.** 7 full-attention layers (5,11,17,23,29,35,41); the
167
+ rest sliding (window 512). With ops.sdpa the **full layers are O(L²)** in the
168
+ backward (≈1 GB bf16 @8K for the [1,8,L,L] scores); sliding are O(L·512). The
169
+ segmentation must treat a full layer as a heavy unit (see §5).
170
+ - **Masks:** one per attention type (sliding/full), built at offset 0; pass into each
171
+ segment (cheap; the sliding [L,L] mask is the only quadratic term besides the full
172
+ attention — already handled by the current trainer).
173
+
174
+ ## 5. The segmentation strategy (the part to get right)
175
+
176
+ NOT a naive "divide by N". Compute the split points from the model:
177
+
178
+ **Constraints & inputs (all readable from the model/config):**
179
+ - Layer attention type per index (`layer_types`): sliding vs full. Full = O(L²) heavy.
180
+ - KV donor/sharer boundary at layer 24 (donors below, sharers above); donor→sharer
181
+ edges that must become saved boundaries (donors 22, 23).
182
+ - Per-layer mixed-precision quant bits (e.g. MiniCPM5's OptiQ config has layer-0 at
183
+ 8-bit, others 4/8 mixed; e4b's HF config is default 4-bit but the strategy must
184
+ read per-layer bits generically). Bits affect **compute time** per layer (8-bit
185
+ matmul slower than 4-bit), slightly the weight residency — NOT the activation size.
186
+ - Uniform per-layer activation size (hidden 2560 + MLP gate/up 2×10240, bf16).
187
+
188
+ **Memory cost model** (per candidate segmentation):
189
+ ```
190
+ peak ≈ resting(model + grads + optimizer + saved boundaries)
191
+ + max_over_segments( Σ_layers_in_seg uniform_activation(L)
192
+ + Σ_full_layers_in_seg attn_OL2(L) ) [+ backward transient]
193
+ ```
194
+ At 8K: uniform_activation ≈ 0.38 MB/tok·layer; attn_OL2(full, ops.sdpa) ≈ ~1 GB.
195
+ So a 6-layer segment with one full layer @8K ≈ 6·0.38·8192 ≈ 2.3 GB + ~1–2 GB full
196
+ ≈ 3.3–4.3 GB; with resting ~6.95 GB that's ~10–11 GB. **To stay ≤10 GB, isolate
197
+ full-attention layers** (put each in a short segment) and group sliding layers.
198
+
199
+ **Time cost model:** total ≈ base(fwd+bwd) + 1×fwd recompute (≈fixed) +
200
+ per-segment `value_and_grad`/boundary overhead (∝ segment count, small). Weight
201
+ per-layer forward time by quant bits + attention type. Target: ≤ a chosen
202
+ wall-clock for the chunk task (e.g. N iters × per-iter ≤ budget).
203
+
204
+ **Natural alignment:** the 6-layer period (`sssssF`) makes 7 segments of 6 (one full
205
+ layer each) the obvious first cut, with the donor/sharer boundary at 24 respected
206
+ (segments 0–3 = donors, 4–6 = sharers). If peak still > target at 8K, split each
207
+ 6-group around its full layer (e.g. [0–4 sliding][5 full] …) so no segment carries a
208
+ full layer plus many sliding layers. The optimizer picks split points to **minimize
209
+ peak subject to the time budget**, respecting: (a) KV donor→sharer boundaries become
210
+ saved K/V, (b) don't split mid-attention-pair unnecessarily, (c) keep ≥~3 layers per
211
+ segment for fusion.
212
+
213
+ **Deliverable:** a `planSegments(model, {L, peakTargetGB, timeBudget})` function that
214
+ returns the split indices + which donor K/V to save, computed from the above.
215
+
216
+ ## 6. Notes, risks, alternatives
217
+ - **Flash vs ops.sdpa for the full layers.** Flash (now dK-correct) is O(L) and would
218
+ remove the full-attention O(L²) term entirely — but flash is ~30× slower per iter
219
+ and currently crashes on e4b at 42-layer scale (immediate C++ exception; separate
220
+ bug). With segmented backward, the full-attention O(L²) is only transient within
221
+ one segment, so ops.sdpa may suffice; revisit flash only if the full term blows the
222
+ budget at 8K AND its e4b crash is fixed.
223
+ - The chunk data needs ≥4096 (median 4092; 0% ≤2048; 98% ≤8192). Success = e4b
224
+ trains at 8192 ≤ ~10 GB AND the chunk eval (`scripts/chunk-eval.ts`) shows a
225
+ quality delta on e4b like MiniCPM5's.
226
+ - Training requires `MLX_BUN_PERF_KERNEL=0 MLX_BUN_FUSED_GELU=0` (those fused
227
+ CustomKernels have no vjp).
228
+ - MLX `Date.now()`/random caveats don't apply here; standard determinism via seed.
229
+
230
+ ## 7. Key files
231
+ - `scripts/ckpt-mem-test.ts` — segmented-backward proof + memory harness (SEG/CKPT/REUSE, active/cache/peak).
232
+ - `scripts/ft-chunk-smoke.ts` — e4b train/fwd/infer smoke (GRAD_CKPT, MEM_LIMIT_GB, MLX_BUN_MEM_LOG).
233
+ - `scripts/sdpa-vs-manual.ts`, `scripts/flash-dk-debug.ts`, `scripts/flash-grad-test.ts` — gradient correctness.
234
+ - `scripts/chunk-{eval,finetune,filter}.ts` — the chunk experiment pipeline.
235
+ - `src/train/trainer.ts` — training loop (combined-eval, mem-log, gradCkpt ctx). Add the segmented path here.
236
+ - `src/train/forward.ts`, `src/train/loss.ts` — training forward + response-only CE.
237
+ - `src/model/gemma4.ts` — e4b model: `forwardLayers`, `runCheckpointedLayer`, `computePerLayerInputs`, KV-sharing (`numDonors`, `previousKvs`, `cacheIndex`), `layer_types`. Add `runLayerRange`.
238
+ - `src/model/minicpm5.ts` — MiniCPM5 (Phase A target).
239
+ - `src/mlx/autograd.ts` (`ValueAndGrad`), `src/mlx/checkpoint.ts` (`Checkpoint`), `src/mlx/ffi.ts` (memory fns, `stopGradient`, `evalAll`).
240
+ - Memory: `[[e4b-lora-training-seqlen-ceiling]]` (full findings), `[[opssdpa-dk-vjp-bug]]`, `[[three-level-fidelity-tree-model]]`.
241
+
242
+ ## 8. First action on resume
243
+ Phase A step 1: add `runLayerRange` to `MiniCPM5Model`, then a segmented training
244
+ path in `trainer.ts`, validate bit-exact grads + measure peak on
245
+ `scripts/ft-chunk-smoke.ts`-style MiniCPM5 run. Then Phase B (e4b) with the §5 plan.
246
+
247
+ ---
248
+
249
+ ## 9. Phase A IMPLEMENTED + validated (2026-06-16, session 2)
250
+
251
+ Phase A is built and the mechanism is **proven on the real MiniCPM5 model**.
252
+ Two findings landed (one a win, one a blocker). Code:
253
+ - `src/model/minicpm5.ts` — `runLayerRange(h, aIdx, bIdx, cache)` (added;
254
+ `forwardLayers` now delegates to it). NOTE it does **not** dispose its input
255
+ `h` (the caller / autograd owns the boundary leaf).
256
+ - `src/train/segmented.ts` — `SegmentedBackward` class (build once, `.step(batch)`
257
+ per iter) + `planSegmentsBySize`. Reusable per-segment value_and_grads.
258
+ - `src/train/trainer.ts` — `TrainConfig.segmentSize` (0 = off; >0 = layers per
259
+ segment). Gated in `sftLoop`, mutually exclusive with `gradCheckpoint`,
260
+ MiniCPM5-only for now. `src/train/job.ts` threads `segment_size`.
261
+ - `src/mlx/array.ts` — `MlxArray.fromBytesCopy(bytes, shape, dtype)` (a COPYING
262
+ leaf constructor via `mlx_array_new_data`, page-aligned, unlike `fromView`).
263
+ - `src/train/loss.ts` — `responseOnlyCe` is now exported.
264
+ - Harness: `scripts/segmented-grad-test.ts` (correctness + peak + `LEAK_LOOP`),
265
+ `scripts/seg-debug.ts` (forward-fidelity bisect), `scripts/vag-leak-test.ts`
266
+ (minimal value_and_grad leak repro). `scripts/chunk-finetune.ts` takes `SEG=n`.
267
+
268
+ ### 9.1 Correctness — the mechanism is exact
269
+ `scripts/segmented-grad-test.ts` (MiniCPM5, synthetic B=1 batch):
270
+ - **Under flash attention: grads bit-match the full value_and_grad — relNorm
271
+ 0.0000%, maxAbs 0.0, loss identical.** The segmentation algorithm (forward
272
+ boundaries → loss-head dh → reverse per-segment surrogate) is reverse-mode AD
273
+ done in segments, exactly equal to one big backward. PROVEN.
274
+ - Run training with `MLX_BUN_TRAIN_ATTN=flash` for bit-exact segmented grads.
275
+
276
+ ### 9.2 Forward-fidelity finding: `ops.sdpa` fused-eager ≠ autograd forward
277
+ Under the default `ops.sdpa`, the segmented grads diverge ~6% (relNorm) from the
278
+ full value_and_grad — **not** a segmentation bug. Root cause (bisected in
279
+ `scripts/seg-debug.ts`): mlx's fused `ops.sdpa` computes a **different bf16 result
280
+ in its eager forward (12.0712) than in its autograd-decomposed forward (12.0568)**
281
+ — a 0.12% gap, independent of LoRA (holds at lora scale 0). Flash uses one forward
282
+ for both → no gap. Consequences:
283
+ - The **existing non-segmented trainer already trains against the autograd forward
284
+ (12.0568) while inference uses the eager forward (12.0712)** — pre-existing, not
285
+ introduced here.
286
+ - Segmented saves boundaries with the eager forward but recomputes/backprops with
287
+ the autograd forward, so the dh seed is inconsistent under `ops.sdpa` → ~6% grad
288
+ perturbation (bf16-class; segmented's *loss* still matches the true eager forward
289
+ exactly). Fine for LoRA fine-tuning; use flash if you want exactness.
290
+
291
+ ### 9.3 Memory — the win is real and large
292
+ `MLX_BUN_MEM_LOG=1`, MiniCPM5 (24 layers, hidden 1536), peak LIVE:
293
+
294
+ | seq | full backward | segmented (SEG=4) | saved |
295
+ |---|---|---|---|
296
+ | 512 | 3.15 GB | 1.87 GB | 1.3 GB |
297
+ | 2048 | 10.91 GB | 3.44 GB | **7.5 GB (3.2×)** |
298
+ | 4096 | **21–26 GB** (spikes) | 6–8 GB | ~3× |
299
+
300
+ This is the thesis confirmed: at 4096 the full backward spikes to 21–26 GB (would
301
+ crash e4b); segmented stays at 6–8 GB. Segmented makes long-context training FIT.
302
+
303
+ ### 9.4 A per-segment memory leak — FOUND and FIXED (use mlx_vjp, not value_and_grad)
304
+ The first segmented implementation used the optiq surrogate-loss trick — a
305
+ `value_and_grad` of `sum(stop_grad(dh) (.) segment_forward(...))` per segment. It
306
+ **leaked ~32 MB of genuinely-live memory per segment per step** (linear, no
307
+ plateau: `LEAK_LOOP` 1.12 → 6.61 GB over 30 iters at SEG=4/2048; ~0.19 GB/step;
308
+ would OOM well before a 300-iter run). The leak survived `clearCache`,
309
+ `Bun.gc(true)`, `synchronize`, disposing every returned array, and reusing the
310
+ value_and_grads across steps. It was segmented-specific (the non-segmented trainer
311
+ is flat). Minimal repro that pins it to `value_and_grad`: `vag-leak-test`
312
+ **MODE=layers** (runLayerRange under value_and_grad) leaks ~6 MB/iter; **MODE=vjp**
313
+ (the same forward via mlx_vjp) is **flat**.
314
+
315
+ **Fix:** drive each segment's backward with **`mlx_vjp`** (the natural primitive:
316
+ forward + an explicit cotangent `dh`) instead of a surrogate-loss `value_and_grad`.
317
+ New binding `Vjp` in `src/mlx/autograd.ts` (+ `mlx_vjp` in `ffi.ts`). Per segment:
318
+ `vjp.apply([boundary, ...LoRA], [dh])` returns outputs (the segment output, unused)
319
+ and vjps `[dh_in, ...dLoRA]` — exactly what the chain needs, no surrogate scalar.
320
+ The loss head is likewise a vjp of `responseOnlyCe(finalNorm(boundary))` with a
321
+ scalar cotangent 1.0, returning the loss and `dh`. Result: **active is flat**
322
+ (0.93 GB × 15 iters in `LEAK_LOOP`; 1.04 GB × 12 iters end-to-end through the
323
+ trainer at SEQ=4096), with the memory win intact (peak 10.91 → 3.29 GB @2048).
324
+
325
+ NOTE on the OTHER memory fix that is also in place: mlx `eval` does **not** detach —
326
+ an eval'd array keeps its upstream graph. Each forward boundary is therefore copied
327
+ into a fresh leaf via `detachLeaf` (`MlxArray.fromBytesCopy`), otherwise each
328
+ boundary drags a whole layer-stack's activations (~0.1 GB per 4-layer segment of
329
+ *within-step* retention before this fix). This is independent of the vjp fix.
330
+
331
+ ### 9.5 STATUS: Phase A complete. Next actions
332
+ Phase A (MiniCPM5) is done and production-ready: mechanism bit-exact (flash),
333
+ memory win confirmed, no leak, trains end-to-end through `scripts/chunk-finetune.ts`
334
+ (`SEG=n`). Remaining:
335
+ 1. ✅ **DONE** — real 300-iter segmented fine-tune (SEG=4, SEQ=4096, ops.sdpa):
336
+ **peak 6.51 GB** (non-seg baseline was 25.47 GB), 35.6 min, val loss 0.231→0.189,
337
+ no leak (active flat at 1.04 GB the whole run). `chunk-eval` on the holdout:
338
+ **95.10/100** — EXCEEDS the non-segmented baseline (91.70) and the pre-tune
339
+ baseline (11.89). json/schema/labels/nonempty 100%, anchors 86%. Segmented
340
+ training produces a quality adapter. Adapter:
341
+ `~/.cache/mlx-bun-finetunes/minicpm5-chunk-segmented`.
342
+ 2. **Phase B (e4b)** with the §5 plan (KV-sharing, per-layer-input, full-attn O(L²)
343
+ isolation). e4b adds `runLayerRange` to `Gemma4Model` with donor-KV /
344
+ per-layer-input boundary threading; the `SegmentedBackward` + vjp machinery and
345
+ the §5 segmentation strategy carry over. This is the path to the original goal:
346
+ e4b @8K under ~10 GB where the optiq/mlx-lm reference crashes.
347
+
348
+ ---
349
+
350
+ ## 10. Phase B (e4b) — BUILT + VALIDATED (2026-06-16)
351
+
352
+ **Status: e4b segmented backward works.** `SegmentedBackwardGemma4`
353
+ (`src/train/segmented.ts`) + the model-side `runLayerRange` are done, wired into
354
+ the trainer (Gemma4 path), and validated on the real e4b
355
+ (`scripts/segmented-grad-test-e4b.ts`):
356
+ - **Forward bit-exact** vs the full value_and_grad (loss identical at every L).
357
+ - **Grads: single-consumer donor reuse is BIT-EXACT** (2-segment split at 24:
358
+ 0/258 targets off). With the natural 7-segment cut the donor K/V grad is ~0.97%
359
+ (relNorm) off the full backward — **bf16 non-associativity, established by
360
+ discriminating tests (NOT a logic bug, and NOT "irreducible because I must match
361
+ bf16" — that earlier inference was wrong):**
362
+ - single-consumer reuse is bit-exact (no sum to reorder);
363
+ - the error is FLAT in consumer-segment count (1→0%, 2→0.98%, 3→0.97%, 6→0.96%,
364
+ 9→1.16% — jumps once, doesn't compound);
365
+ - it tracks SUMMAND count, not segment count: donor 22 (sliding, 15 sharers)
366
+ ≈0.46%, donor 23 (full, 3 sharers) ≈0.000%;
367
+ - the donor-22 grad is grouping-DEPENDENT (~0.5% moving from a 2- vs 9-consumer
368
+ split) for BOTH bf16 AND fp32 accumulation — neither is grouping-invariant.
369
+ fp32 cross-segment accumulation does NOT help (identical at the natural cut,
370
+ marginally worse at fine cuts): the dominant non-associativity is mlx's
371
+ WITHIN-vjp bf16 cotangent sum per consumer, which regrouping the sharers
372
+ changes; only an fp32 forward could remove it. So bf16 is the right (simplest)
373
+ choice; ~1% is bf16-class and fine for LoRA. (The producer's `dh_in` depends on
374
+ `dKV`, so the donor-22 ~0.46% smears thinly across earlier layers — broad but
375
+ small, ~1.4% worst-target.)
376
+ - **The error is CONTROLLABLE BY GROUPING, not fixed by nature.** The lever is
377
+ the SEGMENT CUT — how a donor's sharers distribute across consumer segments —
378
+ NOT the accumulation dtype. The floor is bit-exact: with all of a donor's
379
+ sharers in ONE consumer segment (the 1-consumer cut) there is no sum to
380
+ reorder, 0/258 off. Fewer/larger consumer segments → tighter grad (toward
381
+ bit-exact) but higher peak; more/smaller → ~1% but lower peak. So if you ever
382
+ needed it tighter, keep a donor's sharers together (coarser sharer-side cut),
383
+ trading some peak for fidelity. You almost certainly won't — ~0.5–1% bf16-class
384
+ is fine for LoRA (CPM5 converged healthily to 95.10) — but the lever exists and
385
+ it is the cut, not the dtype.
386
+ - **Memory — VERIFIED vs the real reference (`mlx_lm.lora --train --grad-checkpoint`,
387
+ the way a user trains, oracle venv, freshly measured).** An earlier draft of
388
+ this doc compared against *mlx-bun's own* checkpointed full backward (22.96 GB
389
+ @2048, crash @4096) and claimed "the reference crashes at 4K" — that was WRONG:
390
+ mlx-bun's gradient checkpointing is itself ineffective
391
+ (`[[e4b-lora-training-seqlen-ceiling]]` pt 9), so it is NOT the honest baseline.
392
+ **GROUPING — they differ, and the first table I drew did not match them.**
393
+ mlx-lm checkpoints PER-LAYER: `grad_checkpoint(model.layers[0])` patches
394
+ `type(layer).__call__` to wrap every layer in `mx.checkpoint`, i.e. 42 groups of
395
+ 1, fixed (no coarser/finer knob). Ours groups `segmentSize` layers per segment
396
+ (tunable). The honest, MATCHED-grouping comparison (both per-layer = group of 1,
397
+ all 42 layers, batch 1):
398
+
399
+ | L | mlx-lm (per-layer) | ours segSize=1 (per-layer) | ours segSize=6 |
400
+ |---|---|---|---|
401
+ | 2048 | 12.84 GB | **8.76 GB** | 11.05 |
402
+ | 4096 | 20.87 GB | **10.93 GB** | 16.06 |
403
+ | 8192 | **OOM** (25.72 @16 layers) | **15.29 GB** | 17.5 (seg2) |
404
+
405
+ So at matched grouping ours is lower at every length, AND at 8K mlx-lm OOMs
406
+ training all 42 layers (must drop to 16, 25.7 GB) while ours trains all 42.
407
+ **Two confounds make this not a pure segmentation-vs-checkpoint number, and I
408
+ have not isolated them:** (1) mlx-lm's `default_loss` materializes the full
409
+ `[1,L,262144]` logits + their grad (~4 GB @4K), while ours uses response-only CE;
410
+ (2) ours fully streams the backward (detached per-segment value_and_grads) vs
411
+ mlx-lm's per-layer checkpoint inside one backward. Both are legitimate mlx-bun
412
+ choices, but the headline gap is part CE-optimization, part mechanism — not all
413
+ "segmentation beats checkpointing." (segSize is the peak↔grad-fidelity knob:
414
+ finer = lower peak but more donor-KV bf16 deviation; §10 grad note.) Separately,
415
+ mlx-bun's OWN checkpoint is the broken one (23 GB @2048 vs mlx-lm's 12.8) — that's
416
+ why segmented matters *within* mlx-bun, and fixing it is orthogonal.
417
+ - **Trainer end-to-end** (e4b, SEQ=2048, SEG=4, real chunk data): peak 10 GB,
418
+ active flat (no leak), adapter saved.
419
+ - TWO contiguity fixes were essential: `detachLeaf` must force a row-major copy
420
+ (`ops.contiguous`) — the donor K/V are TRANSPOSED views and `rawBytes` reads
421
+ the buffer linearly (without this the forward was 4% wrong); and the producer
422
+ segment outputs contiguous donor K/V to match the row-major cotangent.
423
+
424
+ **To reach the ~10 GB @8K target** (currently 17.5 GB @8K, seg2): the resting is
425
+ ~6.6 GB (e4b weights), so ~3.4 GB transient is the budget. Next is the §5
426
+ full-attention ISOLATION `planSegments` — put each O(L²) full-attn layer
427
+ (5,11,17,23,29,35,41) in its own short segment so it isn't materialized alongside
428
+ sliding-layer activations — plus possibly flash for the full layers (O(L); flash
429
+ crashes on e4b at multi-K today — a separate bug). The mechanism + tunable knob
430
+ are proven; this is a peak-optimization, not a blocker.
431
+
432
+ **Model side** (`src/model/gemma4.ts`, additive — `forwardLayers` untouched):
433
+ - `runLayerRange(h, aIdx, bIdx, cache, masks, perLayer, donorKvIn) -> {h, donorKvOut}`
434
+ — runs layers `[aIdx,bIdx)`, slices `perLayer` per layer, threads KV-shared
435
+ donors: a sharer reads its donor's K/V from `donorKvIn` (earlier segment) or
436
+ `donorKvOut` (same range); reused donors' K/V (e4b: {22,23}) are returned for
437
+ later segments, non-reused donors' K/V disposed. Does NOT dispose `h`.
438
+ - `makeTrainingMasks(cache, L)` — sliding + full masks, built once.
439
+ - `embedForSegmented(ids) -> {hScaled, perLayer}` — scaled input embedding (first
440
+ boundary) + the `[B,L,nLayers,width]` per-layer-input tensor.
441
+ - `reusedDonors` getter ({22,23} for e4b, ∅ for dense).
442
+ - **Validate first** (when GPU free): running the full range through `runLayerRange`
443
+ must bit-match `forwardLayers` (a forward-equivalence check on the 12B/e4b parity
444
+ harness) before trusting the backward.
445
+
446
+ - **PARITY vs mlx-lm — VERIFIED bit-exact (the correctness oracle).** Ran
447
+ `mlx_lm.lora`'s actual `default_loss` and `nn.value_and_grad` against ours on the
448
+ SAME fixed 2048-token input (`scripts/parity-vs-mlxlm.ts`,
449
+ `scripts/grad-parity-vs-mlxlm.ts`; mlx-lm side in `/tmp/mlxlm-*-parity.py`):
450
+ - **forward + loss:** verified across 6 mask spans (varying promptLen incl. the
451
+ edges ntoks=2047 and ntoks=1), checking the SUMMATION not just one number:
452
+ the **denominator** (our `M = (len-1)-max(0,promptLen-1)`) == mlx-lm's
453
+ `ntoks = mask.sum()` BIT-EXACT on every span; **response-only CE == full-masked
454
+ CE** on every span (so response-only is a pure memory optimization, not a
455
+ parity risk); the loss is BIT-EXACT vs mlx-lm on 4/6 spans and **0.5 float32-ulp**
456
+ (~1e-6, ~5e-5%) on 2 — a logsumexp/reduction f32 non-associativity (full-masked
457
+ shows the same residual, so it's NOT the response-only summation), far below
458
+ bf16 and harmless;
459
+ - **gradients (FULL value_and_grad = our non-segmented trainer): BIT-EXACT** — dB
460
+ over all 42 q_proj layers (identical injected A, B=0, rank 8, scale 20) matches
461
+ mlx-lm 0.000e+0. So mlx-bun's non-segmented training equals mlx-lm's bit-for-bit
462
+ (loss AND grad), extending the inference logit-parity oracle to training;
463
+ - **segmented gradients: ~2.3% (bf16-class)** off mlx-lm (q_proj dB) — the
464
+ donor-KV bf16 non-associativity (above), not a logic bug. So the SEGMENTED path
465
+ is NOT bit-exact with mlx-lm; it's bf16-class. Both `ops.sdpa` value_and_grads
466
+ (ours and mlx-lm's) use the same autograd forward, which is why FULL is exact.
467
+ (Config note: mlx-lm's default LoRA scale is 20.0; mlx-bun's `DEFAULT_TRAIN_CONFIG`
468
+ is 1.0 — a config difference, not correctness. The chunk runs used 1.0.)
469
+
470
+ **Trainer side — `SegmentedBackwardGemma4` (to build + validate):** same skeleton as
471
+ the MiniCPM5 `SegmentedBackward`, with TWO additions.
472
+
473
+ (a) **per-layer-input is a pure constant boundary — NO gradient threading.** The
474
+ `per_layer_input_gate` / `per_layer_projection` LoRA live INSIDE the layer (applied
475
+ to the sliced `pls`), so they're differentiated within each segment's vjp normally.
476
+ The `perLayer` tensor itself feeds in as a constant (its grad would flow to the
477
+ embed / `per_layer_model_projection`, neither a LoRA target) — so detach it once and
478
+ slice per segment; discard its cotangent. Easy.
479
+
480
+ (b) **the KV-shared donor K/V is a SECOND boundary stream WITH its own cotangent.**
481
+ The donor's K/V depend on the donor layer's q/k/v LoRA and are attended by every
482
+ sharer, so `dLoss/d(donor LoRA)` gets contributions from the donor's own attention
483
+ AND all sharers. Threading:
484
+ - **Forward (boundary-saving):** when a segment produces a reused donor's K/V
485
+ (`donorKvOut`), detach it (rope-applied K + V, tiny — ~1 KB/tok) and save as a
486
+ boundary; later sharer segments pass it in via `donorKvIn`.
487
+ - **Backward (reverse), two extra pieces:**
488
+ - keep a cotangent accumulator `dKV[d]` per reused donor d (init zero).
489
+ - a SHARER segment's vjp differentiates `[boundary_h, donorK, donorV, ...LoRA]`
490
+ with cotangent `[dh]`; its vjps for `donorK/donorV` are ACCUMULATED into `dKV[d]`.
491
+ - a DONOR segment (produces donor d's K/V) has a MULTI-OUTPUT forward
492
+ `[h_out, donorK, donorV]`; its vjp takes cotangents `[dh, dKV[d].k, dKV[d].v]`
493
+ (the `Vjp` class already supports nOut>1 + multiple cotangents) and returns
494
+ `[dh_in, ...donor LoRA]`. This folds the sharers' gradient back into the donor.
495
+ - donors 22 (sliding) and 23 (full) are each reused by all sharers of their type;
496
+ if both live in one segment, that segment's forward outputs 5 arrays
497
+ `[h, k22, v22, k23, v23]` with 5 cotangents.
498
+ - **Segmentation (the §5 plan):** respect the donor/sharer split at layer 24 and
499
+ ISOLATE the 7 full-attention layers (5,11,17,23,29,35,41) — each is O(L²) in the
500
+ backward under ops.sdpa (~1 GB bf16 @8K), so put each full layer in a short
501
+ segment and group the sliding layers. The natural first cut is the 6-layer
502
+ `sssssF` period → 7 segments of 6 (one full layer each). `planSegments` from §5
503
+ returns the split indices + which donor K/V to save.
504
+
505
+ Everything else (forward boundaries via `detachLeaf`, head vjp with cotangent 1.0,
506
+ reverse `dh` chain, `mlx_vjp` not surrogate) is identical to the MiniCPM5 path.
507
+ Target: e4b @8K ≤ ~10 GB, then `chunk-eval` quality on e4b (the original goal —
508
+ chunk data is mostly 3.4–8.2K, useless at the ≤2K the reference is capped to).
@@ -0,0 +1,84 @@
1
+ # Fine-tuning experiment: chunking task (2026-06-16)
2
+
3
+ Goal (from Josh): test mlx-bun's fine-tuning end-to-end on lucien's conversation-
4
+ chunking task — baseline → LoRA fine-tune → re-measure the quality delta.
5
+
6
+ ## Headline result
7
+
8
+ A LoRA fine-tune of **MiniCPM5-1B** on the chunk task, trained and served entirely
9
+ by mlx-bun, on the 25-case frozen holdout (`lucien/benchmark/dataset/chunk.json`),
10
+ scored with lucien's exact `scoreChunk` metric:
11
+
12
+ | Metric | Baseline (stock) | Fine-tuned | Δ |
13
+ |---|---|---|---|
14
+ | **Mean score / 100** | **11.89** | **91.70** | **+79.8 (6.7×)** |
15
+ | Valid JSON | 12% (3/25) | 100% (25/25) | +88 |
16
+ | schema | 100% | 100% | — |
17
+ | anchors | 97% | 78% | −19 |
18
+ | labels | 100% | 96% | −4 |
19
+ | nonempty | 100% | 100% | — |
20
+
21
+ The baseline failure was pure **format**: the stock 1B model rambled past the token
22
+ limit without emitting parseable JSON on 88% of cases. After fine-tuning, every case
23
+ emits valid, well-formed chunk JSON and **stops** (68–456 generated tokens vs the
24
+ baseline hitting the 1536 cap). This validates the full mlx-bun pipeline:
25
+ **train (ops.sdpa) → save adapter (hot-swap format) → `AdapterManager.mount` → generate.**
26
+
27
+ ## Why not e4b (the original target)
28
+
29
+ The original plan targeted `gemma-4-e4b-it-OptiQ-4bit`. The chunk data is long
30
+ (median 4092 tokens, 0% ≤2048, 98% ≤8192), so it needs 4–8K training context. On
31
+ this **32 GB M1 Max**, neither mlx-bun NOR the optiq reference can train a 7.4B-param
32
+ model at that length: I ran optiq's own `train_lora` on e4b and it completed only at
33
+ seq 512 (peak 18.24 GB, degenerate — 0 response tokens survive truncation) and
34
+ **crashed at 2048/4096/8192** with `[METAL] Command buffer execution failed: Impacting
35
+ Interactivity` — a memory-pressure GPU stall. It's a hardware ceiling, not a code gap.
36
+ Pivoted to **MiniCPM5-1B** (24 layers, head_dim 128, full attention), which fits 4–8K
37
+ training comfortably. See [[e4b-lora-training-seqlen-ceiling]].
38
+
39
+ ## Correctness correction: ops.sdpa vs flash
40
+
41
+ Earlier in the session I concluded mlx-bun's `ops.sdpa` had a wrong dK gradient and
42
+ built/routed a flash kernel as the "correct" training attention. **That was backwards.**
43
+ A clean check (`scripts/sdpa-vs-manual.ts`) against **mlx-autograd of a plain
44
+ matmul/softmax forward** (the unimpeachable ground truth) shows:
45
+
46
+ ```
47
+ dQ: sdpa-vs-manual= 0.00% flash-vs-manual= 0.06%
48
+ dK: sdpa-vs-manual= 0.00% flash-vs-manual= 106.01% ← flash is the broken one
49
+ dV: sdpa-vs-manual= 0.00% flash-vs-manual= 0.02%
50
+ ```
51
+
52
+ `ops.sdpa` is correct (and fast — one fused kernel). My flash dK kernel is the buggy
53
+ one; the earlier triangulation was fooled by a hand-derived FA-2 backward reference that
54
+ shares the dK formula with the flash kernel. The trainer now defaults to `ops.sdpa`
55
+ (flash opt-in via `MLX_BUN_TRAIN_ATTN=flash`). ops.sdpa's only downside is O(seq²)
56
+ backward memory — fine at ≤4096 for a 1B model (crashes ~8192 on long examples).
57
+
58
+ ## Caveats (honest)
59
+
60
+ - **The benchmark metric is intrinsic** (JSON validity / schema / anchor resolution /
61
+ label quality), not segmentation-F1 vs gold boundaries. The +79.8 reflects "produces
62
+ valid, well-formed output," which is exactly what was broken.
63
+ - **anchors 97%→78%**: the fine-tuned model occasionally emits an anchor UUID that needs
64
+ prefix-repair; one 47K-token case failed to resolve (score 50).
65
+ - **Under-segmentation**: the fine-tuned model emits 1 chunk on most conversations. The
66
+ training data was filtered to ≤4000 tokens (730/1375 examples) for speed, biasing
67
+ toward shorter single-topic conversations. Training on the full data at 8192 (or with
68
+ the flash dK fixed for O(L) memory) would likely improve multi-chunk segmentation.
69
+
70
+ ## Reproduce
71
+
72
+ ```bash
73
+ # baseline
74
+ bun scripts/chunk-eval.ts # 11.89/100
75
+ # filter data, fine-tune, re-measure
76
+ MAXTOK=4000 bun scripts/chunk-filter.ts
77
+ DATA=~/.cache/mlx-bun-finetunes/chunk-data-le4000 SEQ=4096 ITERS=300 \
78
+ ADAPTER=~/.cache/mlx-bun-finetunes/minicpm5-chunk-final bun scripts/chunk-finetune.ts
79
+ ADAPTER=~/.cache/mlx-bun-finetunes/minicpm5-chunk-final bun scripts/chunk-eval.ts # 91.70/100
80
+ ```
81
+
82
+ Training: 300 iters, seq 4096, rank 16, lr 2e-4, ops.sdpa, ~26 min, peak 25.47 GB, val
83
+ loss flat ~0.19 (no overfit). Scripts: `chunk-eval.ts`, `chunk-filter.ts`,
84
+ `chunk-finetune.ts`, `sdpa-vs-manual.ts`.