mlx-bun 0.2.0 → 0.3.0

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 (364) hide show
  1. package/README.md +71 -588
  2. package/docs/README.md +91 -0
  3. package/docs/{design → archive/investigations}/audio-input-plan.md +4 -4
  4. package/docs/{design → archive/investigations}/diffusion-gemma-port.md +3 -3
  5. package/docs/{design → archive/investigations}/faithful-l1-consolidation.md +4 -4
  6. package/docs/{design → archive/investigations}/minicpm5-decode-megakernel.md +11 -11
  7. package/docs/{design → archive/investigations}/optimization_plan.md +1 -1
  8. package/docs/{design → archive/investigations}/pre-colibri-stabilization.md +5 -5
  9. package/docs/{design → archive/investigations}/the-dreaming-handoff.md +2 -2
  10. package/docs/design/batching.md +569 -0
  11. package/docs/design/decode-speed-program.md +3178 -169
  12. package/docs/design/dreaming-nightly-pipeline.md +675 -282
  13. package/docs/design/generic-model-support.md +579 -334
  14. package/docs/design/kv-cache.md +680 -0
  15. package/docs/design/orpo-training.md +673 -1239
  16. package/docs/design/speculative-decoding.md +1627 -0
  17. package/docs/design/turboquant.md +2102 -0
  18. package/docs/design/unified-engine-frontier-plan.md +1831 -727
  19. package/docs/design/web-chat-redesign.md +666 -1083
  20. package/docs/planning/PRODUCT_ROADMAP.md +34 -1
  21. package/docs/planning/release-notes-v0.3.0.md +35 -0
  22. package/docs/planning/video-series.md +2 -2
  23. package/docs/reference/benchmarks.md +1162 -0
  24. package/docs/reference/cli.md +394 -159
  25. package/docs/reference/distribution.md +321 -182
  26. package/docs/reference/environment.md +348 -0
  27. package/docs/reference/glossary.md +39 -0
  28. package/docs/reference/library-api.md +566 -168
  29. package/docs/reference/memory.md +227 -64
  30. package/docs/reference/models.md +104 -6
  31. package/docs/reference/server-api.md +886 -425
  32. package/docs/reference/server-config.md +638 -330
  33. package/docs/reference/training.md +393 -254
  34. package/docs/reference/troubleshooting.md +166 -0
  35. package/package.json +9 -4
  36. package/src/audio/decode.ts +2 -2
  37. package/src/audio/features.ts +2 -2
  38. package/src/backends/mlx/autoregressive.ts +108 -0
  39. package/src/backends/mlx/batch-group.ts +1646 -0
  40. package/src/backends/mlx/diffusion.ts +39 -0
  41. package/src/backends/mlx/draft-target.ts +47 -0
  42. package/src/backends/mlx/evaluation.ts +105 -0
  43. package/src/backends/mlx/fill-append.ts +45 -0
  44. package/src/backends/mlx/gateway-binding.ts +71 -0
  45. package/src/backends/mlx/graph.ts +38 -0
  46. package/src/backends/mlx/memory-client.ts +189 -0
  47. package/src/backends/mlx/memory-guard.ts +40 -0
  48. package/src/backends/mlx/methods.ts +90 -0
  49. package/src/backends/mlx/model-prompt.ts +164 -0
  50. package/src/backends/mlx/model-serving.ts +79 -0
  51. package/src/backends/mlx/prefill.ts +33 -0
  52. package/src/backends/mlx/request-policy.ts +20 -0
  53. package/src/backends/mlx/rollback.ts +51 -0
  54. package/src/backends/mlx/serial-executor.ts +273 -0
  55. package/src/backends/mlx/speculative.ts +114 -0
  56. package/src/backends/mlx/state-views.ts +26 -0
  57. package/src/backends/mlx/text-engine.ts +73 -0
  58. package/src/cli.ts +58 -25
  59. package/src/client.ts +66 -0
  60. package/src/config.ts +21 -4
  61. package/src/contracts/completion.ts +16 -0
  62. package/src/contracts/execution.ts +49 -0
  63. package/src/contracts/generation.ts +103 -0
  64. package/src/contracts/host.ts +7 -0
  65. package/src/contracts/jobs.ts +66 -0
  66. package/src/contracts/pi-provider.ts +30 -0
  67. package/src/contracts/pi.ts +259 -0
  68. package/src/contracts/resources.ts +18 -0
  69. package/src/contracts/scheduling.ts +27 -0
  70. package/src/contracts/tsconfig.json +14 -0
  71. package/src/diffusion/diffusion-generate.ts +218 -153
  72. package/src/embed.ts +1 -1
  73. package/src/engine/admission.ts +59 -0
  74. package/src/engine/cancellation.ts +38 -0
  75. package/src/engine/engine.ts +56 -0
  76. package/src/engine/execution-coordinator.ts +70 -0
  77. package/src/engine/execution-plan.ts +51 -0
  78. package/src/engine/resources.ts +42 -0
  79. package/src/engine/scheduler.ts +67 -0
  80. package/src/engine/session.ts +247 -0
  81. package/src/eval/README.md +44 -1
  82. package/src/eval/kl.ts +236 -3
  83. package/src/eval/reference-logits.ts +207 -0
  84. package/src/eval/runner.ts +8 -96
  85. package/src/fill/echo-index.ts +188 -0
  86. package/src/fill/fill-session.ts +452 -0
  87. package/src/fill/proposal.ts +68 -0
  88. package/src/fill/schema-rows.ts +506 -0
  89. package/src/fill/tool-boundary.ts +103 -0
  90. package/src/fit.ts +1 -1
  91. package/src/generate.ts +656 -268
  92. package/src/grammar.ts +12 -7
  93. package/src/harness-pi.ts +14 -33
  94. package/src/hub-rest.ts +4 -4
  95. package/src/index.ts +7 -0
  96. package/src/inference/denoising.ts +17 -0
  97. package/src/inference/graph.ts +33 -0
  98. package/src/inference/prefill.ts +39 -0
  99. package/src/inference/rollback.ts +7 -0
  100. package/src/jobs/db.ts +4 -0
  101. package/src/jobs/index.ts +2 -0
  102. package/src/jobs/job-entry.ts +12 -12
  103. package/src/jobs/runner.ts +98 -13
  104. package/src/jobs/task-client.ts +19 -0
  105. package/src/jobs/types.ts +2 -58
  106. package/src/kv-store.ts +109 -49
  107. package/src/lab/README.md +18 -0
  108. package/src/{assets → lab/curve}/curve-designer.html +2 -2
  109. package/src/{curve-sampler.ts → lab/curve/curve-sampler.ts} +2 -2
  110. package/src/{expert-trace.ts → lab/expert-trace/expert-trace.ts} +5 -5
  111. package/src/{model → lab/paged-kv}/paged-kv.ts +9 -6
  112. package/src/library.ts +40 -0
  113. package/src/lora.ts +18 -0
  114. package/src/media-fetch.ts +1 -1
  115. package/src/memory/article.ts +1 -1
  116. package/src/memory/chunk.ts +1 -1
  117. package/src/memory/model.ts +30 -169
  118. package/src/memory/prompts.ts +1 -1
  119. package/src/memory/rest.ts +1 -1
  120. package/src/memory/schedule.ts +1 -1
  121. package/src/memory/stages.ts +4 -1
  122. package/src/memory/vault.ts +1 -3
  123. package/src/mlx/array.ts +9 -10
  124. package/src/mlx/compile.ts +12 -2
  125. package/src/mlx/ffi.ts +58 -11
  126. package/src/mlx/materialize.ts +23 -0
  127. package/src/mlx/metal-kernel.ts +10 -11
  128. package/src/mlx/ops.ts +33 -13
  129. package/src/mlx/quantized-rows.ts +36 -0
  130. package/src/mlx/token-bitmask.ts +52 -0
  131. package/src/mlx/turboquant-kv-decode.ts +82 -0
  132. package/src/mlx/turboquant-kv-inverse.ts +122 -0
  133. package/src/mlx/turboquant-ops.ts +33 -3
  134. package/src/mlx/turboquant-tables.ts +2 -2
  135. package/src/model/batched-mask.ts +8 -2
  136. package/src/model/batched-quant.ts +1 -1
  137. package/src/model/batched-rotating-quant.ts +2 -2
  138. package/src/model/batched-rotating.ts +1 -1
  139. package/src/model/compiled-decode.ts +1 -1
  140. package/src/model/diffusion-gemma.ts +8 -8
  141. package/src/model/factory.ts +76 -33
  142. package/src/model/fingerprint.ts +1 -1
  143. package/src/model/flash-attention.ts +3 -3
  144. package/src/model/gemma4-base.ts +26 -9
  145. package/src/model/gemma4.ts +6 -6
  146. package/src/model/generated/gemma4-12b.ts +2 -2
  147. package/src/model/generated/gemma4-26b.ts +2 -2
  148. package/src/model/generated/gemma4-e4b.ts +2 -2
  149. package/src/model/glm52-dsa.ts +13 -9
  150. package/src/model/glm52-memory.ts +1 -1
  151. package/src/model/implementation.ts +69 -0
  152. package/src/model/minicpm5.ts +6 -8
  153. package/src/model/profile.ts +12 -2
  154. package/src/model/qwen-append.ts +12 -0
  155. package/src/model/qwen-conv.ts +52 -0
  156. package/src/model/qwen3_5-checkpoint.ts +164 -0
  157. package/src/model/qwen3_5.ts +140 -56
  158. package/src/model/support.ts +1 -1
  159. package/src/model/trellis-balanced-scatter.ts +51 -0
  160. package/src/model/trellis-linear.ts +672 -0
  161. package/src/model/trellis-shared-m.ts +151 -0
  162. package/src/model/trellis-shared-scatter.ts +144 -0
  163. package/src/model/trellis-splitk-prefill.ts +158 -0
  164. package/src/model/trellis-tiled-prefill.ts +136 -0
  165. package/src/model/trellis-vector-expand.ts +62 -0
  166. package/src/model/trellis-wide-prefill.ts +94 -0
  167. package/src/model/universal/dense.ts +1 -1
  168. package/src/model/universal/modules.ts +8 -1
  169. package/src/native-pack.ts +6 -5
  170. package/src/pi-provider.ts +6 -64
  171. package/src/pi-web.ts +8 -315
  172. package/src/prompt-cache.ts +96 -53
  173. package/src/quantize/allocator.ts +1 -1
  174. package/src/quantize/config-writer.ts +10 -3
  175. package/src/quantize/rotate.ts +4 -4
  176. package/src/quantize/trellis.ts +586 -0
  177. package/src/responses.ts +28 -1
  178. package/src/runtime-config.ts +18 -4
  179. package/src/sampler.ts +4 -4
  180. package/src/serve/batch-scheduler.ts +8 -1671
  181. package/src/serve/chat-request.ts +391 -0
  182. package/src/serve/chat-stage.ts +195 -0
  183. package/src/serve/checkpoint-identity.ts +64 -0
  184. package/src/serve/completion-executor.ts +15 -7
  185. package/src/serve/discovery-routes.ts +12 -13
  186. package/src/serve/generation-gateway.ts +154 -193
  187. package/src/serve/http.ts +159 -0
  188. package/src/serve/inference-request.ts +79 -0
  189. package/src/serve/isolate.ts +297 -105
  190. package/src/serve/lab-routes.ts +3 -0
  191. package/src/serve/lane-registry.ts +2 -1
  192. package/src/serve/model-admin-routes.ts +7 -5
  193. package/src/serve/model-binding.ts +43 -0
  194. package/src/serve/model-host.ts +512 -0
  195. package/src/serve/openai-wire.ts +103 -0
  196. package/src/serve/parent-application.ts +56 -0
  197. package/src/serve/pipeline.ts +33 -0
  198. package/src/serve/preparation.ts +33 -0
  199. package/src/serve/request-plan.ts +19 -15
  200. package/src/serve/request-prep.ts +346 -0
  201. package/src/serve/response-lease.ts +24 -0
  202. package/src/serve/responses-client.ts +69 -0
  203. package/src/serve/session-completion-engine.ts +110 -0
  204. package/src/serve/session-search.ts +1 -1
  205. package/src/serve/static-routes.ts +5 -5
  206. package/src/serve/text-completion-stage.ts +104 -0
  207. package/src/serve/token-streams.ts +519 -0
  208. package/src/server.ts +317 -2649
  209. package/src/spec/assistant-source.ts +10 -44
  210. package/src/spec/deepspec-source.ts +6 -6
  211. package/src/spec/dflash-source.ts +8 -8
  212. package/src/spec/dspark/ab-stats.ts +3 -3
  213. package/src/spec/dspark/calibration.ts +2 -2
  214. package/src/spec/dspark/deepspec-module.ts +2 -2
  215. package/src/spec/dspark/generate-dflash.ts +2 -2
  216. package/src/spec/dspark/generate.ts +2 -2
  217. package/src/spec/dspark/loader.ts +1 -1
  218. package/src/spec/dspark/module-dflash.ts +4 -3
  219. package/src/spec/dspark/module.ts +3 -3
  220. package/src/spec/dspark/quantize-drafter.ts +1 -1
  221. package/src/spec/generate.ts +1 -1
  222. package/src/spec/glm52-mtp-source.ts +1 -1
  223. package/src/spec/ngram-source.ts +1 -1
  224. package/src/spec/prefix-state.ts +45 -0
  225. package/src/spec/qwen-mtp-source.ts +166 -69
  226. package/src/spec/serve-loop.ts +154 -156
  227. package/src/spec/source.ts +42 -10
  228. package/src/spec/two-model.ts +1 -1
  229. package/src/ssd-cache.ts +80 -12
  230. package/src/ssd-durability.ts +25 -13
  231. package/src/tool-call.ts +51 -14
  232. package/src/train/flash-cce.ts +2 -2
  233. package/src/train/forward.ts +6 -0
  234. package/src/train/loss.ts +1 -1
  235. package/src/train/prefix-shared.ts +5 -1
  236. package/src/train/segmented.ts +5 -3
  237. package/src/train/trainer.ts +6 -6
  238. package/src/vision/prompt.ts +1 -1
  239. package/src/vision/qwen3vl-tower.ts +52 -10
  240. package/src/web/app.html +1 -1
  241. package/src/web/app.js +5 -3
  242. package/src/web/src/adapters-panel.ts +3 -3
  243. package/src/web/src/assistant.ts +2 -36
  244. package/src/web/src/chat.ts +4 -4
  245. package/src/web/src/composer.ts +4 -4
  246. package/src/web/src/hub.ts +1 -1
  247. package/src/web/src/markdown.ts +0 -0
  248. package/src/web/src/memory-panel.ts +1 -1
  249. package/src/web/src/model-picker.ts +1 -1
  250. package/src/web/src/palette.ts +1 -1
  251. package/src/web/src/protocol.ts +3 -39
  252. package/src/web/src/sessions.ts +1 -1
  253. package/src/web/src/ui-catalog.ts +4 -18
  254. package/src/web-assets.ts +47 -0
  255. package/src/weights.ts +55 -9
  256. package/docs/archive/investigations/curve-runs/bisector-route.json +0 -98
  257. package/docs/archive/investigations/curve-runs/bisector-trace.json +0 -1393
  258. package/docs/archive/investigations/curve-runs/curve-terrain.json +0 -1179
  259. package/docs/archive/investigations/curve-runs/curve-terrain.svg +0 -42
  260. package/docs/archive/investigations/hlg-ab.html +0 -34
  261. package/docs/archive/investigations/hlg-explorer.html +0 -238
  262. package/docs/archive/investigations/hlg-figs/cliff.svg +0 -1
  263. package/docs/archive/investigations/hlg-figs/cross.svg +0 -1
  264. package/docs/archive/investigations/hlg-figs/edges.svg +0 -1
  265. package/docs/archive/investigations/hlg-figs/k20-grids.svg +0 -1
  266. package/docs/archive/investigations/hlg-figs/k20-peaks.svg +0 -1
  267. package/docs/archive/investigations/hlg-figs/lw-curve.svg +0 -1
  268. package/docs/archive/investigations/hlg-figs/terrain.svg +0 -1
  269. package/docs/archive/investigations/hlg-report.html +0 -262
  270. package/docs/archive/investigations/hlg-runs/hlg-ab.json +0 -149
  271. package/docs/archive/investigations/hlg-runs/hlg-blind-prompts.md +0 -87
  272. package/docs/archive/investigations/hlg-runs/hlg-blind.json +0 -107
  273. package/docs/archive/investigations/hlg-runs/hlg-cliff.json +0 -197
  274. package/docs/archive/investigations/hlg-runs/hlg-cross.json +0 -505
  275. package/docs/archive/investigations/hlg-runs/hlg-edge.json +0 -388
  276. package/docs/archive/investigations/hlg-runs/hlg-eval1.json +0 -25
  277. package/docs/archive/investigations/hlg-runs/hlg-frontier.json +0 -86
  278. package/docs/archive/investigations/hlg-runs/hlg-grid.json +0 -242
  279. package/docs/archive/investigations/hlg-runs/hlg-grid2.json +0 -343
  280. package/docs/archive/investigations/hlg-runs/hlg-lw-coarse.json +0 -110
  281. package/docs/archive/investigations/hlg-runs/hlg-map.json +0 -383
  282. package/docs/archive/investigations/hlg-runs/run0-original.log +0 -159
  283. package/docs/archive/investigations/hlg-runs/runA-recommended.log +0 -131
  284. package/docs/archive/investigations/hlg-runs/runAA-cliff-cpm5.log +0 -26
  285. package/docs/archive/investigations/hlg-runs/runAB-grid2-e4b.log +0 -18
  286. package/docs/archive/investigations/hlg-runs/runAC-peak-e4b-partial.log +0 -25
  287. package/docs/archive/investigations/hlg-runs/runAD-ab-samples.log +0 -8
  288. package/docs/archive/investigations/hlg-runs/runAE-edge-e4b.log +0 -51
  289. package/docs/archive/investigations/hlg-runs/runAF-logit-calibration.log +0 -10
  290. package/docs/archive/investigations/hlg-runs/runAG-eval-config2.log +0 -16
  291. package/docs/archive/investigations/hlg-runs/runB-gain-variants.log +0 -192
  292. package/docs/archive/investigations/hlg-runs/runC-pivots.log +0 -162
  293. package/docs/archive/investigations/hlg-runs/runD-oetf.log +0 -132
  294. package/docs/archive/investigations/hlg-runs/runE-eotf.log +0 -162
  295. package/docs/archive/investigations/hlg-runs/runF-shaper-working.log +0 -86
  296. package/docs/archive/investigations/hlg-runs/runG-shaper-sweep.log +0 -101
  297. package/docs/archive/investigations/hlg-runs/runH-wsweep.log +0 -176
  298. package/docs/archive/investigations/hlg-runs/runI-asweep.log +0 -176
  299. package/docs/archive/investigations/hlg-runs/runJ-knobmap.log +0 -49
  300. package/docs/archive/investigations/hlg-runs/runK-frontier.log +0 -19
  301. package/docs/archive/investigations/hlg-runs/runL-widemap.log +0 -65
  302. package/docs/archive/investigations/hlg-runs/runM-frontier-emb.log +0 -15
  303. package/docs/archive/investigations/hlg-runs/runN-verify.log +0 -116
  304. package/docs/archive/investigations/hlg-runs/runO-frontier-seed5000.log +0 -15
  305. package/docs/archive/investigations/hlg-runs/runP-grid-AxSm.log +0 -15
  306. package/docs/archive/investigations/hlg-runs/runQ-grid-tgW.log +0 -15
  307. package/docs/archive/investigations/hlg-runs/runR-blind-transcript.log +0 -79
  308. package/docs/archive/investigations/hlg-runs/runS-cross-coherence.log +0 -33
  309. package/docs/archive/investigations/hlg-runs/runT-cross-cpm5.log +0 -33
  310. package/docs/archive/investigations/hlg-runs/runU-cross-12b.log +0 -33
  311. package/docs/archive/investigations/hlg-runs/runV-lw-e4b.log +0 -22
  312. package/docs/archive/investigations/hlg-runs/runW-lw-12b.log +0 -22
  313. package/docs/archive/investigations/hlg-runs/runX-lw-cpm5.log +0 -22
  314. package/docs/archive/investigations/hlg-runs/runY-cliff-e4b.log +0 -26
  315. package/docs/archive/investigations/hlg-runs/runZ-cliff-12b.log +0 -26
  316. package/docs/archive/mlx-bun-lab-report.html +0 -275
  317. package/docs/archive/wwdc-mlx-bun.html +0 -848
  318. package/docs/design/adapters-end-to-end.md +0 -205
  319. package/docs/design/batching-perf-path.md +0 -255
  320. package/docs/design/batching-v2-plan.md +0 -529
  321. package/docs/design/bucketing-stage.md +0 -197
  322. package/docs/design/compat-cli-surface-design.md +0 -230
  323. package/docs/design/docs-reorg-plan.md +0 -165
  324. package/docs/design/dspark-serving-program.md +0 -371
  325. package/docs/design/dspark-speculative-decoding.md +0 -318
  326. package/docs/design/grammar-spec-batching-integration.md +0 -301
  327. package/docs/design/memory-inference-path.md +0 -422
  328. package/docs/design/memory-synthesis.md +0 -287
  329. package/docs/design/memory-system.md +0 -237
  330. package/docs/design/mlx-lm-serving-execution-seam.md +0 -81
  331. package/docs/design/mlx-lm-tool-parity-plan.md +0 -545
  332. package/docs/design/omlx-adoption-map.md +0 -98
  333. package/docs/design/orpo-dynamic-lambda.md +0 -220
  334. package/docs/design/orpo-future-enhancements.md +0 -340
  335. package/docs/design/paged-kv-cache.md +0 -214
  336. package/docs/design/parallel-slots.md +0 -567
  337. package/docs/design/parity-tier-dag.md +0 -161
  338. package/docs/design/repo-cleanup-plan.md +0 -231
  339. package/docs/design/runtime-isolation.md +0 -263
  340. package/docs/design/segmented-backward-training.md +0 -543
  341. package/docs/design/spec-decode-larger-targets.md +0 -229
  342. package/docs/design/ssd-kv-cold-tier.md +0 -415
  343. package/docs/design/structured-output.md +0 -484
  344. package/docs/design/tauri-desktop-app.md +0 -127
  345. package/docs/design/the-dreaming-master-plan.md +0 -445
  346. package/docs/design/turboquant-kv.md +0 -235
  347. package/docs/design/turboquant-weights.md +0 -467
  348. package/docs/design/w4a16-compute-precision-spike.md +0 -232
  349. package/docs/design/web-chat-beat-matrix.md +0 -315
  350. package/docs/design/write-pipeline-entity.md +0 -468
  351. package/docs/planning/IDEAS.md +0 -69
  352. package/docs/reference/embedding.md +0 -197
  353. package/docs/reference/features-matrix.md +0 -101
  354. package/docs/reference/orpo-quickstart.md +0 -135
  355. package/src/flags.ts +0 -11
  356. /package/docs/{design → archive/investigations}/colibri-glm52-port.md +0 -0
  357. /package/docs/{planning → archive/planning}/chunk-task-roadmap.md +0 -0
  358. /package/docs/{planning → archive/planning}/curve-sampler-research-plan.md +0 -0
  359. /package/docs/{planning → archive/planning}/memory-docs-and-dag-plan.md +0 -0
  360. /package/docs/{planning → archive/planning}/pi-integration-review.md +0 -0
  361. /package/docs/{planning → archive/planning}/release-notes-v0.2.0.md +0 -0
  362. /package/src/{memory-plan.ts → admission-plan.ts} +0 -0
  363. /package/{docs/archive/investigations → src/serve/assets}/curve-terrain.html +0 -0
  364. /package/{docs/dag → src/serve/assets}/training-inference-map.html +0 -0
@@ -1,1246 +1,680 @@
1
- # ORPO training — design
2
-
3
- Plan to add **ORPO** (Odds Ratio Preference Optimization, Hong et al. 2024)
4
- as a third training `method` alongside `sft` and `dpo`. Status: **in production —
5
- flash-CCE head (steel GEMM fwd+bwd) + prefix-sharing + segmented backward, composed
6
- (see the STATUS callout below).**
7
-
8
- ORPO folds the SFT objective and preference learning into a single
9
- **reference-free** loss. Because it needs no reference model, it is a *smaller*
10
- addition than DPO was — it reuses DPO's data format, loader, and batching, and
11
- runs half the forwards per step.
12
-
13
- > **STATUS (2026-06-19) — the ORPO stack is in PRODUCTION.** The flash-CCE head is
14
- > live in both directions (verbatim MLX **steel** GEMM + ORPO epilogue; `[M,vocab]`-free;
15
- > e4b backward **754 ms**, peak **0.93 GB flat @ M=8192**; `orpo_flash_ce`), prefix-sharing
16
- > is trainer-wired (`orpo_prefix_shared`) and **composed with the flash head AND the
17
- > segmented backward for both MiniCPM5 and e4b/Gemma4**, and adapter **warm-start**
18
- > (`RESUME=`) landed — all default-on in [`scripts/train-orpo.ts`](../../scripts/train-orpo.ts).
19
- > The ✅/🔬 entries + optimization log below are the **design/build history**; for the
20
- > current usage, flags, and numbers see [training.md](../reference/training.md) +
21
- > [orpo-quickstart.md](../reference/orpo-quickstart.md), and the live engineering record
22
- > [steel-flash-cce-handoff.md](../archive/investigations/steel-flash-cce-handoff.md).
23
-
24
- > Oracle note: neither `mlx_lm` nor `optiq` ships ORPO, so — unlike DPO
25
- > ([`loss.ts`](../../src/train/loss.ts) ported `optiq/lora/dpo.py`) — ORPO is
26
- > a **no-oracle mlx-bun original (Lab-class)**. Correctness is anchored to a standalone
27
- > torch/numpy reference of the paper/TRL math (see [Parity](#parity)), not a
28
- > bit-exact upstream port.
29
-
30
- > **No-oracle (Lab-gated) verification (2026-06-21).** The flash-CCE head has no oracle either, so
31
- > the "0.28% dh" figure is **fp-reassociation vs the full-logits *proxy*, not an
32
- > error** (two correct reductions in different orders can't be bit-identical).
33
- > Correctness is proven by **finite-difference** (`flash-fd-check.ts`) + the math
34
- > audit — not by matching a reference. The **coeff filter's** synthetic-data cost
35
- > (0.66→2.7%, from `flash-cce-parity.ts`'s random hidden) is its **worst case**:
36
- > random logits give a flat softmax, but real outputs are sharply peaked, so
37
- > filtering the ≈0-softmax tail is near-free — **measure teacher-forced on real
38
- > hiddens before judging** (it's plausibly a free speedup to enable). Standing Lab
39
- > gates (filter-on-real-data, teacher-forced grad fidelity, end-to-end quality)
40
- > + the host-buffer pin-leak post-mortem:
41
- > [orpo-flash-cce-pin-leak.md](../archive/investigations/orpo-flash-cce-pin-leak.md).
42
-
43
- ## Implementation status
44
-
45
- **Landed (naïve correctness path — the shippable unit + the oracle everything
46
- else validates against):**
47
- - ✅ Mixed-precision rank scaling wired (`bitsMapFromModel` / `readPerLayerKl` →
48
- `resolveRanks`); unit-tested ([`tests/train-rank.test.ts`](../../tests/train-rank.test.ts)).
49
- - ✅ ORPO loss: `branchLogpMean` (with the **response-only B=1 forward** — no
50
- full `[1,T,V]` logits, mirroring the SFT path), `log1mexp`, `orpoLogOdds`,
51
- `orpoLossFromLogps`, `orpoLoss`, `orpoMetrics` in
52
- [`loss.ts`](../../src/train/loss.ts). Math validated vs a standalone JS
53
- reference + autograd differentiability
54
- ([`tests/train-orpo.test.ts`](../../tests/train-orpo.test.ts)).
55
- - ✅ `orpoLoop` + dispatch + `orpoLambda`/`orpoWarmupIters`/`orpoLrSchedule`
56
- config; `job.ts` submit surface + `1e-5` LR default; `/finetune` UI method
57
- picker + λ/schedule panel.
58
- - ✅ End-to-end verified on MiniCPM5-1B (gated
59
- [`tests/train-orpo-e2e.test.ts`](../../tests/train-orpo-e2e.test.ts)): loss
60
- improves, preference accuracy reported, adapter saved + mountable, adapted
61
- generations differ.
62
- - ✅ Docs: [training.md](../reference/training.md) updated.
63
-
64
- **Also landed:**
65
- - ✅ Dataset format renamed `"dpo"` → `"preference"` (with `"dpo"` kept as alias in
66
- the type); [`tests/train-dataset.test.ts`](../../tests/train-dataset.test.ts) updated.
67
- - ✅ Parity oracle script
68
- [`scripts/experiments/parity-orpo.ts`](../../scripts/experiments/parity-orpo.ts):
69
- 10/10 checks pass — scalar loss, log1mexp stability, gradient checks (finite
70
- differences vs autograd) at B=1 and B=3, edge cases.
71
- - ✅ `fusedGegluDifferentiable` in `src/model/fused-geglu-kernel.ts`
72
- (**kernel DELETED 2026-07-05** in the Phase-1 deletion pass —
73
- [unified-engine-frontier-plan.md §6](unified-engine-frontier-plan.md);
74
- the compiled activations are the only geglu now, bit-exact, and training
75
- needs no flag sanitization): `CustomVjp`-backed version with hand-derived
76
- backward (grad\_b = dc·gelu(a), grad\_a = dc·b·gelu'(a)); was tested in
77
- `tests/fused-geglu-vjp.test.ts` (deleted with it).
78
- - ✅ **Fused GeGLU wired into the e4b training path.** A training-mode flag
79
- (`setFusedGeluTraining`, set by the trainer around the loop, cleared in its
80
- finally) makes BOTH Gemma GeGLU sites — the MLP (`gelu(gate)·up`) and the
81
- per-layer-input gate (`gelu(gate)·pli`) — use the differentiable wrapper during
82
- training and the plain `fusedGeglu` (byte-for-byte unchanged) at inference.
83
- **This removes the `MLX_BUN_FUSED_GELU=0` requirement for training** (the plain
84
- kernel is a bare `CustomKernel` with no vjp; feeding it to autograd threw
85
- `[Primitive::vjp] Not implemented for CustomKernel`). Bonus: the backward
86
- recomputes the gelu from the primal instead of retaining the ~9 spelled-out
87
- intermediates, lowering the MLP activation memory the backward holds (the
88
- dominant resident at long context under `ops.sdpa`). **Validated** on real e4b
89
- ([`scripts/experiments/fused-geglu-train-parity.ts`](../../scripts/experiments/fused-geglu-train-parity.ts)):
90
- forward loss bit-identical to the spelled-out path (kernel is kl=0), both sites
91
- dispatch (168 = 42 layers × 2 sites × 2 ORPO branches), spelled arm fused-free,
92
- grads match in the kernel's bf16 class (~2%; the forward is exact, the backward
93
- is two bf16 computations of the same gelu derivative — the hand-vjp is validated
94
- to the closed form at tol 0.05 in the vjp test). Composes with the segmented
95
- path (the fused `CustomVjp` nests inside the segmented `mlx_vjp`: segmented ORPO
96
- still PASSES with fused on, peak 11.25 GB full → 7.58 GB segmented). Unit guard
97
- (`tests/fused-geglu-vjp.test.ts`, deleted with the kernel): the
98
- flag toggles and the differentiable forward bit-matches the plain kernel.
99
- (Historical: training at the time also needed `MLX_BUN_PERF_KERNEL=0` — that
100
- perf kernel was likewise deleted 2026-07-05, so no training flag sanitization
101
- remains.)
102
- - ✅ `responseOnlyLogpMean` helper in `loss.ts` (post-finalNorm h → mean response
103
- logp; used by the ORPO head VJP).
104
- - ✅ `SegmentedBackwardOrpo` in
105
- [`src/train/segmented.ts`](../../src/train/segmented.ts): MiniCPM5 segmented
106
- backward for ORPO — runs chosen+rejected forwards segment-by-segment, two-input
107
- head VJP (`new Vjp(fn, 1)` with `apply([hC, hR], [one])` → vjps[0]=dh\_c,
108
- vjps[1]=dh\_r), backward passes sum LoRA grads from both branches. Wired into
109
- `orpoLoop` behind the existing `segmentSize > 0` gate.
110
- - ✅ **Token-chunked LM head (the large-vocab moat):** `chunkedLogpMeanB1` +
111
- `ChunkCtx` in [`loss.ts`](../../src/train/loss.ts), gated by `orpoChunkSize`
112
- (`TrainConfig` / `orpo_chunk_size`). Each token-chunk's head is wrapped in a
113
- `Checkpoint` so its `[chunk, vocab]` logits are recomputed in the backward
114
- instead of all `[M, vocab]` retained — bounding the dominant large-vocab term
115
- to `[chunkSize, vocab]`. The per-chunk checkpoints, plus the hidden/slices they
116
- read (a Checkpoint *drops* its input activations, so freeing them early
117
- corrupts the result — found and fixed during bring-up), live in a `sink`
118
- disposed by `orpoLoop` via `accumulateStep`'s `afterMicroEval` hook (after the
119
- grads are eval'd). Verified gated
120
- ([`tests/train-orpo-chunked.test.ts`](../../tests/train-orpo-chunked.test.ts)):
121
- forward parity vs non-chunked within **bf16 tolerance** (the only divergence is
122
- occasional single-ULP head-matmul rounding that tiles differently for `[M,V]`
123
- vs `[chunk,V]` — sub-bf16, the established class), **exact** for a single
124
- chunk, and the chunked backward trains + improves loss. This is **token-axis**
125
- chunking — the high-impact win for our case (`M` up to thousands × 262k vocab).
126
- **Vocab-axis** chunking + online-LSE (CCE-style) is unnecessary at 262k
127
- (`[1, 262k]` bf16 ≈ 0.5 MB) and remains a future tier.
128
-
129
- - ✅ **Fused linear-CE head (Liger `FusedLinearCrossEntropy` ported to MLX)** —
130
- `fusedLogpMeanB1` (`loss.ts`), gated by `orpo_fused_ce`. A single `CustomVjp`
131
- with an ANALYTIC `softmax−onehot` backward (no autograd through logsumexp+gather,
132
- no retained `[M,V]` logits in either direction); `dh` via mlx's own
133
- quantized-matmul x-vjp (`transpose=false`); Gemma softcap matched bit-exactly to
134
- `logitSoftcap`. Value BIT-EXACT vs the full-logits head on MiniCPM5 **and** e4b;
135
- grads 0.51%/1.97% bf16-class; peak `[chunk,V]`-bounded. See
136
- [lever 1](#ranked-levers-with-our-setup-verdicts) for the full write-up. This is
137
- the deeper engine the token-chunked Checkpoint head approximated.
138
-
139
- - ✅ **Benchmark + parity harness** (the perf-tracking discipline):
140
- [`scripts/bench-orpo.ts`](../../scripts/bench-orpo.ts) (ms/step + peak GB
141
- across head/segment configs on synthetic long-response data) and
142
- [`scripts/bench-attn-backward.ts`](../../scripts/bench-attn-backward.ts)
143
- (attention-backward dQ/dK/dV parity vs a materialized reference + timing).
144
- MiniCPM5-1B @ SEQ=1024: baseline 3191 ms / 9.34 GB · chunked-head(256) **2758
145
- ms / 8.62 GB** (faster + lower) · segmented(4) 3633 ms / **2.45 GB** (the
146
- dominant memory lever — layer activations, not the head, dominate at this
147
- scale/vocab; the head win grows with vocab, e.g. Gemma's 262k).
148
- - ✅ **SDPA backward — flash kernel FIXED to L2 parity (kept, not deleted).**
149
- Parity oracle, made explicit: the default `ops.sdpa` training path is **L1**
150
- (mlx-lm's tuner differentiates the same `mx.fast.sdpa`); the flash kernel is
151
- **L2** — a port of mlx-optiq's `flash_attention_metal`, so **optiq is its
152
- oracle**. Confirmed end to end: optiq's flash dK == `ops.sdpa` to f16
153
- (`scripts/flash-optiq-check.py`, rel **0.0%**), and mlx-bun's fixed flash ==
154
- `ops.sdpa` (`tests/flash-attention.test.ts`, ≤f16) ⟹ **mlx-bun flash == optiq
155
- (L2 parity)**; `ops.sdpa` itself is finite-difference verified. The original
156
- kernel diverged ~100% from optiq on dK — a genuine **port bug**: a spurious dK
157
- transpose in `flashBackward` (corrupted dK for Tkv≠D) + a per-thread
158
- (divergent) `threadgroup_barrier` in the dQ causal tile-skip; both fixed. The
159
- real `ops.sdpa`-vs-flash tradeoff is **memory**: `ops.sdpa` backward is
160
- **O(L²)** (0.02→0.33→1.40 GB at T=512/2048/4096), flash is **O(L)**
161
- (0.03→0.23→0.90 GB) but ~30× slower → flash is the opt-in long-context path.
162
- **Lessons:** (1) state parity *to a named oracle* (L1 mlx-lm / L2 mlx-optiq),
163
- not "correct"; (2) "fix, don't delete" — the kernel had a localized, fixable
164
- port bug + a real memory purpose; (3) a parity reference is trustworthy only if
165
- read **contiguously** (`ops.contiguous` before `toFloat32`) with a
166
- **non-uniform cotangent** — both pitfalls produced false "kernel is wrong"
167
- signals; finite differences settled it.
168
- - ✅ **Regularization knobs (the small-set overfitting toolkit):** **LoRA-input
169
- dropout** (`lora_dropout`) — recompute-safe (mask keyed by step+layer so the
170
- segmented/checkpoint backward reproduces it; verified by a finite+improving
171
- loss through the segmented recompute path); **rsLoRA** (`rs_lora`, α/√rank,
172
- wired train→save→load so inference applies the same per-layer scale); **LoRA+**
173
- (`lora_plus_ratio`, per-param LR in AdamW — B leaves get the higher LR).
174
- Unit-tested ([`tests/train-regularization.test.ts`](../../tests/train-regularization.test.ts))
175
- + gated e2e with all three on through segmented recompute
176
- ([`tests/train-regularization-e2e.test.ts`](../../tests/train-regularization-e2e.test.ts)).
177
- The dangling `/finetune` dropout field is now wired (`lora_dropout`).
178
- - ✅ **Segmented backward for ORPO on e4b** (`SegmentedBackwardOrpoGemma4`,
179
- [`src/train/segmented.ts`](../../src/train/segmented.ts)): the cross of
180
- `SegmentedBackwardGemma4` (per-layer-input boundary + cross-segment KV-shared
181
- donor cotangent threading) and `SegmentedBackwardOrpo` (two branches +
182
- reference-free ORPO head). Each branch streams forward saving detached
183
- hidden + reused-donor K/V boundaries + its own masks/per-layer constant; the
184
- ORPO head vjp over `[last_chosen, last_rejected]` yields `[dh_c, dh_r]`; each
185
- branch walks backward in reverse over its segments (donor cotangents
186
- accumulated in a per-branch `dKV`), and the two branches' LoRA grads are
187
- summed. Wired into `orpoLoop` (`segment_size > 0`, B=1) alongside the MiniCPM5
188
- path. **Validated** ([`scripts/experiments/segmented-grad-test-orpo-e4b.ts`](../../scripts/experiments/segmented-grad-test-orpo-e4b.ts),
189
- grad-parity vs the monolithic ORPO `value_and_grad` on real e4b, 2026-06-18):
190
- loss matches to ~6 decimals; at **λ=0 the path is byte-identical** to the SFT
191
- `SegmentedBackwardGemma4` (relNorm 1.1341%, maxAbs 3.79e-3 — same numbers,
192
- proving zero added error); the donor-KV recipients (layers 22/23 k/v_proj,
193
- the direct dKV recipients) stay clean (<0.5%), proving the cross-segment donor
194
- threading; overall match sits in the e4b segmented bf16-class (~1.1% flash /
195
- ~2.0–2.3% sdpa, the same level the SFT path shows here). **Peak**: full
196
- two-branch ORPO backward **12.67 GB → segmented 7.66 GB** at L=256 (saved
197
- 5 GB — a bigger win than SFT's 2.4 GB, since the two-branch full backward
198
- holds both branches at once); 8.71 GB at L=1024 where the full backward
199
- won't fit.
200
- - ✅ **Shared prompt-prefix reference (lever 7, MiniCPM5)** in
201
- [`src/train/prefix-shared.ts`](../../src/train/prefix-shared.ts): one forward
202
- over `[prompt; chosen; rejected]` with a block-sparse mask + block-wise RoPE.
203
- Validated 2026-06-18 — **forward BIT-EXACT** vs the two-forward path, grads in
204
- the bf16 class (~1.05%), **1.78× fewer token-passes**, peak 5.40 → 3.80 GB. See
205
- [lever 7](#ranked-levers-with-our-setup-verdicts) for the full write-up.
206
- **LANDED (2026-06-19):** wired into the trainer (`orpo_prefix_shared`), composed
207
- with the flash-CCE head per branch AND with the segmented backward
208
- (`SegmentedBackwardOrpoPrefix`); the e4b port is composed too (next entry).
209
- - ✅ **e4b prefix-shared port — LANDED + composed with the segmented backward
210
- (`SegmentedBackwardOrpoPrefixGemma4`, 2026-06-19); the length-sensitivity below is an
211
- accepted numerical caveat for a fine-tune, not a blocker** (seg-vs-non-seg-same-prefix
212
- grads match to 1.7–2.3%, loss bit-exact-class). Ported to Gemma e4b
213
- (`setGemmaPrefixPlan` + block-wise RoPE in [`gemma4.ts`](../../src/model/gemma4.ts)
214
- `Attention.ropeBlocks`; `blockSparsePrefixMaskGemma` with a LOGICAL-position
215
- sliding-window cut + `Gemma4PrefixSharedCache` + `orpoLossPrefixSharedGemma` in
216
- [`prefix-shared.ts`](../../src/train/prefix-shared.ts), reusing
217
- `forwardHidden` for per-layer-input + donor-KV). **The construction is proven
218
- correct**: the prefix-shared forward is BIT-EXACT to a plain forward over the
219
- same concatenated sequence (`scripts/experiments/prefix-shared-e4b-localize.ts`:
220
- block-wise-RoPE+block-mask path `(c)` == plain-concat path `(b)` to 0.00% at
221
- every chosen position; per-layer-inputs bit-exact across lengths). **But it does
222
- NOT numerically reproduce the two-forward path on e4b**: prefix-shared forward
223
- loss diverges ~0.08% and the chosen/rejected hiddens up to ~3-14% from the two
224
- separate `[prompt;resp]` forwards — because **the e4b full-forward is itself
225
- length-sensitive**. Demonstrated model-only, independent of prefix-sharing:
226
- `forward([prompt])` vs `forward([prompt;extra])` differ **1.7% at position 0**
227
- (one attention key — softmax=1.0, cannot reassociate) up to **~14%** at later
228
- prompt positions, even though those positions are causally independent of the
229
- appended tokens, and even through the **production `TrainingCache`** path (fused
230
- causal). Root cause = e4b's **scale=1.0 attention** (headDim 256, q/k normed →
231
- q·k ~N(0,256) → sharply peaked softmax) amplifying sub-bf16 matmul-tiling
232
- roundings that vary with the row count — the SAME amplification class documented
233
- for the e4b SigLIP vision tower ([[siglip-vision-parity-cross-build]]). MiniCPM5
234
- (standard `1/√d` scale) shows 0.00% length-sensitivity, which is why its
235
- prefix-shared path is bit-exact to two-forward. **Consequence:** on e4b,
236
- prefix-sharing is a *self-consistent* computation (correct gradients of a valid
237
- single-concat loss; 1.78× fewer prompt-token passes, peak proportionally lower)
238
- but is NOT a drop-in numerical equivalent of two-forward training the way it is
239
- on MiniCPM5. **Decision pending** (Josh): land it framed via the
240
- single-concat-forward oracle / defer / investigate the length-sensitivity
241
- further. Code is committed-ready; the parity script's two-forward comparison is
242
- expected to show the e4b-class divergence, not bit-exactness.
243
-
244
- **Follow-on (each validated against the naïve path via the
245
- [oracle ladder](#parity)):** **optimize** the (now-correct) flash kernel — it's
246
- ~30× slower than `ops.sdpa` because it's a naive scalar Metal kernel (no
247
- `simdgroup_matrix`, 32-thread threadgroups → ~0.5% of GPU peak), whereas
248
- `ops.sdpa` is MLX's hand-tuned simdgroup-matrix flash. Its O(L) memory only pays
249
- off when `ops.sdpa`'s O(L²) won't fit (≳16K context). **Note (future research
250
- spike):** MLX is open source — we can read exactly how
251
- `mx.fast.scaled_dot_product_attention` is implemented (simdgroup-matrix tiling),
252
- match it, and then potentially **beat it for OUR specific use case** (fixed
253
- head-dims, training-only backward, our seq-len/precision regime, the O(L)
254
- memory constraint they don't optimize for). Not now — `ops.sdpa` is the fast
255
- default and the current training (`ft-e4b-v2.sh`, no `MLX_BUN_TRAIN_ATTN`) uses
256
- it for both forward and backward; the flash kernel is the dormant opt-in
257
- fallback. Then: fused GeGLU wired into model training path,
258
- intra-layer MLP split, shared prompt-prefix.
259
- **End goal:** a before/after ORPO quality experiment on Gemma e4b or Qwen-4B
260
- (curated preference set + a benchmark eval).
261
-
262
- ## The objective
1
+ ---
2
+ status: landed
3
+ axis: USING
4
+ canonical-for: training
5
+ plan-anchor: "Phase: Steel flash-CCE ORPO head + full ORPO training stack"
6
+ last-verified: 2026-08-23
7
+ ---
8
+
9
+ # Training — design (ORPO, flash-CCE head, prefix sharing, segmented backward)
10
+
11
+ The canonical design record for mlx-bun's LoRA trainer: the **ORPO** objective
12
+ (Hong et al. 2024) as a third `method` beside `sft`/`dpo`, and the long-context
13
+ machinery it runs on — the `[M,V]`-free **flash-CCE** head, the **prefix-shared**
14
+ two-branch forward, and the **segmented backward**. All of it is landed and
15
+ default-on in `scripts/train-orpo.ts` and `mlx-bun train`.
16
+
17
+ - Usage, flags, measured run tables: [training.md](../reference/training.md),
18
+ [docs/reference/training.md](../reference/training.md).
19
+ - Build narratives (frozen): [steel-flash-cce-handoff.md](../archive/investigations/steel-flash-cce-handoff.md),
20
+ [segmented-backward-handoff.md](../archive/investigations/segmented-backward-handoff.md),
21
+ [orpo-flash-cce-pin-leak.md](../archive/investigations/orpo-flash-cce-pin-leak.md),
22
+ [orpo-base-uf-experiment-and-directions.md](../archive/investigations/orpo-base-uf-experiment-and-directions.md),
23
+ [orpo-uf-testing-handoff.md](../archive/investigations/orpo-uf-testing-handoff.md).
24
+ - Open training work is tracked in PLAN.md under the plan-anchor above; status
25
+ prose lives there, not here.
26
+
27
+ Code: [`src/train/loss.ts`](../../src/train/loss.ts) (objective + heads),
28
+ [`trainer.ts`](../../src/train/trainer.ts) (loops, config), [`segmented.ts`](../../src/train/segmented.ts),
29
+ [`prefix-shared.ts`](../../src/train/prefix-shared.ts), [`flash-cce.ts`](../../src/train/flash-cce.ts)
30
+ (+ `steel-qmm-header.ts`), [`job.ts`](../../src/train/job.ts) (submit surface),
31
+ [`lora-params.ts`](../../src/train/lora-params.ts), [`rank.ts`](../../src/train/rank.ts).
32
+ Entry points: [`scripts/train-orpo.ts`](../../scripts/train-orpo.ts) (env launcher),
33
+ `mlx-bun train` (`src/cli.ts`), the `/finetune` job runner. SFT examples:
34
+ `scripts/examples/chunk-finetune.ts`, `scripts/examples/ft-e4b-v2.sh`,
35
+ `scripts/examples/chunk-eval.ts`.
36
+
37
+ ## 1. The objective
263
38
 
264
39
  $$\mathcal{L}_{ORPO} = \mathcal{L}_{NLL}(y_w) + \lambda \cdot \mathcal{L}_{OR}$$
265
40
 
266
- - $\mathcal{L}_{NLL}(y_w)$ — the SFT cross-entropy on the **chosen** sequence.
267
- Scope is selected by **`sft_scope`** (TrainConfig `sftScope`, default
268
- **`full`**):
269
- - **`full`** (default; paper/TRL-faithful): the token-mean cross-entropy over
270
- **all non-pad supervised positions — prompt AND response** (label-shifted;
271
- `nn.CrossEntropyLoss reduction="mean"`), exactly TRL ORPOTrainer's
272
- `chosen_nll_loss` (labels = input ids with only padding masked to −100) and
273
- xfactlab/orpo's `outputs_pos.loss`. Computed from the **same chosen
274
- forward** (no extra forward): the head additionally runs over the prompt
275
- span and the two span means combine as
276
- $\text{NLL}_{full} = -\frac{(P{-}1)\,\text{pm} + M\,\ell_w}{(P{-}1)+M}$
277
- (`combineFullNll`, `src/train/loss.ts`).
278
- - **`response`**: $-\ell_w$, the negative mean response log-prob — the
279
- pre-2026-07 mlx-bun behavior (the house convention of masking the prompt),
280
- kept **bit-exact** for reproducing older runs.
281
-
282
- > **Resolved (2026-07-01).** The review found the then-hardcoded
283
- > response-only scope diverged from the reference implementations (TRL /
284
- > xfactlab/orpo supervise the full prompt+response, only padding masked).
285
- > Decision (Josh): `sft_scope: full | response`, **default `full`** — loss
286
- > curves are apples-to-apples with TRL/paper ORPO by default; `response`
287
- > reproduces old runs. The odds-ratio $\ell_w/\ell_r$ terms stay
288
- > **response-only length-normalized means in BOTH modes** — that also matches
289
- > TRL (its `get_batch_logps` uses prompt-masked labels for the odds ratio;
290
- > only the NLL term is full-sequence). Implemented across every ORPO path
291
- > (naive / chunked / fused / flash-CCE / prefix-shared / all four segmented
292
- > classes): the span-parameterized heads run once over the prompt span and
293
- > once over the response span (disjoint — no token headed twice), keeping
294
- > $\ell_w$ bit-identical to the `response` path. Cross-path full-scope parity
295
- > + an independent oracle (full NLL ≙ `sftLoss` with `promptLen=1`, bf16
296
- > class) are wired in `tests/train-orpo-fused-ce.test.ts`; loss-math units +
297
- > exact response-scope regression pins in `tests/train-orpo.test.ts`.
298
- - $\mathcal{L}_{OR} = -\log \sigma(\text{log\_odds})$, the odds-ratio term:
299
-
300
- $$\text{log\_odds} = (\ell_w - \ell_r) - \big(\text{log1mexp}(\ell_w) - \text{log1mexp}(\ell_r)\big)$$
301
-
302
- where $\text{log1mexp}(x) = \log(1 - e^{x})$, and $\ell_w, \ell_r$ are the
303
- **length-normalized** (mean over response tokens) log-probabilities of the
304
- chosen / rejected responses. $\lambda$ weights the preference term.
305
-
306
- ### Two facts that shape the implementation
307
-
308
- 1. **Length normalization (mean, not sum).** This is the one substantive
309
- difference from our DPO code, whose `seqLogp` returns the per-row *sum* of
310
- response log-probs. ORPO requires the *mean*: $\ell = (\sum_t \log p_t) /
311
- (\#\text{response tokens})$. Faithful to the paper and TRL; summing instead
312
- would bias toward shorter responses.
313
-
314
- 2. **The chosen forward does double duty.** Since
315
- $\mathcal{L}_{NLL}(y_w) = -\ell_w$, the same $\ell_w$ feeds both the SFT term
316
- and the odds ratio. So ORPO is **2 forwards/step** (chosen + rejected),
317
- versus DPO's **4** (2 reference at scale 0 + 2 policy). No reference model,
318
- no `setLoraScale(0)` dance, no detached reference tensors — lighter on
319
- compute and memory than DPO.
320
-
321
- ## How it slots into the current trainer
322
-
323
- Training is already method-dispatched, so ORPO is additive:
324
-
325
- - [`trainLora`](../../src/train/trainer.ts) dispatches on `cfg.method`
326
- (`dpo` → `dpoLoop`, else `sftLoop`). Add an `orpo` branch → `orpoLoop`.
327
- - All loops share the same scaffold: `resolveRanks` → `buildTrainableLora` →
328
- `AdamW` → a `ValueAndGrad` closure that swaps LoRA A/B primals so only those
329
- leaves differentiate. ORPO reuses it unchanged.
330
- - Data: ORPO consumes the existing **preference** format
331
- `{prompt, chosen, rejected}` (today labeled `dpo`), via `loadDpoDataset` /
332
- `iterateDpoBatches` / `DpoBatch`. No new dataset code.
333
-
334
- ## File-by-file changes
335
-
336
- ### `src/train/loss.ts` (the only real new logic)
337
-
338
- - **`branchLogpMean(model, ids, mask, validLen)` → `[B]`** — like `branchLogp`
339
- but returns the **mean** response log-prob per row (sum / mask-count).
340
- - B=1 uses a **response-only forward** (mirrors `responseOnlyCe`):
341
- `trainForwardHidden` → `sliceDynamic` the response span
342
- `[promptLen-1, len-1)` → `logitsFromHidden` on just that slice → gather the
343
- target log-probs → divide by `M` response tokens. This avoids
344
- materializing the `[1, T, V]` logits (and its grad) — the dominant
345
- long-context memory term — exactly as the SFT path already does.
346
- - B>1 falls back to full-logits `branchLogp` + per-row mean (DPO/ORPO default
347
- `batch_size` is 1, so this is the rare path).
348
- - **`log1mexp(x)`** — stable `log(1 - exp(x))`. We have
349
- `exp/sub/log/clip/softplus/where/less` but **no `expm1`/`log1p`**. With
350
- length-normalized average log-probs, `x` is comfortably negative (per-token
351
- mean log-prob is well below 0), so `log(clip(1 - exp(x), eps, 1))` with a
352
- small `eps` is numerically safe; comment the near-zero edge case.
353
- - **`orpoLoss(model, batch, lambda)` → scalar** —
354
- `ℓw = branchLogpMean(chosen)`, `ℓr = branchLogpMean(rejected)`,
355
- `nll = mean(-ℓw)`, `logOdds = (ℓw-ℓr) - (log1mexp(ℓw)-log1mexp(ℓr))`,
356
- `or = mean(softplus(-logOdds))`, return `nll + lambda*or`. Caller owns.
357
- - **`orpoMetrics(model, lora, batch, lambda)`** (no grad) — loss with its
358
- `nll` / `or` split, preference accuracy (`ℓw > ℓr`), and reward margin. No
359
- reference forward.
360
-
361
- ### `src/train/trainer.ts`
362
-
363
- - `TrainConfig`: add `orpoLambda: number`; extend `method` to
364
- `"sft" | "dpo" | "orpo"`.
365
- - `DEFAULT_TRAIN_CONFIG`: `orpoLambda: 0.1` (TRL default; paper uses 0.1–1.0).
366
- - `trainLora`: dispatch `method === "orpo"` → `orpoLoop`.
367
- - `orpoLoop` — a `dpoLoop` clone **minus** `dpoRefLogps` and the `refChosen`/
368
- `refRejected` tensors. The `ValueAndGrad` closure calls `orpoLoss` directly.
369
- Reuse `warmupCosineSchedule`, driven by **`orpo`-prefixed** config knobs (see
370
- below). Emit the same train/val metrics, plus the nll/or split from
371
- `orpoMetrics`.
372
-
373
- **Method-prefixed params (decided).** Schedule/strength knobs are namespaced
374
- per method so each set reads naturally for its method — `dpo_*` for DPO,
375
- `orpo_*` for ORPO. Add to `TrainConfig`: `orpoLambda`, `orpoWarmupIters`,
376
- `orpoLrSchedule` (mirroring the `dpo*` trio). `orpoLoop` reads the `orpo*`
377
- knobs; `dpoLoop` keeps reading `dpo*`. No shared/renamed fields.
378
-
379
- ### `src/train/job.ts`
380
-
381
- - `FinetuneSubmit.method`: add `"orpo"`; add `orpo_lambda?: number`.
382
- - `parseConfig`: pass `orpoLambda` / `orpoWarmupIters` / `orpoLrSchedule`; LR
383
- default branch `method === "orpo" ? 1e-5 : ...` (see Recommended defaults —
384
- **not** DPO's `5e-5`).
385
-
386
- ### `src/train/dataset.ts`
387
-
388
- - Minimal. ORPO loads via `loadDpoDataset`. Accept `probeFormat` returning the
389
- preference shape for `method: "orpo"`. Optionally introduce a user-facing
390
- `"preference"` label with `dpo` kept as an alias, so the format name is not
391
- method-specific.
392
-
393
- ### Surface
394
-
395
- - [`docs/reference/training.md`](../reference/training.md): add ORPO to the
396
- "SFT vs DPO" section (→ "SFT / DPO / ORPO"), the data-format table (the
397
- preference format serves both), and the config table (`orpo_lambda`).
398
- - `/finetune` web UI: add `orpo` to the method picker; show λ when selected.
399
-
400
- ## Recommended defaults
401
-
402
- From a hyperparameter spike on ORPO-for-LoRA-on-small-models (paper, TRL
403
- `ORPOConfig`, and Labonne's Llama-3 ORPO-LoRA recipe). The one trap: the
404
- paper's `8e-6` LR is for **full** fine-tuning — do not inherit it for LoRA, and
405
- do not inherit DPO-LoRA's higher `5e-5` either, because ORPO's loss carries a
406
- full SFT NLL term that a high LR destabilizes.
41
+ - $\mathcal{L}_{NLL}(y_w)$ — cross-entropy on the **chosen** sequence. Scope is
42
+ `sft_scope` (`TrainConfig.sftScope`, default **`full`**):
43
+ - `full` (paper/TRL-faithful): token-mean CE over all non-pad positions,
44
+ prompt AND response, from the **same chosen forward** — the head runs once
45
+ over the prompt span and once over the response span (disjoint), combined
46
+ as $\text{NLL}_{full} = -\frac{(P{-}1)\,\text{pm} + M\,\ell_w}{(P{-}1)+M}$
47
+ (`combineFullNll`). Matches TRL `ORPOTrainer.chosen_nll_loss` and
48
+ xfactlab/orpo `outputs_pos.loss`.
49
+ - `response`: $-\ell_w$, the pre-2026-07 house convention (prompt masked),
50
+ kept bit-exact for reproducing older runs.
51
+ - $\mathcal{L}_{OR} = -\log\sigma(\text{log\_odds})$ with
52
+ $\text{log\_odds} = (\ell_w - \ell_r) - (\text{log1mexp}(\ell_w) - \text{log1mexp}(\ell_r))$,
53
+ $\text{log1mexp}(x) = \log(1-e^x)$. $\ell_w,\ell_r$ are **length-normalized
54
+ means** over response tokens in BOTH scope modes (also TRL's convention —
55
+ its `get_batch_logps` masks the prompt for the odds ratio; only the NLL term
56
+ is full-sequence). $\lambda$ weights only $\mathcal{L}_{OR}$; the NLL term is
57
+ unweighted.
58
+
59
+ Implementation (`loss.ts`): `log1mexp` (stable `log(clip(1 − exp(x), eps, 1))`
60
+ — mlx-bun has no `expm1`/`log1p`; per-token mean logp is comfortably negative
61
+ so the clip is safe), `orpoLogOdds`, `orpoLossFromLogps(lw, lr, λ, nllFull?)`
62
+ (model-free, unit-testable against a hand reference), `orpoLoss(model, batch,
63
+ λ, chunk?, sftScope)` (runs the two forwards), `orpoMetrics` (no-grad
64
+ `loss`/`nll`/`or`/`accuracy`/`margin`).
65
+
66
+ Two facts that shape everything downstream:
67
+
68
+ 1. **Mean, not sum.** The one substantive difference from DPO's `seqLogp`
69
+ (per-row sum). Summing biases toward short responses and makes the OR
70
+ gradient length-dependent.
71
+ 2. **The chosen forward does double duty.** ORPO is **2 forwards/step**
72
+ (chosen + rejected, both with grad) vs DPO's 4 (2 reference + 2 policy). No
73
+ reference model, no `setLoraScale(0)` dance, no detached reference tensors.
74
+ Never forward chosen twice; never compute a rejected NLL.
75
+
76
+ Two unpadded sequential B=1 forwards are the *right* shape, not a limitation:
77
+ TRL's padded `concatenated_forward` exists to amortize FSDP all-gathers we
78
+ don't have; on unified memory the padding is pure waste.
79
+
80
+ ## 2. Oracles and the parity ladder
81
+
82
+ Neither `mlx_lm` nor `optiq` ships ORPO, so — unlike DPO (`loss.ts` ported
83
+ `optiq/lora/dpo.py`) — ORPO is a **no-oracle mlx-bun original (Lab-class)**.
84
+ The current MLX SOTA (`mlx-lm-lora`) ships a non-canonical ORPO
85
+ (`log_sigmoid(chosen − rejected)`, no SFT-NLL term); do not copy it.
86
+
87
+ What IS oracle-backed, and where each tier bottoms out:
88
+
89
+ | Tier | Path | Oracle / gate |
90
+ |---|---|---|
91
+ | L1 | Non-segmented SFT `value_and_grad` through `ops.sdpa` | **Bit-exact vs `mlx_lm.lora`** — loss on 4/6 mask spans, 0.5 f32-ulp on 2; `dB` over all 42 e4b q_proj layers `0.000e+0` (2026-06-16). Response-only CE == full-masked CE on every span. |
92
+ | L2 | `MLX_BUN_TRAIN_ATTN=flash` (port of optiq's `flash_attention_metal`) | optiq flash dK == `ops.sdpa` to f16 (`scripts/oracle/flash-optiq-check.py`); ours == `ops.sdpa` (`tests/flash-attention.test.ts`). |
93
+ | Lab | ORPO loss math | Standalone JS/numpy reference + finite differences (`tests/train-orpo.test.ts`; the deleted `parity-orpo.ts` passed 10/10 incl. FD grads at B=1/B=3). |
94
+ | Lab | Each memory tier (chunked → fused → flash head; segmented; prefix) | Validated against the tier below: value bit-exact or bf16-class, grads bf16-class, bottoming out at the naive full-logits two-forward path. |
95
+
96
+ Rules learned the hard way:
97
+
98
+ - A parity reference is trustworthy only if read **contiguously**
99
+ (`ops.contiguous` before `toFloat32`) with a **non-uniform cotangent** — both
100
+ pitfalls produced false "kernel is wrong" signals; finite differences settled it.
101
+ - "0.28% dh" for the flash-CCE head is fp-reassociation vs a full-logits
102
+ *proxy*, not error (two correct reductions in different orders aren't
103
+ bit-identical). Correctness is FD + the math audit; fidelity is
104
+ **teacher-forced on real hiddens**, not synthetic random logits (whose flat
105
+ softmax is the coeff filter's worst case). Standing regression:
106
+ `tests/train-orpo-fused-ce.test.ts` "teacher-forced fidelity" (cos > 0.999,
107
+ relnorm < 5% vs a `Vjp` full-logits reference).
108
+ - Validate every custom `.vjp` against `mx.vjp`/autograd, never a hand formula.
109
+
110
+ ## 3. Trainer architecture
111
+
112
+ `trainLora` dispatches on `cfg.method`: `sftLoop` / `dpoLoop` / `orpoLoop`. All
113
+ share `resolveRanks` → `buildTrainableLora` → `AdamW` → a closure that swaps the
114
+ LoRA A/B primals so only those leaves differentiate. ORPO consumes the
115
+ **preference** dataset format `{prompt, chosen, rejected}` (`"dpo"` kept as an
116
+ alias) via `loadDpoDataset` / `iterateDpoBatches`.
117
+
118
+ `orpoLoop` path selection (verified in `trainer.ts`):
119
+
120
+ - `segmentSize > 0` → segmented classes (MiniCPM5 or Gemma4 only; else throws).
121
+ With `orpoPrefixShared` the composed `SegmentedBackwardOrpoPrefix[Gemma4]` is
122
+ built alongside a plain `SegmentedBackwardOrpo[Gemma4]` for per-row fallback.
123
+ The segmented head is `boundedHeadFromHidden` (Checkpoint-chunked or fused)
124
+ when `orpoFusedCe || orpoFlashCe`, else full `responseOnlyLogpMean`.
125
+ - Otherwise a `ValueAndGrad` over `orpoLossPrefixShared[Gemma]` (when the row's
126
+ prompts are byte-identical; `splitPrefixBatch`) or `orpoLoss`, with a
127
+ `ChunkCtx` selecting the head: `{fused, flash}` (chunk = `orpoChunkSize` or
128
+ 512) / Checkpoint token-chunked (`orpoChunkSize > 0`) / full logits.
129
+ - Every micro-step goes through `accumulateStep(gradAccumSteps, …)`; the
130
+ per-chunk Checkpoint sink is disposed in its `afterMicroEval` hook.
131
+ - Dropout: `model.loraState.dropoutSeed` is set once per micro-step so the
132
+ segmented/checkpoint recompute replays the same mask; cleared before
133
+ metrics/val.
134
+ - `MLX_BUN_TRAIN_ATTN=flash` is **refused for Gemma models** (e4b SIGTRAPed at
135
+ ≥2K and has not been re-validated since the kernel fixes); MiniCPM5 allowed.
136
+
137
+ ### Config surface (TrainConfig ↔ job submit ↔ CLI/launcher)
138
+
139
+ | TrainConfig | `FinetuneSubmit` | `mlx-bun train` / `train-orpo.ts` env | Default | Notes |
140
+ |---|---|---|---|---|
141
+ | `orpoLambda` | `orpo_lambda` | `LAMBDA` | 0.1 | weights only L_OR |
142
+ | `orpoWarmupIters` / `orpoLrSchedule` | `orpo_warmup_iters` / `orpo_lr_schedule` | — | 0 / `cosine` | launcher sets warmup = min(10, iters/10) |
143
+ | `orpoChunkSize` | `orpo_chunk_size` | — | 0 (job: 512 for orpo) | token-chunk for all head variants |
144
+ | `orpoFusedCe` | `orpo_fused_ce` | `FLASH=0` selects it | false | analytic MLX fused head |
145
+ | `orpoFlashCe` | `orpo_flash_ce` | `FLASH` (default on) | false (job: true for orpo) | implies fused |
146
+ | `orpoPrefixShared` | `orpo_prefix_shared` | `PREFIX` (default on) | false (job: true for orpo) | MiniCPM5 + Gemma4, B=1 |
147
+ | `sftScope` | `sft_scope` | `SFT_SCOPE` | `full` | |
148
+ | `segmentSize` | `segment_size` | `SEG` / `SEGOFF=1`; `--seg` / `--no-segment` | 0 (job/CLI: 2 for orpo) | |
149
+ | `gradAccumSteps` | `grad_accumulation_steps` | `--grad-accum` (CLI only; no env knob in `train-orpo.ts`) | 1 | |
150
+ | `loraDropout` / `rsLora` / `loraPlusRatio` | `lora_dropout` / `rs_lora` / `lora_plus_ratio` | — | 0 / false / 1 | |
151
+ | `warmStartAdapter` | `warm_start_adapter` | `RESUME` / `--resume` | "" | weights only; optimizer + schedule restart |
152
+ | `gradCheckpoint` + `mlpSplit` | `grad_checkpoint` / `mlp_split` | — | false | mutually exclusive with `segmentSize`; `mlpSplit` Gemma4-only |
153
+ | `learningRate` | `learning_rate` | `LR` | 1e-5 for orpo (job/CLI/launcher) | `DEFAULT_TRAIN_CONFIG` is 2e-4 (SFT) |
154
+ | `rank` / `scale` | `rank` / `scale` | `RANK` / `SCALE` | orpo: 16 / **2.0** (CLI + launcher) | `DEFAULT_TRAIN_CONFIG`: 8 / 1.0; SFT example scripts use 20 |
155
+
156
+ Method-prefixed knobs (`orpo_*` / `dpo_*`) are deliberate: no shared or renamed
157
+ fields.
158
+
159
+ ## 4. Recommended defaults and regularization geometry
160
+
161
+ From the hyperparameter spike (paper, TRL `ORPOConfig`, Labonne's Llama-3
162
+ ORPO-LoRA). The trap: the paper's `8e-6` is for *full* fine-tuning and
163
+ DPO-LoRA's `5e-5` is too hot because ORPO carries a full SFT-NLL term.
407
164
 
408
165
  | Knob | Default | Why |
409
166
  |---|---|---|
410
- | `learning_rate` | **`1e-5`** | Matches our SFT recipe; lower than DPO-LoRA (`5e-5`) because the SFT NLL term is in the loss. Sweep range 5e-6–2e-5. |
411
- | `orpo_lambda` (λ) | **`0.1`** | Paper's primary value + TRL default. Weights **only** `L_OR`; `L_SFT` stays unweighted. Stable range 0.1–0.5 (λ=1.0 also depresses chosen log-probs). |
412
- | `orpo_lr_schedule` | **`cosine`** | Paper: linear warmup → cosine decay (linear fine for short runs). |
413
- | `orpo_warmup_iters` | **≈3% of `iters`** | Paper warmup_ratio ≈0.03; ~10 steps on very short runs. |
414
- | `scale` (LoRA α) | keep **`20`** (our recipe) | Labonne uses 32 (α=2·r); either is fine — no ORPO-specific reason to change ours. |
415
- | `rank` | **`16`** | Matches our SFT recipe and Labonne's ORPO-LoRA. |
416
- | epochs | **1** to start | ORPO is single-stage/data-efficient; eval loss rises fast past epoch 1–2 — early-stop on val rather than running long. |
417
-
418
- Sources: [ORPO paper (EMNLP 2024)](https://aclanthology.org/2024.emnlp-main.626/),
167
+ | `learning_rate` | 1e-5 | matches our SFT recipe; sweep 5e-6–2e-5 |
168
+ | `orpo_lambda` | 0.1 | paper's primary value + TRL default; stable 0.1–0.5 (λ=1.0 depresses chosen logp) |
169
+ | schedule / warmup | cosine, ≈3% of iters | the OR gradient's `1/(1−P)` factor amplifies low-likelihood updates, so cold high LR hurts more than in SFT |
170
+ | `rank` | 16 | |
171
+ | epochs | 1 to start | eval loss rises fast past epoch 1–2; early-stop on val margin |
172
+
173
+ Sources: [ORPO paper](https://aclanthology.org/2024.emnlp-main.626/),
419
174
  [TRL `orpo_config.py`](https://github.com/huggingface/trl/blob/main/trl/trainer/orpo_config.py),
420
- [Labonne, Fine-tune Llama 3 with ORPO](https://huggingface.co/blog/mlabonne/orpo-llama-3).
421
-
422
- > Implementer correctness notes from the spike: `L = L_SFT + λ·L_OR` — the SFT
423
- > term is **unweighted** (only `L_OR` carries λ); the odds ratio uses the
424
- > **length-normalized mean** log-prob (summing reintroduces length bias and
425
- > makes the `L_OR` gradient length-dependent); the `1/(1−P)` factor in the OR
426
- > gradient amplifies low-likelihood updates (another reason to keep LR
427
- > conservative); and `log1mexp` is the NaN hazard (clamp / two-branch).
428
-
429
- ## Regularization & optimizer geometry
430
-
431
- ORPO is data-efficient and usually run ~1 epoch on small preference sets, so
432
- **overfitting and step-size geometry matter more than raw capacity**. The knobs,
433
- ordered by leverage, with the non-obvious LoRA interactions called out.
434
-
435
- ### Rank is the primary regularizer
436
-
437
- Low rank *is* the capacity limit — the main implicit regularizer, and the
438
- `rank_scaling` policy (`by_bits` / `by_kl`) is how capacity is *distributed*
439
- across layers. Treat rank as the first dial; weight decay and dropout are
440
- secondary. This is also why wiring the `bitsMap` (above) matters: it lets the
441
- regularizer (capacity) track per-layer precision instead of silently going
442
- uniform.
443
-
444
- ### The rank ↔ scale ↔ init interaction (the one to get right)
445
-
446
- Our effective update is `α·B A` with `α = scale` and **B zero-init, A
447
- uniform(±1/√in)** (so `ΔW=0` at step 0; adapted == base). Two consequences:
448
-
449
- 1. **At step 0 only B moves.** The LoRA backward is `dA = α·xᵀ(dy Bᵀ)`,
450
- `dB = α·(xA)ᵀ dy`. With `B=0`, `dA ∝ dy Bᵀ = 0` — **A has zero gradient on
451
- the first step**; B does all the early learning until it lifts off zero. This
452
- is intended, but it means B's effective LR and A's init scale (which sets the
453
- magnitude of `h=xA`, hence the first `dB`) dominate early dynamics. It's the
454
- reason **LoRA+** (a higher LR for B than A, often ~16×) helps — B is on the
455
- critical path. Worth exposing as an optional `orpo`/LoRA knob.
456
- 2. **Fixed `α` distorts rank scaling.** With `by_bits`/`by_kl` varying rank per
457
- layer, a *fixed* `α` makes the effective step size scale with rank — so
458
- wider adapters get bigger *updates*, not just more *capacity*, defeating the
459
- intent. **rsLoRA** (rank-stabilized: scale by `α/√r` instead of `α/r`-style)
460
- decouples capacity from step size and keeps gradients stable as rank grows.
461
- Given our rank-scaling story, this is a real correctness/stability fix, not a
462
- nicety — flag it as a `scale` policy option to evaluate.
463
-
464
- ### Weight decay — note the geometry, then probably lower it
465
-
466
- AdamW already applies `weight_decay` (default `0.01`) to A/B. But decaying A and
467
- B independently is **not** L2 on the effective update `ΔW=αBA` — minimizing
468
- `‖A‖² + ‖B‖²` subject to a fixed `BA` is a **nuclear-norm-style** (low-rank)
469
- penalty on `ΔW`, not Frobenius. So LoRA weight decay nudges toward
470
- lower-effective-rank updates. For ORPO specifically the **SFT-NLL term already
471
- anchors** the model to high chosen-likelihood (a built-in regularizer), so heavy
472
- WD is often redundant — consider defaulting **lower (e.g. 0.0–0.01)** and tuning,
473
- rather than inheriting the SFT `0.01` blindly.
474
-
475
- ### LR schedule & warmup
476
-
477
- Cosine decay with a short warmup (`orpo_lr_schedule` / `orpo_warmup_iters`,
478
- ≈3%). Warmup matters *more* for ORPO than SFT: the odds-ratio gradient carries a
479
- `1/(1−P)` factor that **amplifies low-likelihood updates**, so a cold high LR is
480
- extra destabilizing. Reuse `warmupCosineSchedule` (already in the DPO loop).
481
-
482
- ### Dropout
483
-
484
- LoRA-input dropout (PEFT-style), default `0.0`, `~0.05` for small sets — see the
485
- [Dropout subsection](#dropout) for wiring and the **recompute-determinism**
486
- constraint (the mask must be replayed in the segmented/checkpointed/chunked
487
- backward or grads diverge). ORPO's base has no dropout modules (TRL's
488
- `disable_dropout` is moot for us); only the adapter dropout is in play.
489
-
490
- > Net: rank (+ `bitsMap`-aware scaling) is the main regularizer; **rsLoRA**
491
- > (`rs_lora`), **LoRA+** (`lora_plus_ratio`), and **LoRA dropout** (`lora_dropout`)
492
- > are now implemented (default off); default weight decay **lower** than SFT (the
493
- > SFT-NLL term anchors); cosine + warmup. The remaining open work is **empirical**
494
- > — sweep `rs_lora`/`lora_plus_ratio`/`lora_dropout`/`weight_decay` on a real run
495
- > and watch the held-out **val margin** (`bestMargin` in metrics.json) to pick
496
- > the setting that resists the ~450-example overfit.
497
-
498
- ## Parity
499
-
500
- Anchor the loss math to a **standalone torch/numpy reference** (the paper /
501
- TRL `odds_ratio_loss` + the NLL term) rather than an upstream port:
502
-
503
- - `scripts/experiments/parity-orpo.ts` (or a `.py` companion run under the
504
- oracle venv): feed fixed `(chosen_logits, rejected_logits, masks)` to both
505
- our `orpoLoss` and a hand-written reference; assert the scalar loss,
506
- `log_odds`, and the nll/or split match to tolerance.
507
- - Add a focused unit test with a couple of toy `(ℓw, ℓr)` cases where
508
- `log_odds` and the loss are computed by hand, guarding the `log1mexp`
509
- stability path.
510
-
511
- **Oracle ladder for the optimized path.** With no upstream ORPO to port, each
512
- optimization tier is validated bit-exact (within bf16 tolerance) against the
513
- tier below it, bottoming out at the math reference above:
514
- - **naïve ORPO** = full-logits, two separate forwards, `orpoLoss` as specified
515
- → validated against the torch/numpy math reference.
516
- - **chunked fused log-prob engine** (lever 1) → loss *and* `∇A`/`∇B` vs the
517
- naïve full-logits forward.
518
- - **segmented / intra-layer split** (lever 4) → grads vs the unsegmented path
519
- (the existing SFT segmented backward already holds to ~0.00% / bf16-class;
520
- same harness).
521
- - **shared prefix** (lever 7) → vs the two-sequence forward (the published
522
- method is numerically identical; the prefix just accumulates both cotangents).
523
-
524
- ## Memory & speed optimization
525
-
526
- ORPO has no upstream bit-exact oracle (a no-oracle original, Lab-class), which gives us latitude to
527
- optimize the runtime aggressively as long as we stay self-consistent with a
528
- naïve reference (see [Parity](#parity)). This section is the result of a
529
- dedicated research spike across three areas — activation/segmentation memory,
530
- large-vocab loss memory, and preference-training structure — ranked for *our*
531
- constraints: MLX, Apple-Silicon **unified memory** (~24 GB), LoRA-only, B=1,
532
- long context, two prefix-sharing branches, 262k vocab.
533
-
534
- ### What dominates, and the one principle that orders everything
535
-
536
- At long context two terms dominate, and **neither is the LoRA grads** (those are
537
- tiny): (1) the LM-head logits `[1, T, 262k]` and their gradient — ~4.3/8.6 GB at
538
- T=8192, bf16 — and (2) the per-layer activations held for the backward. ORPO
539
- forwards *two* responses, so the logit term is effectively doubled vs SFT.
540
-
541
- The ordering principle, specific to unified memory: **only reducing total live
542
- footprint moves the 24 GB constraint — relocation does not.** On a discrete GPU,
543
- offloading activations to host DRAM over PCIe is a real second lever (Unsloth's
544
- offloaded checkpointing, ZeRO-Infinity). On Apple Silicon the CPU and GPU share
545
- one physical pool ([MLX unified memory](https://ml-explore.github.io/mlx/build/html/usage/unified_memory.html)),
546
- so "offload to CPU" frees **zero** bytes, risks double-residency (raising peak),
547
- and burns the ~273 GB/s bandwidth that is the actually-scarce resource. **We do
548
- not implement activation offloading.**
549
-
550
- ### Ranked levers (with our-setup verdicts)
551
-
552
- **1. Chunked / fused log-prob engine — build first, biggest unconditional win.**
553
- CE and the per-token target log-prob ORPO needs are the *same* quantity:
554
- `logp_t = (h_t·W_{y_t}) − logsumexp_v(h_t·W_v)`, `ce_t = −logp_t`. So one
555
- memory-efficient engine serves both the SFT-NLL term and the odds-ratio
556
- log-probs. The trick (Liger `FusedLinearCrossEntropy`, Apple
557
- [Cut Cross-Entropy](https://arxiv.org/abs/2411.09009)): tile the vocab
558
- projection, combine partial `logsumexp`s with the
559
- [online-softmax](https://arxiv.org/abs/1805.02867) recurrence, gather the target
560
- logit — so the full `[T, V]` logits never materialize, and the backward
561
- reconstructs `softmax − onehot` from a saved per-token LSE scalar (O(T) stored,
562
- not O(T·V)). CCE reports loss memory **24 GB → ~1 MB**, head-training **28 → ~1
563
- GB** at this vocab scale. This **subsumes our current `responseOnlyCe` trick**
564
- (which only shrinks the logits to the response span) and generalizes it.
565
- - *MLX reality:* nothing fused/chunked exists in the MLX ecosystem today —
566
- this is new work. Build it in **pure MLX** as a Liger-style **chunked**
567
- log-prob (matmul + `logsumexp` + `takeAlongAxis` per chunk, driven by an
568
- `mx.vjp` per chunk — *not* a surrogate `value_and_grad`, per our
569
- [segmented-backward leak lesson](segmented-backward-training.md)). Pass
570
- `(hidden, baseW, loraA, loraB, targets)` as **explicit** VJP args so the
571
- LoRA-factored head backward (`∇A`, `∇B`) falls out per chunk — Apple's CCE
572
- has no LoRA path, so even adopting it would mean deriving this ourselves.
573
- Exact in forward and backward; no Metal kernel required. Reserve a
574
- `mx.fast.metal_kernel` flash-CCE as a **Lab speed experiment** only if per-chunk
575
- logits are still too large in practice.
576
-
577
- > ✅ **LANDED + validated (2026-06-18) — `fusedLogpMeanB1` in
578
- > [`loss.ts`](../../src/train/loss.ts), gated by `orpo_fused_ce`.** A single
579
- > `CustomVjp` (Liger `FusedLinearCrossEntropy` structure ported to MLX): forward
580
- > token-chunks the response, materializes only `[chunk,V]` logits per chunk (=
581
- > `quantizedMatmul(h_c, W, …, transpose=true)` + Gemma `logitSoftcap`), reduces to
582
- > `logp = target_logit − logsumexp`, drops the logits; the **analytic** backward
583
- > recomputes per chunk and forms `cotangent·(onehot − softmax)·sech²` directly
584
- > (no autograd through logsumexp+gather), then `dh_c =
585
- > quantizedMatmul(grad, W, …, transpose=false)` — mlx's own x-vjp of the
586
- > transpose=true head (contracts vocab without dequantizing the 1.3 GB head).
587
- > **Validated** ([`scripts/experiments/fused-ce-parity.ts`](../../scripts/experiments/fused-ce-parity.ts)
588
- > + gated [`tests/train-orpo-fused-ce.test.ts`](../../tests/train-orpo-fused-ce.test.ts)):
589
- > value **BIT-EXACT** vs the full-logits `branchLogpMeanB1` on BOTH MiniCPM5 (no
590
- > softcap) and e4b (softcap=30 — matched by reusing `logitSoftcap`'s exact
591
- > `div`-by-bf16-cap formula); LoRA grads in the bf16 class (**0.51%** MiniCPM5 /
592
- > **1.97%** e4b, the documented scale=1.0 band); peak `[chunk,V]`-bounded both
593
- > directions (MiniCPM5 M=2048: 13.32 → 12.80 GB, the win grows with M·V). Wired
594
- > through `ChunkCtx.fused` → `branchLogpMean` (B=1, non-segmented; Gemma + CPM5
595
- > heads); `orpo_fused_ce` reuses `orpo_chunk_size` as the token-chunk (default
596
- > 512). Head is not a LoRA target so only `dh` flows back (head-LoRA `∇A/∇B` fold
597
- > + vocab-blocked online-softmax CCE tier are the remaining follow-ons). The spec
598
- > that guided it is below.
599
- >
600
- > ⚠️ **Segmented integration — investigated; head is NOT the segmented bottleneck
601
- > (2026-06-18).** Wired a bounded head into `SegmentedBackwardOrpo` /
602
- > `SegmentedBackwardOrpoGemma4` (constructor `fusedChunkSize`, gated by
603
- > `orpo_fused_ce`; `boundedHeadFromHidden` in `segmented.ts`). Two findings:
604
- > (1) the **analytic fused `CustomVjp` does NOT bound memory when nested inside the
605
- > segmented `mlx_vjp` head** — its per-chunk `[chunk,V]` graph is not freed
606
- > incrementally there (it IS at the top level, where peak scales with chunk size),
607
- > so it *raised* peak, growing with M (+0.8 GB @L=3072, +1.1 GB @L=4096 on
608
- > MiniCPM5). So the segmented bounded head uses the **Checkpoint recompute** variant
609
- > (`chunkedLogpMeanFromHidden`) instead — bit-exact (0.00% grads, pure recompute),
610
- > never increases peak. (2) But **bounding the head does not reduce the segmented
611
- > peak at any reachable scale** — verified full-vs-bounded head identical peak on
612
- > MiniCPM5 (130k vocab) L=2048/3072/4096 × SEG=2/6 AND e4b (262k vocab) L=2048
613
- > (11.95 GB both). The per-segment backward recompute + the saved forward
614
- > boundaries are the binding constraint; the head `[M,V]` is transient (computed in
615
- > the head phase, freed before the segment-backward peak), so it never stacks with
616
- > the peak. **Conclusion:** segmentation already removes the layer pressure, and at
617
- > these scales the head was never the stacking term — so fused-CE-in-segmented is
618
- > NOT a peak win (the cumulative-pressure hypothesis doesn't bind here). The
619
- > Checkpoint bounded head is retained as a bit-exact, zero-cost safety for the
620
- > extreme vocab×M regime (head would only bind well past what fits) and as the hook
621
- > for a future Metal CCE kernel. Default segmented path (`orpo_fused_ce` off) is
622
- > unchanged — full `responseOnlyLogpMean` head.
623
- >
624
- > ⚠️ **Vocab-blocked online-softmax (full CCE) — implemented + correct, but pure
625
- > MLX can't bound its memory (2026-06-18).** `makeVocabBlockedHeadVjp` in
626
- > `loss.ts` (reached via the `vocabBlock` param / experiment scripts) tiles the
627
- > vocab into `vocabBlock`-wide blocks with the [online-softmax](https://arxiv.org/abs/1805.02867)
628
- > running (max `m`, sumexp `d`) recurrence — the exact Cut Cross Entropy structure
629
- > so `[chunk, V]` is never formed. **Numerically validated**
630
- > ([`scripts/experiments/fused-ce-parity.ts`](../../scripts/experiments/fused-ce-parity.ts)
631
- > `VBLOCK=…`): value bf16-class vs the whole-vocab head, grads **flat at ~0.46%
632
- > across block sizes** (8192/2048/512) — once two precision fixes landed: the
633
- > online `(m,d)` accumulation must be **f32** (bf16 across many blocks corrupts the
634
- > exp-sensitive `lse`), and the **`dh` cross-block accumulation must be f32** (the
635
- > dominant grad bug — bf16 summing hundreds of block-`dh`s gave 17%→45% error
636
- > scaling with block count). **But it does NOT bound memory** — the head-isolated
637
- > peak ([`scripts/experiments/fused-ce-headmem.ts`](../../scripts/experiments/fused-ce-headmem.ts),
638
- > MiniCPM5 M=2048) goes the WRONG way: whole-vocab **3.4 GB → 14.1 GB at
639
- > 512-wide blocks**, growing monotonically as blocks shrink. Root cause: the online
640
- > softmax is a **sequentially-dependent chain** (`m`/`d` recurrence + the `dh`
641
- > accumulation chain), and MLX's lazy eval **retains the whole chain** instead of
642
- > freeing each `[chunk,Vblock]` incrementally — unlike the *independent*
643
- > token-chunks (which free fine). Forcing an eval mid-chain breaks autodiff (a
644
- > 35 GB blowup). **Conclusion:** the CCE residency bound (`[chunk,Vblock]` peak) is
645
- > unreachable in pure MLX; it requires a `mx.fast.metal_kernel` with the vocab loop
646
- > INSIDE the kernel (the flash-CCE Lab kernel). The pure-MLX implementation is kept as
647
- > a **validated correctness reference** for that kernel and is **gated off** (not
648
- > wired to a trainer knob — it would only raise peak). So: token-chunking
649
- > (`orpo_chunk_size`) + the analytic fused head (`orpo_fused_ce`) are the usable
650
- > pure-MLX memory levers; vocab-blocked CCE is a Metal-kernel follow-on.
651
- >
652
- > 🔬 **flash-CCE Metal kernel — PROTOTYPED + working, scaling-limited at largest
653
- > vocab (2026-06-18).** [`src/train/flash-cce.ts`](../../src/train/flash-cce.ts):
654
- > `mx.fast.metal_kernel` forward (`logp` + `lse`) and backward (`dh`) that compute
655
- > logits in-kernel from the quantized head (in-Metal 4/8-bit affine dequant via
656
- > mlx's `quantized.h` qdot pattern — the deleted fused-decode kernel used the same),
657
- > online softmax across vocab, target capture, softcap + sech², so the vocab loop
658
- > lives INSIDE the kernel — neither `[M,V]` nor a dequantized `[V,hidden]` head
659
- > touches HBM. **Validated** ([`scripts/experiments/flash-cce-parity.ts`](../../scripts/experiments/flash-cce-parity.ts)):
660
- > forward `logp` bf16-class (~0.21%) vs the MLX whole-vocab head; backward `dh`
661
- > ~bit-exact vs autograd (isolated 2e-4%; e4b M=512 0.40%); softcap correct on
662
- > e4b. **Memory bound ACHIEVED** — peak ~flat in V: MiniCPM5 M=8192 forward
663
- > reference **2.45 GB → kernel 0.34 GB**; e4b M=512 backward **2.06 → 1.17 GB**.
664
- > **Scaling limit (v1):** one token = one threadgroup looping the WHOLE vocab
665
- > serially. On e4b (262k vocab × 2560 hidden) each threadgroup runs ~3.3× longer
666
- > than MiniCPM5 and **intermittently trips the GPU watchdog** at higher M (e4b ≤512
667
- > solid; ≥1024 nondeterministic garbage — confirmed 89/95/100% across repeats; a
668
- > timeout, not a logic bug — MiniCPM5 is solid to M=8192).
669
- >
670
- > ✅ **VOCAB-PARALLEL form LANDED — e4b now scales (2026-06-18).** Transpiled
671
- > Apple CCE's `cce_lse_forward` / `cce_backward` grid: `grid=[TG, nVocabBlocks, M]`,
672
- > each threadgroup handles a bounded `VOCAB_BLOCK`-row (8192) slice for one token,
673
- > so no dispatch loops the whole vocab. **Forward** writes per-block partials
674
- > (blockMax, blockΣexp, target logit) to `[M, NBLK]`, merged cross-block by cheap
675
- > MLX ops (`gMax`/`Σexp(max−gMax)`/`lse`) — Apple uses a locked atomic logaddexp;
676
- > the MLX merge is simpler, same result. **Backward** recomputes per (token,
677
- > vocab-block) and **atomic-adds** its partial `dh` into the shared `[M,H]` output
678
- > (`atomicOutputs` + `initValue 0`; NBLK-way contention per dim = Apple's locked
679
- > atomic `dE` add — and we need only `dE`/`dh`, not `dC`, since the head is frozen).
680
- > **Validated** ([`scripts/experiments/flash-cce-parity.ts`](../../scripts/experiments/flash-cce-parity.ts)):
681
- > e4b M=1024 (NBLK=32) — watchdog GONE, deterministic, **logp 0.20% / dh 0.40%**
682
- > (bf16-class), **bwd peak 3.41 → 1.46 GB**, fwd peak 1.27 → 0.82 GB; MiniCPM5
683
- > unregressed. Dequant runs f32 in-kernel → arguably more accurate than the bf16
684
- > MLX head.
685
- >
686
- > ✅ **SPEED — `BLOCK_B` token tiling landed (2026-06-18).** The v1 vocab-parallel
687
- > kernel was correct + memory-bounded but ~1000× too slow (e4b M=512: fwd 5.1 s,
688
- > bwd 16.8 s) — **ALU-bound on the scalar dequant** (71 GB/s effective ≪ 273
689
- > peak), because one-token-per-threadgroup re-dequantizes the head M times.
690
- > Transpiled Apple CCE's `BLOCK_B` token tiling: each thread dequantizes a vocab
691
- > row ONCE and reuses it across BLOCK_B tokens. e4b M=512: **fwd 5.1→1.5 s (3.3×,
692
- > BLOCK_B=8)**, **bwd 16.8→7.3 s (2.3×, BLOCK_B_BWD=4)**, total **2.5×**; grads +
693
- > memory win unchanged (parity logp 0.20% / dh 0.40%, bwd peak 2.06→1.17 GB). Two
694
- > findings: (a) past BLOCK_B≈8 the dequant is amortized and the SCALAR matmul
695
- > dominates (BLOCK_B=16 ≈ BLOCK_B=8) → next tier **simdgroup_matrix**; (b) the
696
- > backward is REGISTER-bound by `dacc[BLOCK_B·DPER]` (BLOCK_B=8 spills/faults;
697
- > BLOCK_B_BWD=4 fits) → separate fwd/bwd tile widths. **Remaining:** simdgroup_matrix
698
- > GEMM + D-tiling + CCE near-zero gradient filter (best case ≈ MLX matmul speed,
699
- > same FLOPs); then `CustomVjp` wrap + `orpo_flash_ce` + trainer wiring.
700
- >
701
- > **Implementation spec — fused linear-CE log-prob head.** Concrete plan,
702
- > grounded in the code. The head is `embed.asLinear(h)` =
703
- > `quantizedMatmul(h, w, scales, biases, spec, /*transpose*/true)` for tied models
704
- > (Gemma/CPM5-tied) or `lmHead` (`QuantizedLinear`) for untied; a **vocab chunk is
705
- > an axis-0 row-slice** of `w`/`scales`/`biases` (the quant `spec` is one per head)
706
- > fed to `quantizedMatmul` per chunk. **Gemma applies `final_logit_softcapping`
707
- > (e4b = 30.0): `logit = cap·tanh(raw/cap)`** — must be folded into BOTH the
708
- > online-LSE and the backward (`d logit/d raw = sech²(raw/cap) = 1 − tanh²(raw/cap)`);
709
- > CPM5 has no softcap.
710
- >
711
- > - **New fn `fusedLogpMeanB1(model, ids, mask, validLen, vocabChunk)` → [1]** in
712
- > [`loss.ts`](../../src/train/loss.ts), selected by a new `orpo_vocab_chunk`
713
- > knob (the existing `orpo_chunk_size` stays the token-axis tier; the two
714
- > compose — token-chunk the M response rows, vocab-chunk each row's head). It
715
- > replaces the per-chunk `logitsFromHidden` (which materializes `[Cc, V]`) with
716
- > the tiled engine below, wrapped in a single `CustomVjp` (the pattern the
717
- > since-deleted `fused-geglu-kernel.ts` established: a
718
- > `mx.custom_function` with a hand `.vjp`, validated against `mx.vjp`/autograd —
719
- > never a hand formula, per the standing rule).
720
- > - **Explicit VJP inputs** `(hC [M,hidden], baseW, scales, biases, [loraA, loraB
721
- > if the head is a LoRA target], targets [M])`; output = per-token `logp [M]`
722
- > (or the summed scalar). The head is NOT a default LoRA target today
723
- > (`DEFAULT_TARGET_MODULES` is attn/MLP), so v1 can treat the head as **frozen**
724
- > (only `dh` flows back) and skip `∇A_head`/`∇B_head` — add the LoRA-head path
725
- > only when the head becomes a target. `dh` is what seeds the layer-stack LoRA
726
- > backward, so the frozen-head path already delivers the memory win.
727
- > - **Forward (online-softmax over vocab tiles), per token-row block:** init
728
- > `m = −inf [M]`, `s = 0 [M]`, `tgtLogit = 0 [M]`. For each vocab chunk `[v0,v1)`:
729
- > `raw = quantizedMatmul(hC, w[v0:v1], scales[v0:v1], biases?[v0:v1], spec, true)`
730
- > `[M, Cv]`; apply softcap if set; `mNew = max(m, max_v raw)`;
731
- > `s = s·exp(m−mNew) + Σ_v exp(raw−mNew)`; `m = mNew`; where the target index
732
- > falls in `[v0,v1)`, capture `tgtLogit`. After all chunks: `lse = m + log(s)`,
733
- > `logp = tgtLogit − lse [M]`. **Store only `(m or lse, s, tgtLogit, targets)` —
734
- > O(M), never `[M,V]`.**
735
- > - **Backward (recompute vocab tiles from the saved LSE):** upstream cotangent
736
- > `g [M]` (= `∂loss/∂logp_t`). For each vocab chunk recompute `raw`, apply
737
- > softcap, `softmax_v = exp(softcap(raw) − lse)`; the per-logit cotangent is
738
- > `g·(onehot_target − softmax)` (× `sech²` if softcap). Accumulate
739
- > `dh += (that)·W_chunk` (through the quantized matmul's `x`-vjp, which MLX has)
740
- > and, if head-LoRA, `∇A/∇B` via the factoring. **Never forms `[M,V]`.** Validate
741
- > the `.vjp` against `mx.vjp` over `logitsFromHidden`+gather on a small vocab.
742
- > - **ORPO-grad folding (later micro-opt):** ORPO needs `logp` then reduces via
743
- > `orpoLossFromLogps`; with `logp` exposed as the `CustomVjp` output, autograd
744
- > composes the odds-ratio grad outside the head kernel (correct, simplest).
745
- > Folding `∂L_OR/∂logp` *inside* the kernel (Tier-1 fusion #3) is a speed-only
746
- > refinement once the exact engine lands.
747
- > - **Oracle ladder (per [Parity](#parity)):** `fusedLogpMeanB1` loss + `∇`(LoRA)
748
- > vs the naïve full-logits `branchLogpMeanB1` (bf16-exact); then vs the
749
- > token-chunked `chunkedLogpMeanB1` (same class). New gated test
750
- > `tests/train-orpo-fused-ce.test.ts` mirroring `train-orpo-chunked.test.ts`;
751
- > bench row in [`bench-orpo.ts`](../../scripts/bench-orpo.ts) (peak at 262k vocab).
752
- > **Note:** at 262k a single row `[1,262k]` bf16 ≈ 0.5 MB, so the token-chunked
753
- > head ALREADY bounds memory to `[chunkSize, 262k]`; the fused engine's win is
754
- > (a) eliminating the `[chunk,V]` forward+backward materialization entirely and
755
- > (b) the head-LoRA + ORPO-grad fusion — the moat (no MLX preference trainer
756
- > has it), not a raw 24 GB→1 MB cliff at our vocab.
757
-
758
- **2. Reference-free structure — already ours; keep it, and don't regress it.**
759
- ORPO is **2 forwards/step** (chosen + rejected, both with grad) vs DPO's **4**
760
- (2 reference + 2 policy). That is the single biggest reason ORPO fits 24 GB.
761
- The chosen forward feeds **both** the NLL term and the chosen log-prob; the
762
- rejected forward contributes only its scalar log-prob — never forward chosen
763
- twice, never compute a rejected NLL.
764
-
765
- **3. Two unpadded sequential forwards — NOT a padded concatenated forward.**
766
- TRL's `concatenated_forward` pads chosen+rejected to a shared max and runs one
767
- batch-of-2; its own docstring says this is to amortize **FSDP** all-gathers —
768
- which we don't have. On single-device bandwidth-bound unified memory the padding
769
- is pure waste, and we must retain both branches' activations for the backward
770
- either way. So our two B=1 forwards (chosen, then rejected) are the *right*
771
- shape, not a limitation.
772
-
773
- **4. Segmented backward + intra-layer (attn/MLP) split — the per-layer AND
774
- intra-layer ask.** Wire the preference path into the existing
775
- [`SegmentedBackward`](../../src/train/segmented.ts) (currently SFT B=1 only),
776
- then go finer. The residual-stream hidden `h` *between* the attention add and
777
- the MLP add is a **graph min-cut of width one** `[1, T, d]` — the same kind of
778
- object we already detach at segment edges. Detach `h`, run the **MLP sub-block**
779
- VJP to get `dL/dh`, seed that into the **attention sub-block** VJP. Peak per unit
780
- drops from `attn + MLP` to `max(attn, MLP) + h`.
781
- - *The non-obvious part, specific to us:* because we train through `ops.sdpa`
782
- (MLX's fused flash kernel), the O(T²) attention scores are **never
783
- materialized** — so the **MLP intermediate** (SwiGLU/GeGLU gate+up at
784
- `d_ff`) is the dominant resident tensor, the *reverse* of the
785
- materialized-scores textbook case. So the high-value isolation is the **MLP
786
- sub-block**, not attention. This nests strictly inside the current reverse
787
- VJP loop (a segment of N blocks → 2N sub-VJP units), same detach-leaf
788
- discipline, same e4b donor-KV threading (which lives entirely in the
789
- attention sub-block). Refs: Megatron
790
- [selective recomputation](https://arxiv.org/abs/2205.05198),
791
- [Checkmate](https://arxiv.org/abs/1910.02653).
792
- - ✅ **LANDED on the gradient-checkpoint path** (`config.mlpSplit`, Gemma4 only,
793
- needs `gradCheckpoint` on). `DecoderLayer.forward` is refactored into
794
- `forwardAttn` (inputNorm → attn → residual add, threading donor K/V) and
795
- `forwardMlp` (FFN + per-layer gate); `runSplitCheckpointedLayer` wraps each in
796
- its own `Checkpoint` with the post-attn residual `hMid` as the boundary. The
797
- trainer partitions each layer's LoRA into attn (`self_attn.*`) and MLP
798
- sub-blocks. **Validated bit-exact** on real e4b
799
- ([`scripts/experiments/mlp-split-checkpoint-e4b.ts`](../../scripts/experiments/mlp-split-checkpoint-e4b.ts)):
800
- split grads == single-checkpoint grads == no-checkpoint grads to **0.000000%
801
- / maxAbs 0.0** (it is pure recompute), and the fused-GeGLU `CustomVjp` nests
802
- cleanly inside the `Checkpoint`. **Honest peak finding:** on the *per-layer*
803
- checkpoint path the marginal win is small — L=2048 no-ckpt **28.60 GB** →
804
- single **23.40 GB** → split **23.35 GB** (the single checkpoint already
805
- serializes layers, so the split only saves ~one layer's attn-sized
806
- activations during its recompute window; under `ops.sdpa` the MLP, not attn,
807
- dominates, so `max(attn,MLP) ≈ MLP`). The split's value grows with coarser
808
- checkpoint units / inside the segmented backward; the bigger *MLP* memory
809
- lever at long context is lever 5 (sequence-chunk), still the documented
810
- follow-on. Off by default (numerically identical, a finer memory↔compute
811
- knob).
812
-
813
- **5. Sequence-chunk the MLP sub-block — finer still, free, add only if needed.**
814
- The MLP and projections are **per-token independent**, so the `[1, T, d]`
815
- boundary can be split into row-chunks and the MLP run one chunk at a time,
816
- dropping its resident intermediate from `O(T·d_ff)` to `O(chunk·d_ff)` at
817
- ~zero recompute. A third nesting axis (depth-segment → block → attn/MLP →
818
- seq-chunk-of-MLP) that stacks multiplicatively. Add only if the MLP term is
819
- still binding at 8K after lever 4. (Attention can't be token-chunked naïvely —
820
- it's all-to-all — but `ops.sdpa` already handles it;
821
- [Blockwise/Ring](https://arxiv.org/abs/2305.19370) is the general form.)
822
-
823
- **6. Rotor-style segment planning — replace `planSegmentsBySize`.** The √n rule
824
- ([Chen 2016](https://arxiv.org/abs/1604.06174)) says ~√(nLayers) segments
825
- minimize peak at ~1× recompute (≈6 segments for e4b's 42 layers — matches its
826
- natural `sssssF` period). For our *heterogeneous* stack (full vs sliding layers,
827
- mixed quant, and eventually sub-blocks) the right tool is a **rotor DP**
828
- ([Beaumont 2019](https://arxiv.org/abs/1911.13214)): given a peak-memory budget
829
- it emits the recompute-minimizing split points, isolating the O(T²) full-
830
- attention layers automatically instead of hand-tuning `segment_size`.
831
-
832
- **7. Shared prompt-prefix — a gated, genuinely novel differentiator.** Chosen
833
- and rejected share an identical prompt prefix; the causal mask guarantees the
834
- prefix's hidden states/KV are independent of which response follows. So compute
835
- the **prompt once** and reuse it for both response continuations — token cost
836
- `2(P+R) → P + 2R`. This is published for DPO
837
- ([Wang & Hegde, "Accelerating DPO with Prefix Sharing"](https://arxiv.org/abs/2410.20305))
838
- but **no mainstream trainer does training-time prefix sharing** (TRL's concat
839
- encodes the prompt twice) — real differentiator territory. Backprop is sound:
840
- the prefix is one node with two consumers, so reverse-mode AD just **sums the
841
- two branches' cotangents** into `∂L/∂prefix` — falls out of our VJP machinery,
842
- and it saves the prefix *backward* too.
843
- - *Verdict — gate on P/R.* Speedup → 2× as prompt ≫ response, → ~0 as prompt
844
- ≪ response (measured 1.17–1.57× depending on data). Our chunk/document
845
- fine-tunes are prompt-heavy → a real 1.3–1.5× win; short-prompt/long-answer
846
- pairs get ~nothing. Implementation is **high** complexity (needs a
847
- block-sparse mask or, the natural fit for our KV-cache machinery,
848
- **prefix-KV-once + a per-response attention pass** reading the shared prefix
849
- K/V, with RoPE position-ids reset to `prompt_len` for each response). Build
850
- it **after** lever 1, behind a flag that activates above a P/R threshold.
851
- - ✅ **MiniCPM5 reference BUILT + validated (2026-06-18).**
852
- [`src/train/prefix-shared.ts`](../../src/train/prefix-shared.ts):
853
- `orpoLossPrefixShared` runs ONE forward over the concat
854
- `[prompt(P); chosenResp(Rc); rejectedResp(Rr)]` with (a) a **block-sparse
855
- mask** (`blockSparseMask`: causal AND NOT rejected→chosen) carried via a
856
- `PrefixSharedCache`, and (b) **block-wise RoPE** that resets each response to
857
- offset `P` (`ropeBlocks` + `setMiniCpmPrefixPlan` in
858
- [`minicpm5.ts`](../../src/model/minicpm5.ts)); the head gathers
859
- `chosen[k]←H[P-1+k]`, `rejected[0]←H[P-1]` (shared prompt-last),
860
- `rejected[k≥1]←H[P+Rc+k-1]`. `splitPrefixBatch` detects an identical-prompt
861
- B=1 row and falls back to the two-forward `orpoLoss` otherwise.
862
- **Validated** ([`scripts/experiments/prefix-shared-parity.ts`](../../scripts/experiments/prefix-shared-parity.ts),
863
- P=512/Rc=64/Rr=80): the **FORWARD is BIT-EXACT** vs the two-forward path
864
- (`rel=0.00e+0%`) — the construction proof; ℓw/ℓr are identical. **Grads agree
865
- to ~1.05% (bf16 grad class, < 2%)** — NOT ULP-exact, and confirmed at λ=0
866
- (where the two-consumer prefix-cotangent sum is inert yet the ~1% gap holds),
867
- so the residual is **f32-over-bf16 reassociation** in the LoRA grad matmuls
868
- reducing over the merged length `T` vs `P+Rc`/`P+Rr` separately — the same
869
- band the e4b segmented ORPO path shows, not a construction error. **Token
870
- saving 1.78×** (two-forward 1166 → shared 656 token-passes) and **peak
871
- 5.40 → 3.80 GB**. **Not yet wired into `orpoLoop`** — it's the standalone
872
- reference + correctness gate; trainer wiring (a P/R-gated `orpo_*` flag) and
873
- the e4b port (sliding-window mask interaction + donor-KV + per-layer-input)
874
- are the follow-ons.
875
-
876
- **Explicitly dropped / deferred:** activation offloading (no-op on unified
877
- memory — §principle); TRL padded concatenated forward (FSDP-motivated padding
878
- tax — lever 3); sequence packing and length-grouped batching (SFT-only wins,
879
- near-moot at B=1 — revisit if we add grad accumulation).
880
-
881
- ### Build order for the optimized path
882
-
883
- 1. **Chunked fused log-prob engine** (lever 1) — replaces `responseOnlyCe` /
884
- full-logits `branchLogp` for both the NLL and odds-ratio terms. Biggest win,
885
- unconditional, exact.
886
- 2. **Two unpadded forwards** (lever 3) + keep reference-free (lever 2).
887
- 3. **Preference path into `SegmentedBackward`** then the **intra-layer MLP
888
- split** (lever 4); **rotor planner** (lever 6) to choose the cuts.
889
- 4. **Seq-chunk the MLP sub-block** (lever 5) if still binding at 8K.
890
- 5. **Shared prefix** (lever 7), flagged + P/R-gated, validated bit-exact against
891
- the two-sequence forward.
892
-
893
- > Sourcing caveats from the spike to re-verify before quoting in user docs:
894
- > SimPO's "~10% memory / ~20% faster vs DPO" and the packing "2×" figures are
895
- > paper-reported on other setups; the Griewank 1992 page cite needs a primary
896
- > check (Algorithm 799 / 2000 is solid). None affect the design — only external
897
- > numbers we might cite.
898
-
899
- ## Kernel fusion — the flat-DAG payoff
900
-
901
- The reason to generate a loop-unrolled, branch-deleted forward isn't just
902
- readability: with no Python loop or `instanceof`/conditional control flow, the
903
- whole forward (and its differentiated backward) is a **single straight-line
904
- dataflow DAG**. That unlocks two things — (a) `mx.compile` can form one large
905
- fusion region over it, and (b) static pattern-matching can swap hot subgraphs
906
- for custom fused Metal kernels. Both apply to **forward and backward**.
907
-
908
- ### Verified MLX facts (source line refs from `main`; re-grep against the pinned 0.31.2 before relying)
909
-
910
- - **`mx.compile` fuses the autodiff graph, not just the forward** — but
911
- narrowly. It fuses **contiguous chains of elementwise unary/binary ops** into
912
- one Metal kernel (the canonical `gelu` example: ~5× on M1 Max) and "merges
913
- common work" (dedups duplicate subgraphs); fusion **boundaries** are the
914
- non-elementwise nodes — matmul, SDPA, reductions (softmax/`logsumexp`),
915
- reshape/transpose. So it fuses the **norm/RoPE/residual/LoRA-add/activation
916
- glue between matmuls**, both directions. Control flow executes at *trace* time
917
- (loops unroll, branches resolve), so a straight-line unrolled DAG has **no
918
- control-flow fusion barrier left** and the longest possible elementwise
919
- chains — that is exactly why unrolling helps the fuser.
920
- - **Implementation rule:** compile the **outermost** transformed function —
921
- `mx.compile(value_and_grad(...))`, or the documented
922
- `@partial(mx.compile, inputs=state, outputs=state)` step that fuses
923
- fwd+bwd+optimizer into one graph — or the backward lands *outside* the fused
924
- region. `shapeless=True` avoids recompiles across seq-len (but not ndim/dtype
925
- changes, and shape-conditional graphs break under it).
926
- - **`mx.fast` ships fused fwd+bwd VJP kernels for `rms_norm`, `layer_norm`,
927
- `rope`** (`RMSNormVJP` / `LayerNormVJP`; `RoPE::vjp` cleverly re-runs the same
928
- kernel inverted — zero extra kernel). **Use these; don't hand-roll them.**
929
- - **SDPA is the exception, and it matters:** the forward is fused, but the
930
- **backward is NYI on Metal** — `ScaledDotProductAttentionVJP::use_fallback()`
931
- returns true unconditionally and `eval_gpu` throws, so MLX **decomposes** the
932
- SDPA backward into matmul→softmax→matmul and **materializes the L×L attention
933
- matrix** (the fused backward exists only for CUDA/cuDNN). This is precisely why
934
- our `ops.sdpa` training path has **O(L²) backward memory** (see
935
- [training.md](../reference/training.md)) and why segmentation is doing the
936
- compensating. → A **fused FA2-style SDPA backward** (store only the per-row
937
- `logsumexp` `L`; recompute `S=QKᵀ`, `P=exp(S−L)` in the backward) is therefore
938
- a genuine, high-value custom-kernel target, not a free win.
939
- - **Not covered by `mx.fast`** (need custom kernels): SDPA backward (above),
940
- GeGLU/SwiGLU, cross-entropy / log-softmax+gather, the LoRA epilogue.
941
- - **`mx.fast.metal_kernel` wrapped in `mx.custom_function` with a `.vjp` is
942
- fully differentiable** (verified) — the mechanism for every custom fused
943
- fwd+bwd kernel below. Validate each `.vjp` against `mx.vjp`/autograd, never a
944
- hand-derived formula (our standing rule).
945
-
946
- ### Ranked fusion opportunities
947
-
948
- **Tier 0 — free, do first (table-stakes):**
949
- 1. **Compile the outermost unrolled forward+grad.** One `mx.compile` region over
950
- the straight-line DAG fuses the norm/RoPE/residual/LoRA-add/activation glue
951
- and the matching backward, and merges duplicate work. The unrolling is what
952
- makes the region large; compiling the outermost `value_and_grad` is what puts
953
- the backward inside it.
954
- 2. **Route training through `mx.fast.rms_norm` / `layer_norm` / `rope`** —
955
- already fused both directions. (Note: `sdpa` forward only — its backward is
956
- the custom-kernel target in Tier 1.)
957
-
958
- **Tier 1 — custom fused fwd+bwd Metal kernels (the cutting-edge; no-oracle originals, Lab-gated):**
959
- 3. **Fused linear + log-softmax + gather loss (CCE/Liger-style) — THE moat.**
960
- Lever 1 of the memory plan as a single kernel: fuse the (quantized,
961
- vocab-chunked) head matmul + `logsumexp` + target gather so the `[T, 262k]`
962
- logits never touch memory; the backward reconstructs `softmax − onehot` from a
963
- saved per-token LSE (Liger writes the grad in-place; CCE skips near-zero
964
- softmax elements). Returns the per-token log-prob ORPO needs — **and the
965
- ORPO odds-ratio gradient must be folded into this kernel**, since the grad is
966
- produced inside the forward, not via autograd over materialized logits.
967
- **No MLX project has this** (see landscape) — biggest differentiator.
968
- 4. **Fused SDPA backward (FA2-style).** Fills the Metal NYI gap above: store the
969
- per-row `logsumexp` `L` in the forward, recompute `S=QKᵀ` / `P=exp(S−L)` in
970
- the backward (5 matmuls, no stored L×L matrix) → O(L) attention-backward
971
- memory instead of today's O(L²). High value because it removes the term
972
- segmentation is currently compensating for. (FA3's extra tricks are
973
- Hopper-specific; mirror FA2 on Metal.)
974
- 5. **Fused GeGLU/SwiGLU with a vjp.** (The custom fused-GeGLU/SwiGLU kernels
975
- were **DELETED 2026-07-05** — compiled activations own the fusion win
976
- bit-exactly; see
977
- [unified-engine-frontier-plan.md §6](unified-engine-frontier-plan.md). Kept
978
- here as the backward-math record.) The backward math is known (Liger): for
979
- `c = act(a)⊙b`, save `a`,`b`, recompute the activation — `db = dc·act(a)`,
980
- `da = dc·b·act'(a)`. This `.vjp` was written and validated before the
981
- deletion; the memory angle still matters since the MLP intermediate is our
982
- **dominant resident tensor** (the intra-layer split target, lever 4).
983
- 6. **Fused dequant-GEMM + LoRA epilogue (+ manual backward).** MLX's
984
- `quantized_matmul` already fuses dequant into the *base* GEMM; the missing
985
- piece is folding the LoRA epilogue `scale·(x@A)@B` into that kernel and a
986
- hand-derived fused backward — `h=x@A`; `dB=s·hᵀ@dy`; `dA=s·xᵀ@(dy@Bᵀ)`;
987
- `dx=dy@Wᵀ + s·(dy@Bᵀ)@Aᵀ` (Unsloth's `matmul_lora` is the blueprint; rank
988
- r≪d so `h` is cheap to recompute). MLX's stock LoRA is **three separate ops**
989
- + generic autograd — no fused path exists in the ecosystem.
990
-
991
- **Tier 2 — DAG-level passes the flat graph enables:**
992
- 7. **Recompute↔backward fusion** in the segmented path: the recompute-forward
993
- and its per-segment vjp share one compiled fused region (the reason to do
994
- this in a compiler vs eager `mlx_vjp`). Plus the classic passes over the
995
- static DAG — TVM-style anchor pattern-fusion (matmul/SDPA absorbs an
996
- elementwise epilogue; two anchors never chain), Inductor-style vertical +
997
- horizontal fusion (fuse QKV projections, share one RoPE angle), an
998
- XLA-style don't-duplicate-expensive-ops guard, and cross-layer CSE/global
999
- value numbering (the unrolled N-layer graph repeats RoPE tables, masks,
1000
- scale constants).
1001
- 8. **Min-cut rematerialization for segment boundaries.** AOTAutograd's
1002
- `min_cut_rematerialization_partition` is the directly-portable algorithm:
1003
- build a flow network (internal edge capacity = tensor byte-size), bias toward
1004
- recomputing fusion-friendly ops, and `min_cut` picks save-vs-recompute — a
1005
- formal replacement for hand-chosen `segment_size` that *is* fusion-aware.
1006
- [Checkmate](https://arxiv.org/abs/1910.02653)'s MILP is the exact-budget
1007
- upgrade when we must hit the 24 GB ceiling precisely.
1008
-
1009
- ### Competitive landscape (why this is a flagship)
1010
-
1011
- An ecosystem survey found **no MLX preference trainer fuses or chunks the
1012
- loss** — every one (`mlx-lm-lora`, `optiq`'s `dpo.py`, `mlx-tune`, SiLLM, the
1013
- `mlx-examples` DPO gist) materializes the full `[B, T, V]` logits and calls
1014
- stock `log_softmax`/`cross_entropy`. CCE and Liger are Triton/CUDA-only with **no
1015
- Metal path**. Two openings stand out:
1016
-
1017
- - **Correctness:** the current MLX SOTA, `mlx-lm-lora`, ships a **non-canonical
1018
- ORPO** — it uses `log_sigmoid(chosen_logp − rejected_logp)` and **omits the
1019
- additive SFT-NLL term** ORPO requires. Our loss (true log-odds + λ·NLL,
1020
- length-normalized; one known scope divergence — see the note under "The
1021
- objective") is *already* closer to the paper than anything shipping.
1022
- **Do not copy their loss.**
1023
- - **Efficiency:** a **Metal fused/chunked CE + fused LoRA** would be the first
1024
- fused preference loss in the MLX world, on a quantized base, with grad
1025
- checkpointing / segmented backward. Other gaps nobody fills: cached DPO
1026
- reference log-probs, a concatenated/prefix-shared chosen+rejected forward.
1027
-
1028
- These fused kernels are **no-oracle originals (Lab-gated)** — validated
1029
- against the naïve path via the [oracle ladder](#parity), consistent with the
1030
- fidelity model (external-oracle tiers L1/L2 plus the no-oracle Lab class).
1031
-
1032
- ## Model coverage, quantization, batching, dropout
1033
-
1034
- Four cross-cutting requirements, each grounded in what the code already does.
1035
-
1036
- ### Mixed-precision quantized base
1037
-
1038
- **Already handled in the layer stack — ORPO inherits it free.** Each
1039
- [`QuantizedLinear`](../../src/model/gemma4-base.ts) carries its **own**
1040
- `QuantSpec` (bits, group size) from `quantFor(config.quantization, path)`, and
1041
- its `forward` is `quantizedMatmul(x, w, scales, biases, spec)` + the f32 LoRA
1042
- residual. mlx's `quantized_matmul` has a working vjp **w.r.t. `x`** (the entire
1043
- project trains LoRA on a 4-bit base today), and the base weights are frozen
1044
- (grads flow only through the f32 A/B). "Mixed precision" just means a different
1045
- `spec` per call — nothing special on the layer-stack backward.
1046
-
1047
- Two things ORPO must get right for mixed precision:
1048
-
1049
- 1. **Vocab-chunked head over a quantized, possibly-mixed head.** The fused
1050
- log-prob engine (lever 1) tiles the LM head along the vocab axis. The head is
1051
- quantized in every model — tied `QuantizedEmbedding.asLinear`
1052
- (Gemma, tied Qwen) or a separate `QuantizedLinear` `lmHead` (MiniCPM5,
1053
- untied Qwen) — so a vocab chunk is a **row-slice of the quantized
1054
- `w`/`scales`/`biases`** fed to `quantizedMatmul` per chunk. One spec covers
1055
- the whole head, so chunking is a clean slice; this is the only quant-aware
1056
- piece of new code.
1057
- 2. **Wire the `bitsMap` so `by_bits`/`by_kl` actually fire. ✅ DONE.** This is
1058
- optiq's mixed-precision mechanism, confirmed from its source: a calibration
1059
- **sensitivity pass** measures each layer's KL divergence when quantized
1060
- (`optiq/core/sensitivity.py`), the most-sensitive layers are kept at 8-bit,
1061
- and **the same KL signal also sets the LoRA rank** — `by_bits`:
1062
- `rank·(bits/4)` (4-bit→r8, 8-bit→r16), `by_kl`: `rank·clip(kl/median,0.5,2)`.
1063
- optiq's words: *"the same layers kept at 8-bit also get more adapter rank —
1064
- one signal, two optimizations."* It is **rank scaling, not LR/loss/gradient
1065
- weighting** — "more signal to the 8-bit layers" = more adapter capacity
1066
- there. Our [`rank.ts`](../../src/train/rank.ts) was already a faithful port of
1067
- optiq's `sensitivity_rank.py`, but the trainer never supplied the maps, so it
1068
- silently went uniform. Fixed: `bitsMapFromModel` reads each target's
1069
- `linear.spec.bits` (authoritative, always present), `readPerLayerKl` reads
1070
- `optiq_metadata.json` for `by_kl`; the trainer builds both and passes them to
1071
- `resolveRanks`, and emits the resulting rank spread. Benefits SFT/DPO too;
1072
- ORPO inherits it. (Unit-tested weight-free in
1073
- [`tests/train-rank.test.ts`](../../tests/train-rank.test.ts).)
1074
-
1075
- ### Per-model specialized loops vs the monolith
1076
-
1077
- **Preference: a per-model loop-unrolled, branch-deleted training path, with a
1078
- monolithic fallback** — and the codebase already has this exact pattern for the
1079
- forward path. [`scripts/gen-model.ts`](../../scripts/gen-model.ts) generates
1080
- specialized files in [`src/model/generated/`](../../src/model/generated/)
1081
- (`gemma4-12b/e4b/26b`) that unroll the per-layer loop, bake per-layer constants
1082
- (cache index, sliding/full window, **per-layer quant bits/group size**), and
1083
- delete `instanceof`/conditional branches; [`factory.ts`](../../src/model/factory.ts)
1084
- dispatches `GENERATED.get(configFingerprint(config)) ?? Gemma4Model` — generated
1085
- if the fingerprint matches, **monolith otherwise**.
1086
-
1087
- Plan, mirroring that structure for training:
1088
-
1089
- - **Monolith first.** The generic `SegmentedBackward` (MiniCPM5) /
1090
- `SegmentedBackwardGemma4` call stock `runLayerRange` — they cover Gemma + CPM5
1091
- today. Qwen has no segmented backward yet (needs `SSMCache` + a gated-delta
1092
- custom-kernel vjp), so **Qwen ORPO lands first on the full (un-segmented)
1093
- monolithic backward**; segmented Qwen is a follow-on.
1094
- - **Then generate the specialized segment-forward** per model (Qwen / Gemma /
1095
- CPM5) — the unrolled, branch-deleted function the per-segment vjp calls —
1096
- emitted by extending `gen-model.ts`, fingerprint-dispatched, with the monolith
1097
- as the miss fallback. Same proven mechanism, applied to the training segment
1098
- forward instead of the decode forward. (The `mx.compile` flat-DAG path in
1099
- [`compiled-decode.ts`](../../src/model/compiled-decode.ts) is the other
1100
- specialization lever, for reference.)
1101
-
1102
- All three target families exist: [`gemma4.ts`](../../src/model/gemma4.ts),
1103
- [`minicpm5.ts`](../../src/model/minicpm5.ts),
1104
- [`qwen3_5.ts`](../../src/model/qwen3_5.ts) (gated-delta hybrid).
1105
-
1106
- ### Batching
1107
-
1108
- DPO batching already exists — [`iterateDpoBatches`](../../src/train/dataset.ts)
1109
- B>1 pads each branch to its own batch-max with response masks, and
1110
- [`forward.ts`](../../src/train/forward.ts) has the padding-aware `[B,1,L,L]`
1111
- batched attention mask. ORPO reuses all of it: the **chunked fused log-prob
1112
- engine tiles over the `(B · response-token)` axis**, so B>1 falls out, with the
1113
- length-normalization done per row.
1114
-
1115
- The real constraint is the **regime matrix** — the memory-optimized paths
1116
- (segmented backward, response-only head) are **B=1 only** today:
1117
-
1118
- | Regime | Path | Use when |
1119
- |---|---|---|
1120
- | **B>1 monolithic** | batched padded forward + chunked loss, no segmentation | short context, headroom for B rows |
1121
- | **B=1 segmented** | segmented/intra-layer backward, response-only/chunked head | long context (the memory-bound case) |
1122
-
1123
- At long context the intended lever is **`grad_accumulation_steps`** (raise
1124
- effective batch with no extra peak) rather than true B>1. ✅ **Wired** (all three
1125
- loops, sft/dpo/orpo): the shared `accumulateStep` helper in
1126
- [`trainer.ts`](../../src/train/trainer.ts) runs `grad_accumulation_steps`
1127
- micro-batches per optimizer step and mean-accumulates their grads (each scaled by
1128
- `1/N`) before a single `opt.step`. Each micro-batch's grads (and the running
1129
- accumulator) are eval'd before the next micro-batch's forward builds, so only one
1130
- micro-batch's activations are ever live — **peak memory does not grow with `N`**
1131
- (measured ~1.02× at N=3 for both SFT and ORPO on MiniCPM5; composes with the
1132
- segmented and gradient-checkpoint paths). `N=1` is a byte-for-byte pass-through.
1133
- The accumulation math is proven deterministically in
1134
- [`tests/train-gradaccum.test.ts`](../../tests/train-gradaccum.test.ts) and
1135
- demonstrated end-to-end in
1136
- [`scripts/experiments/parity-gradaccum.ts`](../../scripts/experiments/parity-gradaccum.ts).
1137
- True batched segmented backward is a larger effort (pad-aware boundaries); not
1138
- required for a first landing.
1139
-
1140
- ### Dropout
1141
-
1142
- **Currently a dangling stub:** the `/finetune` UI has an `f-dropout` field that
1143
- is sent in the submit payload, but it is **not** in `FinetuneSubmit` / the
1144
- trainer, and the saved config hardcodes `lora_dropout: 0.0`
1145
- ([`lora-params.ts`](../../src/train/lora-params.ts)). Wire it:
1146
- `lora_dropout` → `FinetuneSubmit` → `TrainConfig` → a **training-only** dropout
1147
- on the LoRA input in the adapter forward (PEFT applies dropout to `x` before
1148
- `A`). Default `0.0`; Labonne's ORPO-LoRA uses `0.05`.
1149
-
1150
- - **ORPO note:** TRL sets `disable_dropout=True` on the *base* model so
1151
- chosen/rejected log-probs are deterministic — our quantized base has no
1152
- dropout modules, so only the **LoRA-adapter dropout** is in play. Keep it.
1153
- - **Correctness constraint that must be flagged:** our segmented backward and
1154
- gradient checkpointing **recompute the forward inside the vjp**. If dropout
1155
- draws a fresh mask on the recompute, the gradient is **wrong**. So when
1156
- dropout > 0, the dropout mask must be **deterministic per step** — draw an RNG
1157
- key once per step and **replay the same key** in every recompute (forward and
1158
- each per-segment vjp). Default `0.0` sidesteps this entirely; enabling dropout
1159
- requires the key-replay plumbing. This also interacts with the
1160
- chunked-loss recompute. Note it in the implementation.
1161
-
1162
- ## Experiment: before/after ORPO on Gemma e4b (IFEval)
1163
-
1164
- The end-goal quality experiment — does ORPO *meaningfully improve* the model?
1165
- Train on **UltraFeedback** preference pairs, measure **IFEval**
1166
- (instruction-following) before vs after. IFEval is the primary metric because
1167
- its checks are **programmatically verifiable** — no LLM judge, fully
1168
- reproducible (AlpacaEval/MT-Bench would need a GPT-4-class judge; a follow-on).
1169
-
1170
- Pieces built (all unit-tested, judge-free):
1171
- - [`src/eval/ifeval.ts`](../../src/eval/ifeval.ts) — verifiable-instruction
1172
- scorer (keywords / length / format / case / start-end / punctuation; prompt-
1173
- and instruction-level accuracy). [`tests/ifeval.test.ts`](../../tests/ifeval.test.ts).
1174
- - [`src/eval/ultrafeedback.ts`](../../src/eval/ultrafeedback.ts) +
1175
- [`scripts/curate-ultrafeedback.ts`](../../scripts/curate-ultrafeedback.ts) —
1176
- binarized UltraFeedback → `{prompt, chosen, rejected}` with length filtering.
1177
- [`tests/ultrafeedback.test.ts`](../../tests/ultrafeedback.test.ts).
1178
- - [`scripts/run-ifeval.ts`](../../scripts/run-ifeval.ts) — generate + score, with
1179
- or without an adapter (the before/after runner).
1180
- - [`scripts/bench-orpo.ts`](../../scripts/bench-orpo.ts) — track step time/peak.
1181
-
1182
- Runbook (the generate/train steps are yours to run — they're long / download):
1183
- 1. **Curate:** export `HuggingFaceH4/ultrafeedback_binarized` to JSONL, then
1184
- `bun scripts/curate-ultrafeedback.ts uf.jsonl data/uf-pref 2048`.
1185
- 2. **Baseline:** `MODEL=<e4b> bun scripts/run-ifeval.ts ifeval.jsonl` → record.
1186
- 3. **Fit check:** `MODEL=<e4b> CHUNK=512 bun scripts/bench-orpo.ts` to confirm
1187
- the config fits 24 GB (e4b's 262k vocab makes the chunked head matter).
1188
- 4. **Train ORPO** (no env-flag sanitization needed — the no-vjp kernels were
1189
- deleted 2026-07-05): `method:"orpo"`,
1190
- `learning_rate 1e-5`, `orpo_lambda 0.1`, `orpo_chunk_size 512`,
1191
- `max_seq_length ~1024`, `rank 16`. ⚠️ Segmented backward for ORPO is
1192
- **MiniCPM5-only** today (`SegmentedBackwardOrpoGemma4` is a follow-on), so
1193
- e4b ORPO runs the non-segmented path — rely on the **chunked head** + a modest
1194
- `max_seq_length` to fit; longer context needs the Gemma segmented port.
1195
- 5. **After:** `MODEL=<e4b> bun scripts/run-ifeval.ts ifeval.jsonl --adapter <out>`
1196
- → compare prompt/instruction accuracy to the baseline.
1197
-
1198
- The curated set should bias toward instruction-following pairs so the IFEval
1199
- signal moves (UltraFeedback is broad; a topic/length filter focused on
1200
- formatting/constraint-following prompts sharpens the before/after delta).
1201
-
1202
- **Prompt masking (verified):** the NLL / odds-ratio is computed on the
1203
- **completion only** — `respMask` zeroes the prompt and the loss sums only
1204
- `mask[t+1]==1` positions. This holds even for a huge prompt + tiny completion
1205
- (e.g. a 13 KB policy/transcript prompt + a ~100-char JSON answer): left-
1206
- truncation preserves the boundary (`promptLen = maxLen − completionLen`). The
1207
- *only* way the prompt leaks in is a completion that is itself ≥ `max_seq_length`
1208
- — `loadDpoDataset` warns loudly if that happens. Regression-tested in
1209
- [`tests/dpo-masking.test.ts`](../../tests/dpo-masking.test.ts).
1210
-
1211
- **Small sets over-fit fast** (~450 pairs): keep `orpo_lambda` modest (default
1212
- `0.1`), carve a ~50-pair `valid.jsonl` (curate with `valFrac ≈ 0.1`), and watch
1213
- the **val margin** (mean chosen−rejected log-odds) — it's emitted per
1214
- `steps_per_eval` and persisted to `metrics.json` as `bestMargin` (a better
1215
- checkpoint signal than val loss). `save_checkpoints: true` keeps every eval
1216
- checkpoint so you can mount the best-margin one.
1217
-
1218
- ## Resolved decisions
1219
-
1220
- - **LR / λ defaults** — `learning_rate=1e-5`, `orpo_lambda=0.1`, cosine
1221
- schedule, warmup ≈3% of iters (see Recommended defaults). Revisit after a
1222
- real run.
1223
- - **Schedule knobs** — **method-prefixed**: `orpo_*` for ORPO, `dpo_*` for DPO
1224
- (no shared fields).
1225
- - **Format naming** — rename the `dpo` dataset format to `preference` with
1226
- `dpo` kept as an alias, now that two methods consume it.
1227
- - **Mixed-precision quant** — inherited free in the layer stack (per-linear
1228
- `QuantSpec` + working `quantized_matmul` vjp). New quant-aware code is limited
1229
- to the vocab-chunked head. Also wire `bitsMap` from `linear.spec.bits` so
1230
- `by_bits` rank scaling fires (fixes a known silent fallback; helps SFT/DPO).
1231
- - **Per-model paths** — monolith first (Gemma + CPM5 segmented; Qwen full
1232
- backward), then generated per-model unrolled segment-forwards via
1233
- `gen-model.ts`, fingerprint-dispatched with the monolith as fallback.
1234
- - **Batching** — reuse DPO batching; chunked loss makes B>1 fall out.
1235
- Memory-optimized paths stay B=1; `grad_accumulation_steps` is the intended
1236
- long-context lever but is **not yet consumed by any loop** (tracked follow-on).
1237
- True batched segmented backward is a follow-on.
1238
- - **Dropout** — wire the existing UI stub through to a training-only LoRA-input
1239
- dropout (default `0.0`). If > 0, the dropout key must be drawn per step and
1240
- **replayed** in every recompute (segmented/checkpoint/chunked) or gradients
1241
- diverge.
1242
- - **Regularization (open — small sweep)** — rank is the primary regularizer;
1243
- adopt **rsLoRA** (`α/√r`) so `rank_scaling` regularizes capacity, not step
1244
- size; consider **LoRA+** (higher LR for B, which does the early learning under
1245
- zero-init); default **weight decay lower than SFT** (the SFT-NLL term already
1246
- anchors); cosine + warmup. See [Regularization & optimizer geometry](#regularization--optimizer-geometry).
175
+ [Labonne](https://huggingface.co/blog/mlabonne/orpo-llama-3).
176
+
177
+ **Regularization, ordered by leverage** (ORPO is ~1 epoch on small sets, so
178
+ overfitting and step geometry matter more than capacity):
179
+
180
+ - **Rank is the primary regularizer**; `rank_scaling` (`by_bits`/`by_kl`) is how
181
+ capacity is distributed. `bitsMapFromModel` (reads `linear.spec.bits`) and
182
+ `readPerLayerKl` (`optiq_metadata.json`) feed `resolveRanks` — before this was
183
+ wired the policy silently went uniform. `by_bits`: `rank·(bits/4)`; `by_kl`:
184
+ `rank·clip(kl/median, 0.5, 2)` — optiq's "one signal, two optimizations"
185
+ (`tests/train-rank.test.ts`).
186
+ - **Rank ↔ scale ↔ init.** Update is `α·BA` with B zero-init, A uniform(±1/√in).
187
+ At step 0 `dA ∝ dy·Bᵀ = 0`: B does all the early learning → **LoRA+**
188
+ (`lora_plus_ratio`, higher LR on B leaves) helps. A fixed α makes the step
189
+ size scale with per-layer rank, defeating rank scaling → **rsLoRA**
190
+ (`rs_lora`, α/√r; saved into the adapter config so inference applies the same
191
+ per-layer scale).
192
+ - **Weight decay** on A and B independently is a nuclear-norm-style penalty on
193
+ `ΔW`, not Frobenius; the SFT-NLL term already anchors, so default lower than
194
+ SFT's 0.01 and tune.
195
+ - **LoRA-input dropout** (`lora_dropout`, PEFT-style, default 0, ~0.05 for small
196
+ sets). The base has no dropout modules (TRL's `disable_dropout` is moot).
197
+ Recompute determinism is mandatory: mask keyed by (step, layer) so segmented
198
+ / checkpoint / chunked recompute reproduces it (`tests/train-regularization*.test.ts`).
199
+
200
+ The remaining work here is empirical: sweep `rs_lora` / `lora_plus_ratio` /
201
+ `lora_dropout` / `weight_decay` on a real run and judge by held-out val margin
202
+ (`bestMargin` in `metrics.json`).
203
+
204
+ ## 5. Memory and speed
205
+
206
+ Two terms dominate at long context, and neither is the LoRA grads: (1) the LM
207
+ head logits `[1,T,V]` + gradient (~4.3/8.6 GB bf16 at T=8192, V=262k), doubled
208
+ by ORPO's two branches; (2) per-layer activations held for the backward. The
209
+ ordering principle for Apple Silicon: **only reducing total live footprint
210
+ moves the constraint — relocation does not.** CPU and GPU share one pool, so
211
+ activation offloading frees zero bytes, risks double residency, and burns the
212
+ scarce bandwidth. We do not implement it.
213
+
214
+ ### 5.1 The head: from token-chunking to the flash-CCE kernel
215
+
216
+ CE and the per-token target logp ORPO needs are the same quantity
217
+ (`logp_t = h_t·W_{y_t} − logsumexp_v(h_t·W_v)`), so one memory-efficient engine
218
+ serves the NLL and the odds-ratio terms. Four tiers were built; each is
219
+ validated against the one below.
220
+
221
+ 1. **Token-chunked Checkpoint head** (`chunkedLogpMeanB1`, `orpoChunkSize`).
222
+ Each `[chunk,V]` head is wrapped in a `Checkpoint` and recomputed in the
223
+ backward. A Checkpoint *drops* its input activations, so the hidden slices
224
+ it reads must live in the `sink` until grads are eval'd (found during
225
+ bring-up). Forward parity within bf16 (single-ULP head-matmul tiling
226
+ differences), exact for one chunk (`tests/train-orpo-chunked.test.ts`).
227
+ 2. **Fused linear-CE head** (`fusedLogpMeanB1`, `orpo_fused_ce`; Liger
228
+ `FusedLinearCrossEntropy` on MLX). One `CustomVjp`: forward materializes
229
+ `[chunk,V]` per chunk (`quantizedMatmul(h, W, transpose=true)` + Gemma
230
+ `logitSoftcap`), reduces to `target − lse`, drops the logits; the analytic
231
+ backward recomputes and forms `g·(onehot − softmax)·sech²` directly, then
232
+ `dh = quantizedMatmul(grad, W, transpose=false)` — mlx's own x-vjp,
233
+ contracting vocab without dequantizing the head. Value **bit-exact** vs full
234
+ logits on MiniCPM5 and e4b (softcap=30 matched by reusing `logitSoftcap`'s
235
+ div-by-bf16-cap formula); grads 0.51% / 1.97% bf16-class
236
+ (`tests/train-orpo-fused-ce.test.ts`). The head is frozen (not a LoRA
237
+ target), so only `dh` flows back.
238
+ 3. **Vocab-blocked online-softmax in pure MLX** — implemented, numerically
239
+ correct, **does not bound memory**, gated off. The `(m,d)` recurrence and
240
+ the cross-block `dh` accumulation must be f32 (bf16 gave 17→45% grad error
241
+ scaling with block count). But the online softmax is a sequentially
242
+ dependent chain and MLX's lazy eval retains the whole chain: head-isolated
243
+ peak went 3.4 GB → 14.1 GB at 512-wide blocks (MiniCPM5 M=2048, 2026-06-18).
244
+ Forcing an eval mid-chain breaks autodiff. Conclusion: the CCE residency
245
+ bound needs the vocab loop INSIDE a kernel.
246
+ 4. **flash-CCE Metal kernel** (`flash-cce.ts`, `orpo_flash_ce`) — Apple
247
+ Cut-Cross-Entropy + Liger transpiled Triton → Metal with three divergences:
248
+ the classifier is the **quantized** head (in-Metal 4/8-bit affine dequant,
249
+ mlx `quantized.h` qdot pattern); only `dE`/`dh` is needed (frozen head, no
250
+ `dC`); the cross-block LSE merge is cheap MLX ops on per-block partials, not
251
+ atomic logaddexp. Evolution, all 2026-06-18/19 on the M1 Max 32 GB dev box:
252
+ - v1 (one threadgroup per token, whole vocab serially): memory bound
253
+ achieved (MiniCPM5 M=8192 fwd 2.45 → 0.34 GB; e4b M=512 bwd 2.06 → 1.17
254
+ GB) but e4b ≥1024 tripped the GPU watchdog.
255
+ - Vocab-parallel grid `[TG, nVocabBlocks, M]`, `VOCAB_BLOCK=8192`; backward
256
+ atomic-adds partial `dh` into `[M,H]`. e4b M=1024 deterministic, logp
257
+ 0.20% / dh 0.40%, bwd peak 3.41 → 1.46 GB.
258
+ - `BLOCK_B` token tiling (dequant a vocab row once, reuse across 8 tokens
259
+ fwd / 4 bwd — the backward is register-bound by `dacc`): 2.5× total.
260
+ - **steel GEMM** (`steel-qmm-header.ts`, MLX's verbatim `BlockMMA`) for
261
+ forward and backward, with a temp-BlockMMA-per-H-tile + lane-local
262
+ fragment accumulation: e4b bwd **3687 → 754 ms**, fwd 180 ms, peak
263
+ **0.93 GB flat @ M=8192**; dh 0.40% e4b / 0.28% cpm.
264
+ `MLX_BUN_CCE_NOSTEEL` / `MLX_BUN_CCE_BWD_NOSTEEL` fall back to the
265
+ simdgroup / scalar kernels (also the non-32-divisible-shape fallback).
266
+ - Apple-CCE backward skips: coeff filter (`MLX_BUN_CCE_BWD_FILTER_EPS`) and
267
+ blockMax skip (`MLX_BUN_CCE_BWD_BLOCK_EPS`), **both default `1e-5` since
268
+ 2026-07-02** (`=0` compiles them out). Real-data teacher-forced
269
+ measurement (M1 Max, chunk-ORPO data): filter 0.343%/1.41× (CPM5),
270
+ 0.158%/1.70× (e4b); block skip ≤0.004% dh; combined **1.71× / 3.16×**
271
+ backward vs exact. Run-to-run dh is not byte-stable at any eps (atomic-add
272
+ reassociation). Note: docs/reference/training.md still describes the skips as
273
+ off by default; `flash-cce.ts` is the truth.
274
+ - Operand precision today is **W4A32**: 4-bit weights dequantized into fp32
275
+ tiles and multiplied with fp32 simdgroup matrices; the `dh` accumulator is
276
+ already fp32. See §8.1 (W4A16 spike).
277
+
278
+ Composition finding: the fused `CustomVjp` **does not bound memory when nested
279
+ inside the segmented `mlx_vjp` head** (its per-chunk graph is not freed
280
+ incrementally there; +0.8–1.1 GB at L=3072–4096 on MiniCPM5), so the segmented
281
+ bounded head uses the Checkpoint variant `chunkedLogpMeanFromHidden` (bit-exact
282
+ recompute). And bounding the head did not reduce the segmented peak at any
283
+ reachable scale (MiniCPM5 L=2048–4096 × SEG 2/6; e4b L=2048: 11.95 GB both) —
284
+ the head `[M,V]` is transient and never stacks with the per-segment backward
285
+ peak. The trainer still routes flash/fused into the segmented head so e4b never
286
+ reintroduces `[M,V]`.
287
+
288
+ Host-buffer lesson (2026-06-21): `u32()` kernel-arg arrays built with the
289
+ zero-copy `fromView` and disposed before the lazy kernel evaluated leaked pins
290
+ (+32/step) → use-after-free that ran clean on the 32 GB M1 Max and crashed on
291
+ the 24 GB M4 Pro. Fix: `fromBytesCopy`; `MetalKernel.apply` `ptr()` lifetimes
292
+ hardened; a `pinned` canary on the train metric. `fromView` is only for
293
+ process-lifetime memory. A related UAF in the segmented ORPO classes (the flash
294
+ head's `headSink` freed before the lazy backward read `lse`/`blockMax`) is fixed
295
+ by `ops.evalAll` on the head-VJP roots before dispose.
296
+
297
+ ### 5.2 Attention backward: `ops.sdpa` vs the flash kernel
298
+
299
+ Default training attention is `ops.sdpa` (`mx.fast.scaled_dot_product_attention`)
300
+ — exact (dQ/dK/dV FD-verified 0.00%) and fast, but its Metal backward is NYI
301
+ (`ScaledDotProductAttentionVJP::use_fallback()` returns true), so MLX
302
+ decomposes it and **materializes the L×L scores for every layer**: O(L²)
303
+ backward memory (0.02 → 0.33 → 1.40 GB at T=512/2048/4096 in the isolated
304
+ attention-backward bench; ~3.5 GB/layer @8K on e4b). The hand-rolled flash
305
+ kernel (`src/model/flash-attention.ts`, `MLX_BUN_TRAIN_ATTN=flash`) is O(L)
306
+ (0.03 → 0.23 → 0.90 GB) but ~30× slower — a naive scalar kernel at ~0.5% of GPU
307
+ peak. Two genuine port bugs were fixed rather than deleting it (a spurious dK
308
+ transpose for Tkv≠D; a divergent `threadgroup_barrier` in the dQ causal
309
+ tile-skip), restoring L2 parity with optiq. It remains the dormant long-context
310
+ fallback for MiniCPM5; the trainer refuses it for Gemma.
311
+
312
+ The sliding-window layers are NOT cheaper in the backward — the window is only
313
+ an additive mask on the materialized scores (measured 2026-07-02: a sliding
314
+ pair +7.1 GB ≈ sliding+full +7.15 GB @8K). This falsified the earlier
315
+ full-attention-isolation planner premise (§5.3).
316
+
317
+ ### 5.3 Segmented backward
318
+
319
+ Naive gradient checkpointing (ours and Python mlx's) does not stream: the
320
+ checkpointed backward holds every layer's recompute activations at once, so
321
+ e4b spiked to 23 GB live @2048 (resting 6.95 GB) and crashed at 4096
322
+ (`peak(L) ≈ 7.7 GB + 7.6 MB/token`). Measurement note that unlocked this:
323
+ `mlx_get_peak_memory` is peak **live**, not live+cache; use
324
+ `activeMemory()`/`cacheMemory()`/`clearCache()` to separate them, and eval the
325
+ loss and grads together (`evalAll([value, ...grads])`) — evaluating `value`
326
+ separately frees the forward and forces the backward to recompute-and-hold
327
+ everything.
328
+
329
+ **Mechanism** (`SegmentedBackward*`, `planSegmentsBySize`):
330
+
331
+ 1. Forward, saving boundaries: run the layer stack; at each segment edge
332
+ (`segmentSize` layers) eval and **detach** the residual hidden into a leaf
333
+ (`detachLeaf` = `ops.contiguous` + `detachCopy`; `detachLeaves` batches the
334
+ eval barrier). mlx `eval` does not detach — without the copy each boundary
335
+ drags a layer-stack of activations.
336
+ 2. Head: `finalNorm` + LM head + loss over the last boundary as a `Vjp` with
337
+ scalar cotangent 1.0 → loss value and `dh_out`.
338
+ 3. Backward, reverse over segments: `vjp.apply([boundary_k, ...LoRA_k], [dh_out])`
339
+ → `[dh_in, ...dLoRA_k]`; `dh_in` seeds segment k−1; LoRA grads scatter into
340
+ the flat grad vector. Only one segment's activations are live. Recompute
341
+ overhead ≈ 1× forward regardless of segment count, so `segmentSize` is a
342
+ peak knob at ~fixed time.
343
+
344
+ **The `mlx_vjp` finding.** The first implementation used optiq's
345
+ surrogate-loss trick — `value_and_grad` of
346
+ `sum(stop_grad(dh) ⊙ segment_forward(...))` per segment. Numerically identical,
347
+ but it **leaked ~32 MB of live memory per segment per step** (linear, no
348
+ plateau; 1.12 → 6.61 GB over 30 iters at SEG=4/2048), unreclaimable by
349
+ `clearCache`/GC/`synchronize`/disposal. A minimal repro pinned it to
350
+ `value_and_grad` (`MODE=layers` leaks ~6 MB/iter; `MODE=vjp` flat). Driving
351
+ each segment with **`mlx_vjp`** (`Vjp` in `src/mlx/autograd.ts`) is the
352
+ production path: active flat (1.04 GB × 12 iters end-to-end at SEQ=4096). Vjp
353
+ objects are built once per run and reused; rebuilding per step leaks.
354
+
355
+ **Numbers.** MiniCPM5-1B, SEG=4, M1 Max 32 GB, 2026-06-16, peak live:
356
+
357
+ | seq | full backward | segmented | note |
358
+ |---|---|---|---|
359
+ | 512 | 3.15 GB | 1.87 GB | |
360
+ | 2048 | 10.91 GB | **3.29 GB** | post-vjp fix (3.44 pre) |
361
+ | 4096 | 21–26 GB (spikes) | 6–8 GB | |
362
+
363
+ Under flash attention the segmented grads bit-match the full `value_and_grad`
364
+ (relNorm 0.0000%, maxAbs 0.0). Under `ops.sdpa` they differ ~6% relNorm — not a
365
+ segmentation bug: mlx's fused sdpa gives a different bf16 result in its eager
366
+ forward (12.0712) than its autograd-decomposed forward (12.0568), so boundaries
367
+ saved eagerly and recomputed under autograd disagree. The non-segmented trainer
368
+ already trained against the autograd forward while inference used the eager
369
+ one. A real 300-iter run (SEG=4, SEQ=4096) peaked 6.51 GB (non-seg 25.47 GB),
370
+ no leak, and its adapter scored 95.10 on the chunk holdout vs 91.70
371
+ non-segmented.
372
+
373
+ **e4b (Gemma4) additions** (`SegmentedBackwardGemma4`, `Gemma4Model.runLayerRange`
374
+ / `makeTrainingMasks` / `embedForSegmented` / `reusedDonors`):
375
+
376
+ - Per-layer-input is a pure constant boundary (its grad would flow to the
377
+ embed / `per_layer_model_projection`, neither a LoRA target): detached once,
378
+ sliced per segment, cotangent discarded.
379
+ - KV-shared donor K/V (donors 22 sliding / 23 full feed all sharers in
380
+ [24,42)) is a **second boundary stream with its own cotangent**: sharer
381
+ segments differentiate `[h, donorK, donorV, ...LoRA]` and accumulate the
382
+ donor vjps into `dKV[d]`; the donor segment's multi-output forward
383
+ `[h_out, k, v]` takes `[dh, dKV.k, dKV.v]`. Two contiguity fixes were
384
+ essential: `detachLeaf` must force row-major (donor K/V are transposed
385
+ views) and the producer must output contiguous donor K/V.
386
+ - Grads: single-consumer donor reuse is bit-exact (2-segment split at 24: 0/258
387
+ off); the natural 7-segment cut is ~0.97% relNorm from bf16
388
+ non-associativity of the within-vjp cotangent sums — flat in consumer count,
389
+ tracks summand count (donor 22 ≈0.46%, donor 23 ≈0.000%), not helped by fp32
390
+ cross-segment accumulation. **The lever is the segment cut, not the dtype**:
391
+ keep a donor's sharers in one consumer segment for tighter grads at higher
392
+ peak. Vs the `mlx_lm.lora` bit-exact reference: segmented q_proj dB ~2.3%
393
+ (bf16-class).
394
+ - Memory vs the honest reference (`mlx_lm.lora --train --grad-checkpoint`,
395
+ which checkpoints per layer, 42 groups of 1; oracle venv, M1 Max 32 GB,
396
+ 2026-06-16), matched grouping:
397
+
398
+ | L | mlx-lm (per-layer) | ours segSize=1 | ours segSize=6 |
399
+ |---|---|---|---|
400
+ | 2048 | 12.84 GB | **8.76 GB** | 11.05 |
401
+ | 4096 | 20.87 GB | **10.93 GB** | 16.06 |
402
+ | 8192 | OOM (25.72 @16 layers) | **15.29 GB** | 17.5 (seg2) |
403
+
404
+ Two unisolated confounds: mlx-lm's `default_loss` materializes full
405
+ `[1,L,262144]` logits (~4 GB @4K) vs our response-only CE; and full streaming
406
+ vs per-layer checkpoint inside one backward. The headline is part
407
+ CE-optimization, part mechanism.
408
+
409
+ **Segmentation strategy — corrected 2026-07-02.** The original plan (isolate the
410
+ 7 O(L²) full-attention layers in singleton segments, `planSegments` with a
411
+ memory cost model) was built and A/B'd @8K: **zero peak win** (18.09 vs 18.02
412
+ GB seg2), because every layer's sdpa backward is O(L²) (§5.2). The worst
413
+ segment is set by **layer count alone** — `segment_size` is the whole knob:
414
+ seg1 = 14.59 GB @8K on e4b, +3% step time, loss identical. Going lower needs an
415
+ O(L)-memory attention backward, not smarter segmentation. `planSegmentsBySize`
416
+ stays; the donor/boundary constraints from the plan still hold. Rotor/min-cut
417
+ planners (§8.1) would need a corrected cost model.
418
+
419
+ **ORPO variants.** `SegmentedBackwardOrpo[Gemma4]` run chosen and rejected
420
+ forwards segment-by-segment, a two-input head `Vjp` over `[hC, hR]` yields
421
+ `[dh_c, dh_r]`, each branch walks backward over its segments (donor cotangents
422
+ in a per-branch `dKV`), LoRA grads summed. e4b at L=256: full two-branch 12.67
423
+ GB → segmented 7.66 GB; 8.71 GB at L=1024 where the full backward doesn't fit
424
+ (2026-06-18, M1 Max 32 GB). At λ=0 the e4b path is byte-identical to SFT
425
+ `SegmentedBackwardGemma4` (relNorm 1.1341%, maxAbs 3.79e-3 — the same numbers),
426
+ proving zero added error; donor recipients (layers 22/23 k/v_proj) stay <0.5%.
427
+
428
+ **Intra-layer MLP split** (`mlpSplit`, gradCheckpoint path, Gemma4 only):
429
+ `forwardAttn` / `forwardMlp` checkpointed separately with the post-attn
430
+ residual `hMid` as the boundary. Bit-exact (0.000000%). Honest peak: L=2048
431
+ no-ckpt 28.60 → single 23.40 → split 23.35 GB — the per-layer checkpoint
432
+ already serializes layers, so the split saves one layer's attn-sized window;
433
+ under `ops.sdpa` the MLP intermediate dominates. Off by default.
434
+
435
+ ### 5.4 Shared prompt-prefix forward
436
+
437
+ Chosen and rejected share the prompt; the causal mask makes the prefix's
438
+ hidden states independent of the continuation. One forward over
439
+ `[prompt(P); chosen(Rc); rejected(Rr)]` with (a) a **block-sparse mask**
440
+ (causal AND NOT rejected→chosen, `blockSparseMask`, carried by
441
+ `PrefixSharedCache`), (b) **block-wise RoPE** resetting each response to offset
442
+ P (`ropeBlocks`, `setMiniCpmPrefixPlan` / `setGemmaPrefixPlan`), and the head
443
+ gathering `chosen[k] ← H[P−1+k]`, `rejected[0] ← H[P−1]`, `rejected[k≥1] ←
444
+ H[P+Rc+k−1]` (`prefixGatherIdx`). Token cost `2(P+R) → P+2R`; reverse-mode AD
445
+ sums the two branches' cotangents into the prefix for free. Published for DPO
446
+ (Wang & Hegde, arXiv 2410.20305); no mainstream trainer does it at training
447
+ time. `splitPrefixBatch` falls back to two-forward for rows whose prompts
448
+ differ (logged once, counted).
449
+
450
+ - **MiniCPM5**: forward **bit-exact** vs two-forward (P=512/Rc=64/Rr=80), grads
451
+ ~1.05% (f32-over-bf16 reassociation in LoRA grad matmuls over the merged
452
+ length; holds at λ=0), 1.78× fewer token-passes, peak 5.40 → 3.80 GB
453
+ (2026-06-18).
454
+ - **e4b** (`blockSparsePrefixMaskGemma` with a LOGICAL-position sliding-window
455
+ cut, `Gemma4PrefixSharedCache`, `orpoLossPrefixSharedGemma`): the
456
+ construction is proven — block-RoPE+mask == a plain forward over the same
457
+ concat to 0.00% at every chosen position — but it does NOT reproduce the
458
+ two-forward numbers (loss ~0.08%, hiddens 3–14%) because **the e4b forward is
459
+ itself length-sensitive**: `forward([prompt])` vs `forward([prompt; extra])`
460
+ differ 1.7% at position 0 up to ~14% later, through the production
461
+ `TrainingCache`. Root cause is e4b's scale=1.0 attention (headDim 256, normed
462
+ q/k → sharply peaked softmax) amplifying sub-bf16 matmul-tiling roundings
463
+ that vary with row count — the same class as the SigLIP tower. MiniCPM5
464
+ (1/√d) shows 0.00%. Accepted as a numerical caveat for a fine-tune; the
465
+ single-concat forward is its oracle. Composed with the segmented backward
466
+ (`SegmentedBackwardOrpoPrefixGemma4`, donor-KV + logical-window mask threaded
467
+ through segments): seg-vs-non-seg grads 1.7–2.3%, peak 30–39% lower
468
+ (2026-06-19).
469
+ - Measured effect on e4b @8192 (M1 Max 32 GB, prompt-dominant data, short
470
+ probe): 16.1 GB / ~175 s/step → **13.3 GB / ~70 s/step** with prefix-share.
471
+
472
+ ### 5.5 What composes with what
473
+
474
+ | | flash/fused head | segmented | prefix-shared | grad-accum | B>1 |
475
+ |---|---|---|---|---|---|
476
+ | flash/fused head | — | yes (Checkpoint-bounded head inside segments) | yes (per branch) | yes | no (B=1) |
477
+ | segmented | | — | yes (MiniCPM5 + Gemma4) | yes | no |
478
+ | prefix-shared | | | — | yes | no |
479
+ | grad-accum | | | | — | n/a |
480
+
481
+ The memory-optimized paths are **B=1 only**; `grad_accumulation_steps` is the
482
+ effective-batch lever at B=1 memory (`accumulateStep` evals and frees each
483
+ micro-graph; measured ~1.02× peak at N=3 on MiniCPM5, both SFT and ORPO;
484
+ `tests/train-gradaccum.test.ts`). B>1 falls out on the monolithic path via
485
+ `iterateDpoBatches` padding + the batched pad mask (`forward.ts`). True batched
486
+ segmented backward is not built. Qwen has no segmented backward (needs
487
+ `SSMCache` + a differentiable gated-delta kernel) and would land on the full
488
+ backward first.
489
+
490
+ Explicitly dropped: activation offloading (no-op on unified memory), TRL's
491
+ padded concatenated forward, sequence packing / length-grouped batching
492
+ (SFT-only wins, moot at B=1).
493
+
494
+ ### 5.6 Quantized base
495
+
496
+ Each `QuantizedLinear` carries its own `QuantSpec`; `quantized_matmul` has a
497
+ working x-vjp and the base is frozen, so mixed precision costs nothing in the
498
+ layer stack. The only quant-aware training code is the head: a vocab chunk is
499
+ an axis-0 row slice of the quantized `w`/`scales`/`biases` (one spec per head),
500
+ and the flash-CCE kernel dequantizes in-kernel.
501
+
502
+ ## 6. Kernel-fusion notes (the flat-DAG payoff)
503
+
504
+ Verified MLX facts (re-grep against the pinned 0.31.2 before relying):
505
+ `mx.compile` fuses contiguous elementwise chains in fwd AND bwd, bounded by
506
+ matmul/SDPA/reductions/reshape — compile the *outermost* `value_and_grad` or
507
+ the backward lands outside the region; `mx.fast` ships fused fwd+bwd for
508
+ `rms_norm`/`layer_norm`/`rope` (use them); SDPA backward is the Metal
509
+ exception (§5.2); `mx.fast.metal_kernel` inside `mx.custom_function` with a
510
+ `.vjp` is fully differentiable (the mechanism every custom head uses).
511
+
512
+ Remaining custom-kernel targets, ranked: fused SDPA backward (FA2-style,
513
+ store per-row LSE, recompute `P` — removes the O(L²) term segmentation
514
+ compensates for); fused dequant-GEMM + LoRA epilogue with the Unsloth-style
515
+ manual backward (`h=x@A; dB=s·hᵀ@dy; dA=s·xᵀ@(dy@Bᵀ); dx=dy@Wᵀ + s·(dy@Bᵀ)@Aᵀ`);
516
+ recompute↔backward fusion in the segmented path; min-cut rematerialization
517
+ (AOTAutograd's partitioner) as a fusion-aware replacement for hand-chosen cuts.
518
+ The GeGLU/SwiGLU `CustomVjp` (grad_b = dc·gelu(a), grad_a = dc·b·gelu'(a)) was
519
+ written and validated, then deleted 2026-07-05 with the fused kernels —
520
+ compiled activations own that fusion bit-exactly
521
+ ([unified-engine-frontier-plan.md §6](unified-engine-frontier-plan.md)), and
522
+ training needs no env-flag sanitization.
523
+
524
+ Per-model specialized training paths would mirror `scripts/gen-model.ts`'s
525
+ fingerprint-dispatched generated forwards with the monolith as fallback; not
526
+ built.
527
+
528
+ ## 7. Quality experiment pipeline
529
+
530
+ Pieces (judge-free, unit-tested): `src/eval/ifeval.ts` (verifiable-instruction
531
+ scorer), `src/eval/ultrafeedback.ts` + `scripts/curate-ultrafeedback.ts`
532
+ (binarized UF → preference JSONL with length filtering), `scripts/run-ifeval.ts`
533
+ (before/after runner), `scripts/bench-orpo.ts` (ms/step + peak across head /
534
+ segment configs). Prompt masking is verified (`tests/dpo-masking.test.ts`):
535
+ left-truncation preserves the prompt/response boundary; the only leak is a
536
+ completion ≥ `max_seq_length`, which `loadDpoDataset` warns on.
537
+
538
+ Result so far: an 800-step MiniCPM5 UltraFeedback run left IFEval flat
539
+ (22.5%) — general data + a tiny run on a 1B is the wrong lever. The
540
+ load-bearing target is the **chunk segmenter** (distilled segmentation with
541
+ boundary/label accuracy vs gold). Small sets (~450 pairs) overfit fast: keep λ
542
+ modest, carve a ~50-pair `valid.jsonl`, watch val margin, `save_checkpoints`
543
+ and mount the best-margin checkpoint.
544
+
545
+ ## 8. Future
546
+
547
+ ### 8.1 Deferred performance levers
548
+
549
+ - **Grad-accumulation A/B.** `--grad-accum` is exposed in `mlx-bun train`
550
+ (`scripts/train-orpo.ts` has no env knob for it). What's open is the
551
+ measurement: at hard B=1 every update is one pair (the 0.4↔3.3 per-step loss
552
+ swing is that noise); A/B G=4/8 on val margin. It is not a speedup and not a
553
+ guaranteed win (the prior run converged at B=1, val 1.66→1.50). Generic
554
+ N-way batching is parked — prefix-share is already a hand-built
555
+ batch-of-2 (1F+1B over `[P; Rc; Rr]`), strictly cheaper than a generic
556
+ 2-batch's 2F+2B.
557
+ - **W4A16 head GEMM (compute precision) — proposed 2026-06-19, not started.**
558
+ The steel head runs W4A32 (`BlockMMA<float,float>` fwd and both bwd phases;
559
+ fp32 staging tiles; dequant into fp32). Apple GPUs run fp16 FMA at ~2× fp32,
560
+ and the kernel is **ALU-bound, measured live** (M1 Max, MiniCPM5 ORPO
561
+ `--no-segment` seq 4096: GPU 100% @1296 MHz, memory BW 129 GB/s ≈ ⅓ of ~400,
562
+ 17.4/32 GB) — the 4-bit storage already did its job and the GPU spends
563
+ cycles on fp32 FMAs. Plan: (0) confirm which `BlockMMA`/`BlockLoader`
564
+ template params are operand vs accumulator; (1b) profile the dequant-ALU vs
565
+ FMA split — if FMA < ~40% of the kernel, reset expectations; (2) add a gate
566
+ (`CCE_A_HALF` define + env flag, fp32 stays the exact parity mode for the
567
+ 1e-5 gate, half graded by greedy-prefix logit parity); (3) forward to half
568
+ operands first, land alone; (4) backward range probe — the survivors after
569
+ the skips are near-zero coeff tails where fp16 underflows, and `h` can carry
570
+ residual outliers (M1 Max has no bf16 simdgroup_matrix, so fp16 is the only
571
+ half); (5) backward to half only on a go; (6) re-sweep `BLOCK_B`/tiles —
572
+ half staging halves threadgroup memory, the constraint that capped the
573
+ current tiling. fp32 accumulate is non-negotiable; validate grads vs
574
+ autograd. Context measurement (M1 Max, MiniCPM5 seq 4096): `--no-segment`
575
+ ~1.25 s/step @9.26 GB vs `--seg 2` ~1.58 s/step @2.62 GB — bit-identical
576
+ output, ~20% faster for 3.5× peak.
577
+ - **Sequence-chunk the MLP sub-block** inside segments: MLP is per-token
578
+ independent, so its `d_ff` intermediate can drop from `O(T·d_ff)` to
579
+ `O(chunk·d_ff)` at ~zero recompute — the bigger MLP lever than `mlpSplit`.
580
+ - **Budget-aware segment planner** (rotor DP / min-cut): only worth building
581
+ with the corrected cost model (every layer O(L²) under `ops.sdpa`); today
582
+ `segment_size` is the whole knob.
583
+ - **Faster flash-attention backward**: simdgroup tiling, larger threadgroups,
584
+ FA2-style LSE-only storage — matters only where `ops.sdpa`'s O(L²) is the
585
+ blocker (≳16K). MLX's own `mx.fast.sdpa` source is the blueprint.
586
+ - **Fused LoRA epilogue** (§6) — benefits SFT/DPO/ORPO and adapter serving.
587
+ - **Shape-specialized compiled training graphs**: each row has concrete
588
+ `(P, Rc, Rr)`, so ORPO can compile exact or bucketed shapes keyed on model
589
+ checksum + flags + segment plan + chunk sizes, eager fallback for misses.
590
+ Custom Metal kernels stay outside compiled regions.
591
+ - **ORPO-grad folding inside the head kernel** (speed-only; autograd composes
592
+ the odds-ratio grad outside it today) and a head-LoRA `∇A/∇B` path if the
593
+ head ever becomes a target.
594
+ - Optimize the flash SDPA kernel with simdgroup_matrix, or beat `mx.fast.sdpa`
595
+ for our fixed head-dims / training-only backward / O(L) constraint —
596
+ research spike, not scheduled.
597
+
598
+ ### 8.2 Dynamic λ controller (proposed 2026-06-20, reframed 2026-07-01; not built)
599
+
600
+ Josh's framing: *λ is not a hyperparameter to tune but a process variable to
601
+ control* — push preference pressure to the maximum the model can bear
602
+ (self-discovering), retreat on confirmed coherence damage (self-stabilizing),
603
+ hold inside the healthy band. Motivation: the SFT/NLL half learns easily (val
604
+ loss falls while accuracy/margin barely move); over-weighting OR triggers
605
+ **likelihood displacement** (margin widened by crushing `logp(rejected)` and
606
+ sometimes `logp(chosen)`, cf. arXiv 2410.08847). Closest prior is β-DPO
607
+ (arXiv 2407.08639, per-batch calibration); the claim here would be closed-loop
608
+ trajectory control with a displacement-aware signal — the controller must beat
609
+ the *best constant* λ from a sweep, not λ=0.1.
610
+
611
+ - **Signals**: `lw` and `lr` separately (today `orpoMetrics` returns only
612
+ `loss/nll/or/accuracy/margin` — surfacing `lw`/`lr` is the load-bearing
613
+ instrumentation), on **val** each `stepsPerEval` (per-step B=1 train metrics
614
+ are too noisy). Control on the **response-only** `lw` — full-scope NLL mixes
615
+ prompt modeling into the level. Margin↑ via `lw`↑ is healthy; margin↑ via
616
+ `lr`↓ with `lw` flat/falling is displacement.
617
+ - **Law**: phase machine (SFT warmup at `LAMBDA_MIN` until `dLw` plateaus →
618
+ ramp `+RAMP` per eval while `dLw ≥ −EPS_DISP` → back off on displacement,
619
+ hysteresis over K evals; hard stop if `lw` falls below its start by a
620
+ threshold) — i.e. AIMD; generalize to P → PD → PID incrementally, each term
621
+ earning its place (D on a robust EMA slope for the leading indicator; I with
622
+ anti-windup for chronic offset). Bias toward pushing with a safety margin
623
+ below the cliff; the degenerate failure is collapsing to pure SFT.
624
+ - **Wiring**: λ becomes a mutable ref read per step (today it is a constant
625
+ threaded into `orpoLoss` and baked into the segmented classes' constructors
626
+ → add `setLambda`); controller lives in `orpoLoop` after each val eval;
627
+ gated `orpoLambdaSchedule: "static" | "adaptive"`, static = today exactly.
628
+ - **Validation**: same data/seed, Arm A static 0.1 vs Arm B adaptive; compare
629
+ val accuracy/margin and `lw` trajectories plus downstream win-rate; validate
630
+ the controller on the prefer-uppercase positive control first; set EPS from
631
+ observed val-`lw` noise, not hand-fit. Report both arms (adaptive is our
632
+ method, not "standard ORPO").
633
+
634
+ ### 8.3 Objective variants and data
635
+
636
+ Once the shared logp engine exists these are cheap siblings: **SimPO**
637
+ (`softplus(−β((lw − lr) − γ))`), **IPO**, **hinge/SLiC** margin loss, label
638
+ smoothing / per-example weights / loss clipping / ambiguity filtering. Data
639
+ quality likely beats objective cleverness on small runs: filter UltraFeedback
640
+ by score gap, bounded length ratio, semantic contrast, and instruction-following
641
+ categories if IFEval is the metric.
642
+
643
+ ## History
644
+
645
+ - 2026-06-15 — e4b LoRA enablement diagnosis: the wall is backward memory
646
+ (full-vocab logits + un-streamed activations), not attention; response-only
647
+ CE landed.
648
+ - 2026-06-16 — Segmented backward Phase A (MiniCPM5, bit-exact under flash,
649
+ 10.91 → 3.29 GB @2048) and Phase B (e4b, donor-KV + per-layer-input
650
+ threading, all 42 layers @8K where mlx-lm OOMs); surrogate `value_and_grad`
651
+ leak found, replaced by `mlx_vjp`; non-segmented SFT proven bit-exact vs
652
+ `mlx_lm.lora`.
653
+ - 2026-06-17 — ORPO landed as a method: loss math + tests, `orpoLoop`, job
654
+ submit, `/finetune` picker, e2e on MiniCPM5; `bitsMap` rank scaling wired;
655
+ dataset format renamed `preference`; flash SDPA kernel fixed to L2 parity.
656
+ - 2026-06-18 — Token-chunked head; fused linear-CE head; vocab-blocked pure-MLX
657
+ CCE (correct, unbounded, gated off); flash-CCE kernel v1 → vocab-parallel →
658
+ `BLOCK_B`; `SegmentedBackwardOrpo` + `SegmentedBackwardOrpoGemma4`; MiniCPM5
659
+ prefix-shared reference (forward bit-exact); LoRA dropout / rsLoRA / LoRA+;
660
+ `mlpSplit`.
661
+ - 2026-06-19 — Steel GEMM flash head fwd+bwd in production (e4b bwd 3687 → 754
662
+ ms, 0.93 GB flat); prefix-sharing wired and composed with the flash head and
663
+ segmented backward for MiniCPM5 and e4b; warm-start (`RESUME`);
664
+ `scripts/train-orpo.ts` launcher; W4A16 spike proposed; grad-accum A/B
665
+ deferred.
666
+ - 2026-06-20/23 — Dynamic λ controller proposed, PID/AIMD framing added.
667
+ - 2026-06-21/22 — Host-buffer pin-leak post-mortem (`fromView` → `fromBytesCopy`);
668
+ Lab gates: FD + teacher-forced fidelity replace "parity" for the no-oracle
669
+ head; coeff filter measured near-free on real data.
670
+ - 2026-07-01 — `sft_scope: full | response` (default `full`), implemented on
671
+ every ORPO path; dynamic-λ reframed as band control.
672
+ - 2026-07-02 — Apple-CCE skips default 1e-5 (1.71× / 3.16× backward);
673
+ teacher-forced fidelity regression test; full-attention isolation planner
674
+ refuted (every layer O(L²)); `segment_size` 1 = 14.59 GB @8K on e4b.
675
+ - 2026-07-05 — Fused GeGLU/SwiGLU and perf kernels deleted; no training flag
676
+ sanitization remains.
677
+ - 2026-08-23 — This doc consolidated `docs/design/orpo-training.md`,
678
+ `docs/design/orpo-training.md`, `docs/design/orpo-training.md`, and
679
+ `docs/design/orpo-training.md`; examples scripts moved to
680
+ `scripts/examples/`.