mlx-bun 0.0.4 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +166 -93
  2. package/docs/design/adapters-end-to-end.md +50 -46
  3. package/docs/design/compat-cli-surface-design.md +10 -8
  4. package/docs/design/docs-reorg-plan.md +14 -1
  5. package/docs/design/hlg-sampling.md +12 -2
  6. package/docs/design/memory-system.md +1 -1
  7. package/docs/design/optimization_plan.md +4 -2
  8. package/docs/design/orpo-future-enhancements.md +298 -0
  9. package/docs/design/orpo-training.md +1199 -0
  10. package/docs/design/parallel-slots.md +34 -26
  11. package/docs/design/runtime-isolation.md +1 -1
  12. package/docs/design/segmented-backward-training.md +58 -39
  13. package/docs/design/tauri-desktop-app.md +127 -0
  14. package/docs/{segmented-backward-handoff.md → investigations/segmented-backward-handoff.md} +1 -1
  15. package/docs/investigations/starter-model-port-handoff.md +32 -13
  16. package/docs/investigations/steel-flash-cce-handoff.md +431 -0
  17. package/docs/planning/IDEAS.md +29 -0
  18. package/docs/planning/PRODUCT_ROADMAP.md +147 -27
  19. package/docs/planning/chunk-task-roadmap.md +1 -1
  20. package/docs/planning/journal.md +161 -0
  21. package/docs/reference/distribution.md +61 -5
  22. package/docs/reference/library-api.md +29 -14
  23. package/docs/reference/orpo-quickstart.md +91 -0
  24. package/docs/reference/server-api.md +105 -7
  25. package/docs/reference/server-config.md +12 -5
  26. package/docs/reference/training.md +292 -0
  27. package/package.json +1 -1
  28. package/src/eval/ifeval.ts +138 -0
  29. package/src/eval/ultrafeedback.ts +60 -0
  30. package/src/lora.ts +12 -7
  31. package/src/mlx/metal-kernel.ts +18 -4
  32. package/src/model/flash-attention.ts +32 -16
  33. package/src/model/fused-geglu-kernel.ts +111 -0
  34. package/src/model/gemma4-base.ts +39 -1
  35. package/src/model/gemma4.ts +196 -21
  36. package/src/model/minicpm5.ts +44 -3
  37. package/src/server.ts +3 -3
  38. package/src/train/dataset.ts +18 -3
  39. package/src/train/flash-cce.ts +1162 -0
  40. package/src/train/job.ts +26 -2
  41. package/src/train/lora-params.ts +53 -4
  42. package/src/train/loss.ts +941 -0
  43. package/src/train/optimizer.ts +10 -3
  44. package/src/train/prefix-shared.ts +435 -0
  45. package/src/train/rank.ts +36 -0
  46. package/src/train/segmented.ts +1148 -3
  47. package/src/train/steel-qmm-header.ts +1461 -0
  48. package/src/train/trainer.ts +509 -52
  49. package/src/web/app.html +21 -5
  50. /package/{docs → src/assets}/curve-designer.html +0 -0
@@ -0,0 +1,91 @@
1
+ # ORPO training quickstart — the full stack, preconfigured
2
+
3
+ One launcher runs the complete new system — **flash-CCE Metal head** (steel fwd+bwd,
4
+ `[M,V]`-free) + **segmented backward** (gradient-checkpointed layer activations) +
5
+ **prefix-sharing** (one forward over `[prompt; chosen; rejected]`) + LoRA. Each feature
6
+ **falls back and logs** if a precondition isn't met (e.g. a row whose chosen/rejected
7
+ prompts aren't identical → two-forward for that row).
8
+
9
+ ## Command
10
+
11
+ ```bash
12
+ MODEL=/path/to/snapshot DATA=/path/to/datadir bun scripts/train-orpo.ts
13
+ ```
14
+ - `DATA` is a directory with `train.jsonl` (+ optional `valid.jsonl`); rows are
15
+ `{"prompt": "...", "chosen": "...", "rejected": "..."}`.
16
+ - The script **auto-detects e4b/Gemma** and sets its required training env flags
17
+ (`MLX_BUN_PERF_KERNEL=0`, `MLX_BUN_FUSED_GELU=0`) before loading the model — you don't
18
+ set them yourself.
19
+
20
+ ### Preconfigured defaults (override via env)
21
+ | env | default | meaning |
22
+ |---|---|---|
23
+ | `SEQ` | e4b 8192 · cpm 4096 | max sequence length |
24
+ | `SEG` | 2 | segment size (layers/segment); the validated e4b@8192 sweet spot |
25
+ | `ITERS` | 100 | iterations |
26
+ | `LR` | 1e-5 | learning rate (cosine + short warmup) |
27
+ | `RANK` / `SCALE` | 16 / 2.0 | LoRA rank (by_bits scaled) / scale |
28
+ | `LAMBDA` | 0.1 | ORPO odds-ratio weight |
29
+ | `FLASH=0` | (on) | use the MLX fused head instead of the flash Metal head |
30
+ | `PREFIX=0` | (on) | disable prefix-sharing (plain two-forward) |
31
+ | `SEGOFF=1` | (off) | disable the segmented backward (more memory) |
32
+ | `ADAPTER` | `~/.cache/mlx-bun/mlx-bun-finetunes/orpo-<model>` | output dir (the finetunes cache, not the repo) |
33
+
34
+ Example — e4b overnight at 8192:
35
+ ```bash
36
+ MODEL=~/.cache/huggingface/hub/models--mlx-community--gemma-4-e4b-it-OptiQ-4bit/snapshots/<snap> \
37
+ DATA=./data/mydpo ITERS=200 bun scripts/train-orpo.ts
38
+ ```
39
+
40
+ ## What to expect — performance (measured, M1 Max 32 GB)
41
+
42
+ ### e4b (Gemma4, H=2560, V=262144) — full stack
43
+ | seq | config | peak GB | s/step |
44
+ |---|---|---|---|
45
+ | 8192 | flash + segmented(2) + **prefix-share** (prompt-dominant data) | **13.3** | **~70** |
46
+ | 8192 | flash + segmented(2), no prefix | 16.1 | ~175 |
47
+ | 4096 | flash + segmented(2), no prefix | 11.7 | ~81 |
48
+ | 2048 | flash + segmented(2), no prefix | 10.2 | ~40 |
49
+ | 1024 | flash + segmented(2), no prefix | 8.7 | ~21 |
50
+
51
+ - **Fits comfortably in 32 GB at 8192** (the historical "e4b OOMs ≥2048" ceiling is gone).
52
+ - **Prefix-sharing is a real win when the prompt dominates** — at 8192 with a long shared
53
+ prompt it cut memory 16→13 GB *and* step time 175→70 s (prompt encoded once, not twice).
54
+ On response-dominant data the saving shrinks toward zero (and it falls back per-row when
55
+ prompts differ); it never hurts correctness.
56
+ - Memory is **linear in sequence length** (~+1.5 GB / 1024 tokens) — segmenting + flash
57
+ attention keep it linear, not quadratic. The head itself is **flat 0.93 GB** regardless
58
+ of vocab/seq.
59
+ - An ~8 h run at 8192 ≈ 100–400 steps depending on the prompt/response split.
60
+
61
+ ### MiniCPM5 (1B, H=1536, V=130560) — full stack
62
+ - Fits easily (~1–2 GB at short seq). ~0.4–1 s/step at seq 256; scales with seq.
63
+ - Everything composes (flash / segmented / prefix-share, in any combination).
64
+
65
+ ### The flash-CCE head in isolation (the piece that made large-vocab affordable)
66
+ - Forward 180 ms, backward **754 ms** (e4b) / 224 ms (cpm) — exact (dh 0.40% / 0.28%
67
+ bf16-class), `[M,V]`-free, peak **0.93 GB flat at M=8192**. The backward is **5× the old
68
+ hand-rolled kernel** (3687 ms) via the verbatim MLX steel GEMM + fused dequant.
69
+
70
+ ## Fallback behavior (logged)
71
+ - **Prefix-share, per row**: if `chosen`/`rejected` prompts aren't byte-identical →
72
+ two-forward for that row. The first occurrence logs `prefix-share: row prompt mismatch
73
+ → two-forward fallback`; the saving is lost only on mismatched rows.
74
+ - **`FLASH=0`** → the MLX fused linear-CE head (`quantizedMatmul` both ways; exact,
75
+ `[chunk,V]` transient) instead of the Metal kernel.
76
+ - **`SEGOFF=1`** → all layer activations resident (only viable at short seq on e4b).
77
+ - Models other than MiniCPM5 / Gemma4 with `orpoPrefixShared` error out (not silently).
78
+
79
+ ## Optional / experimental
80
+ - The Apple-CCE backward skips (`MLX_BUN_CCE_BWD_BLOCK_EPS=1e-5`, the lossless vocab-block
81
+ skip; `MLX_BUN_CCE_BWD_FILTER_EPS=1e-5`, the coeff filter) are **off by default** — on
82
+ the now-fast steel kernel they give little and the coeff filter costs gradient accuracy.
83
+ The block skip *can* help on long genuinely-peaked real text; A/B it on your data and
84
+ watch that the loss doesn't move.
85
+
86
+ ## Caveats (honest)
87
+ - The e4b numbers above are from **short probe runs** (2–3 steps); they confirm the
88
+ pipeline runs, fits memory, and produces finite/decreasing loss — **not** training
89
+ quality (that's your data + hyperparameters).
90
+ - e4b gradients are **bf16-class** (~1–3% vs an exact reference) — expected for the fused
91
+ bf16 path; MiniCPM5 is tighter (forward bit-exact, grads ~1%).
@@ -30,7 +30,18 @@ Request body (OpenAI chat schema; unknown fields ignored):
30
30
  "tools": [ /* OpenAI function tools */ ],
31
31
  "tool_choice": "auto", // "none" disables tools
32
32
  "chat_template_kwargs": { // forwarded to the chat template
33
- "enable_thinking": false // MiniCPM5: <think> channel on/off
33
+ "enable_thinking": false // MiniCPM5 / Qwen3.5: <think> channel on/off
34
+ },
35
+ "reasoning_effort": "medium", // "none"|"minimal"|"low"|"medium"|"high"
36
+ // gates enable_thinking on Qwen3.5/MiniCPM5:
37
+ // "none" → off, any other level → on.
38
+ // Overrides chat_template_kwargs.enable_thinking.
39
+ "hlg": { // HLG tone-curve sampling (per request).
40
+ "enabled": true, // merged over --hlg-sampling server defaults.
41
+ "width": 4, // logit-width of the tone plateau
42
+ "shoulder": 4, // top shoulder width
43
+ "toe": 6, // bottom toe width
44
+ "pivot_offset": 6 // pivot point offset from top
34
45
  },
35
46
  "adapter": "id" // LoRA: "id", stacked "a+b", or "none"
36
47
  }
@@ -92,12 +103,15 @@ messages; multi-turn prompt prefixes reuse the KV prompt cache
92
103
  automatically.
93
104
 
94
105
  Tool-call parsing is per model family. Gemma 4 uses its native
95
- `<|tool_call>`…`<tool_call|>` sentinel tokens. MiniCPM5 emits XML in
96
- decoded text (`<function name="…"><param name="…">…`, CDATA-wrapped
97
- values supported); content before the tool markup still streams live,
98
- only the markup is withheld and converted to `tool_calls`. Argument
99
- values are decoded against the tool's JSON schema (string-typed params
100
- stay strings); markup that fails to parse falls back to plain content.
106
+ `<|tool_call>`…`<tool_call|>` sentinel tokens. Qwen3.5 emits decoded
107
+ text wrapped in `<tool_call><function=name><parameter=key>value` XML
108
+ equals-style blocks. MiniCPM5 emits XML in decoded text
109
+ (`<function name="…"><param name="…">…`, CDATA-wrapped values
110
+ supported). For both Qwen3.5 and MiniCPM5 content before the tool
111
+ markup still streams live, only the markup is withheld and converted to
112
+ `tool_calls`. Argument values are decoded against the tool's JSON schema
113
+ (string-typed params stay strings); markup that fails to parse falls
114
+ back to plain content.
101
115
 
102
116
  ### Errors
103
117
 
@@ -191,6 +205,90 @@ OpenAI SDK speak this now). Oracle: optiq responses shim.
191
205
  }
192
206
  ```
193
207
 
208
+ ## GET /library
209
+
210
+ Returns all models found in the local HuggingFace hub cache (via the
211
+ registry scan), each annotated with a fit assessment for this machine.
212
+ Response is cached for 30 seconds (registry scan + config reads; no
213
+ tensor bytes are read).
214
+
215
+ ```jsonc
216
+ {
217
+ "models": [{
218
+ "repo_id": "…",
219
+ "model_type": "gemma3" | "minicpm5" | "qwen3" | …,
220
+ "size_bytes": 0,
221
+ "quant_bits": 4,
222
+ "vision": false, // has vision sidecar
223
+ "supported": true, // recognized model family
224
+ "serving": false, // currently loaded in this server
225
+ "assessment": { // null if config unreadable
226
+ "fits": true,
227
+ "max_safe_context": 8192,
228
+ "predicted_decode_tps": 0.0
229
+ }
230
+ }]
231
+ }
232
+ ```
233
+
234
+ ## GET /downloads
235
+
236
+ Snapshot of the last 5 model downloads (active, completed, or errored)
237
+ initiated via `mlx-bun download` or the web library panel.
238
+
239
+ ```jsonc
240
+ {
241
+ "downloads": [{
242
+ "repo_id": "…",
243
+ "state": "active" | "done" | "error",
244
+ "current_file": "model.safetensors" | null,
245
+ "received_bytes": 0,
246
+ "total_bytes": 0,
247
+ "files_done": 0,
248
+ "files_total": 0,
249
+ "bytes_per_sec": 0, // rolling ~5 s window
250
+ "started_at": 1760000000,
251
+ "finished_at": null,
252
+ "error": "…" // present on state "error"
253
+ }]
254
+ }
255
+ ```
256
+
257
+ ## GET /fit
258
+
259
+ Fit assessment for the loaded model on this machine, plus a capability
260
+ matrix across Apple SKUs. Used by the status page. Experts bytes come
261
+ from the registry so MoE active-parameter predictions match the serve
262
+ banner and `mlx-bun fit`. When the eval DB has a real measured decode
263
+ rate for this model snapshot, it is included and takes precedence over
264
+ the prediction.
265
+
266
+ ```jsonc
267
+ {
268
+ "machine": { "chip": "M4 Pro", "ram_bytes": 0, "bandwidth_gbs": 0.0 },
269
+ "context_tokens": 8192, // current admission ceiling
270
+ "typical_context_tokens": 8192, // min(8192, context_tokens)
271
+ "typical_decode_tps": 0.0, // predicted at typical_context_tokens
272
+ "measured_decode_tps": null, // real number from eval DB, or null
273
+ "measured_at": null, // unix ms of measurement, or null
274
+ "report": {
275
+ "fits": true,
276
+ "weights_bytes": 0,
277
+ "kv_bytes": 0,
278
+ "transient_bytes": 0,
279
+ "total_bytes": 0,
280
+ "usable_bytes": 0,
281
+ "max_safe_context": 8192,
282
+ "predicted_decode_tps": 0.0
283
+ },
284
+ "sku_matrix_ctx": 32768,
285
+ "sku_matrix": [{
286
+ "sku": "M4 Pro 24 GB", "ram_gb": 24,
287
+ "fits": true, "max_context": 32768, "decode_tps": 0.0
288
+ }]
289
+ }
290
+ ```
291
+
194
292
  ## Adapters (LoRA hot-swap)
195
293
 
196
294
  - `GET /v1/adapters` — `{ adapters: [{ id, path, rank, scale, size_bytes, mounted_layers }] }`
@@ -25,11 +25,18 @@ the design rationale is in
25
25
  | `--prompt-cache` | GB | `2` (≈2e9 bytes) | Byte cap for the prompt (prefix-reuse KV) cache. **Binary GiB (×2³⁰)** on input. `0`/unset ⇒ default; there is no flag to disable the cache outright. |
26
26
  | `--batch` | n | `1` (serial) | Max concurrent requests batched through the mlx-lm-parity engine. `>1` switches the **whole server** into bf16 continuous batching — a *mode*, not a load fallback. See [Execution modes](#execution-modes-serial-vs---batch-n). Alias: `--decode-concurrency` (mlx_lm.server drop-in). |
27
27
  | `--kv-quant` | `config`\|`off`\|`4`\|`8` | `config` serial / `off`(bf16) under `--batch N` | KV-cache quantization. `config` = per-layer `kv_config.json` (optiq parity); `off` = bf16; `4`/`8` = uniform bits (group 64, start 0). Under `--batch N`, an explicit value routes those requests to the **serial** lane (batched is bf16-only). |
28
- | `--thinking` | `true`\|`false` | model's own (false for CPM) | Server-wide default for the chat template's `enable_thinking` (MiniCPM5/CPM hybrid reasoning). A request's `chat_template_kwargs.enable_thinking` overrides it. |
28
+ | `--thinking` | `true`\|`false` | model's own (false for CPM) | Server-wide default for the chat template's `enable_thinking` (MiniCPM5/CPM and Qwen3.5 hybrid reasoning). A request's `chat_template_kwargs.enable_thinking` overrides it. |
29
29
  | `--temperature` | n ∈ [0,5] | `generation_config.json` | Server-wide sampling default. Per-request `temperature` still wins; the browser chat (sends none) inherits this. |
30
30
  | `--top-p` | n ∈ [0,1] | `generation_config.json` | Server-wide top-p default (per-request `top_p` wins). |
31
31
  | `--top-k` | n ∈ [0,1e6] | `generation_config.json` | Server-wide top-k default (per-request `top_k` wins). |
32
- | `--compiled-decode` | on\|off | on | Replay the per-step decode graph in C++ (`MLX_BUN_COMPILED_DECODE`). Bit-exact A/B lever. **Serial lane only** (see note below). |
32
+ | `--no-open` | (bool) | off | Skip the automatic browser open on start. By default an interactive terminal session opens `http://<host>:<port>/#/chat` once the server is ready; pass this flag to suppress it (e.g. headless or non-TTY environments already skip it). |
33
+ | `--hlg-sampling` | `on`\|`off` | off | Piecewise tone-curve (HLG) sampling: rolls off the top-token region, boosts the mids, gentles the tail. The overall gain folds from `--temperature`. See [docs/design/hlg-sampling.md](../design/hlg-sampling.md). |
34
+ | `--hlg-width` | nats | `4` | HLG mid-region half-width (nats). Only meaningful with `--hlg-sampling on`. |
35
+ | `--hlg-shoulder` | nats | `4` | HLG highlight rolloff scale (nats). Only meaningful with `--hlg-sampling on`. |
36
+ | `--hlg-toe` | nats | `6` | HLG shadow rolloff scale (nats). Only meaningful with `--hlg-sampling on`. |
37
+ | `--hlg-pivot-offset` | nats | `6` | HLG pivot point: nats below the top token. Only meaningful with `--hlg-sampling on`. |
38
+ | `--expert-offload` | (bool) | off | **MoE models only.** Serve experts from a page-aligned file mmap (built on first use). Keeps the model out of memory pressure — physical footprint ≈ active params. Ignored with a warning on dense models. Bit-exact with the resident path. |
39
+ | `--compiled-decode` | on\|off | on | Replay the per-step decode graph in C++ (`MLX_BUN_COMPILED_DECODE`). Bit-exact A/B lever. **Serial lane only** (see note below). **Gemma4-dense only** — LoRA, MoE, and non-Gemma4 models (MiniCPM5 / Qwen3.5) run eager; an unsupported step falls back to eager for the rest of that generation. |
33
40
  | `--perf-kernel` | on\|off | **on** | Fused quantized-KV decode-SDPA Metal kernel (`MLX_BUN_PERF_KERNEL`), the perf side of the compat A/B. Engages on quantized caches at decode. **Serial lane only.** |
34
41
  | `--fused-decode` | on\|off | off | Experimental: tile the quantized decode SDPA (`MLX_BUN_FUSED_DECODE`). **Serial lane only.** |
35
42
  | `--fused-sdpa` | on\|off | on | Fused SDPA path for quantized prefill/continuation (inverted env `MLX_BUN_NO_FUSED_SDPA`). **Serial lane only.** |
@@ -40,7 +47,7 @@ winners; flip them to compare. They are set as `MLX_BUN_*` env vars
40
47
  before the model loads, so they apply to `mlx-bun pi` too. They affect
41
48
  the **serial** decode path (`generate()`); the batched scheduler calls
42
49
  `model.forwardHidden` directly and so is unaffected by all of them — see
43
- [Levers that don't reach the batched lane](#levers-that-dont-reach-the-batched-lane).
50
+ [Levers that don't reach the batched lane](#--batch-n-is-compat-mode--perf-flags-dont-apply-by-design).
44
51
 
45
52
  > **Note — `--perf-kernel` default.** The code defaults it **on**
46
53
  > (`perfKernelEnabled()` returns true unless `MLX_BUN_PERF_KERNEL=0`, and
@@ -129,7 +136,7 @@ concurrently with each other.
129
136
  | `--thinking` / `enable_thinking` | ✅ batches (template-render concern, lane-independent) |
130
137
  | multi-turn / long prompt | ✅ batches, but **no prompt-cache reuse** (`cached_tokens=0`) |
131
138
 
132
- Both full-attention (CPM) and sliding-window (Gemma) models batch; the
139
+ All three model families — full-attention (CPM), sliding-window (Gemma), and hybrid gated-DeltaNet (Qwen3.5) — batch; the
133
140
  scheduler assembles each layer's cache by attention type.
134
141
 
135
142
  ## Compatibility matrix
@@ -216,7 +223,7 @@ The live config and batch state:
216
223
 
217
224
  ```jsonc
218
225
  {
219
- "server": { "owner": "serve", "model": "...", "started_at": 0 },
226
+ "server": { "owner": "serve" | "pi-session" | "embedded", "model": "...", "started_at": 0 },
220
227
  "prompt_cache": { "entries": 0, "bytes": 0, "max_bytes": 2000000000, "hits": 0, "misses": 0 },
221
228
  "response_store": { "entries": 0, "bytes": 0, "max_bytes": 33554432, "ttl_ms": 3600000 },
222
229
  "kv_quant": {
@@ -0,0 +1,292 @@
1
+ # Training / fine-tuning reference
2
+
3
+ How to fine-tune a served model with LoRA adapters: the entry points, the
4
+ data formats, every config flag, and the methodology behind the knobs.
5
+
6
+ mlx-bun trains **LoRA adapters** (the base quantized weights stay frozen),
7
+ supports **SFT**, **DPO**, and **ORPO**, and runs on a single Apple-Silicon GPU. The
8
+ output is a PEFT-compatible adapter you hot-swap into the server — see
9
+ [adapters-end-to-end](../design/adapters-end-to-end.md) for the serving side
10
+ and [segmented-backward-training](../design/segmented-backward-training.md)
11
+ for the long-context memory mechanism.
12
+
13
+ > Source of truth: the config schema is `FinetuneSubmit` in
14
+ > [`src/train/job.ts`](../../src/train/job.ts); defaults are
15
+ > `DEFAULT_TRAIN_CONFIG` in [`src/train/trainer.ts`](../../src/train/trainer.ts).
16
+ > This doc is generated against those — if they drift, the code wins.
17
+
18
+ ## Entry points
19
+
20
+ There is **no `mlx-bun train` CLI verb.** Training runs as a subprocess job,
21
+ reachable four ways:
22
+
23
+ | Path | How | Use when |
24
+ |---|---|---|
25
+ | **Web UI** | `mlx-bun serve`, open `/finetune` — pick model → dataset → hyperparameters → train; watch live train/val loss; merge/export the adapter | Interactive, the default |
26
+ | **HTTP API** | `POST /api/finetune/submit` (job id + SSE events); `POST /api/finetune/inspect-dataset` to probe a file; `POST /api/finetune/merge` to fold an adapter into base weights | Scripted / remote |
27
+ | **Script** | [`scripts/chunk-finetune.ts`](../../scripts/chunk-finetune.ts) — an env-driven wrapper that calls the runner directly | Repeatable CLI runs |
28
+ | **Shell recipe** | [`scripts/ft-e4b-v2.sh`](../../scripts/ft-e4b-v2.sh) `probe`\|`train` — the actual e4b run we use; sets the required env (see [What we actually run](#what-we-actually-run-the-e4b-recipe)) | Reproducing our e4b fine-tune |
29
+ | **ORPO launcher** | [`scripts/train-orpo.ts`](../../scripts/train-orpo.ts) — preconfigured ORPO with the **full stack on by default** (flash-CCE head + segmented backward + prefix-sharing); auto-detects e4b and sets its env; `RESUME=<dir>` warm-starts. See [orpo-quickstart](./orpo-quickstart.md) | Running an ORPO fine-tune |
30
+ | **Library** | `import { finetuneRunner } from "./src/train/job"` and call it with a config + emitter | Embedding training in your own TS |
31
+
32
+ ### Quick start (HTTP API)
33
+
34
+ ```bash
35
+ curl -s localhost:8090/api/finetune/submit -X POST -H 'content-type: application/json' -d '{
36
+ "model_dir": "/path/to/snapshot",
37
+ "data_dir": "/path/to/dataset", // dir with train.jsonl (+ optional valid.jsonl)
38
+ "adapter_path": "/path/to/output-adapter",
39
+ "method": "sft",
40
+ "rank": 16, "iters": 300, "learning_rate": 2e-4, "max_seq_length": 2048
41
+ }'
42
+ # → { "jobId": "..." } then stream events from the jobs SSE endpoint
43
+ ```
44
+
45
+ `model_dir`, `data_dir`, and `adapter_path` are **required**; everything else
46
+ falls back to the defaults below.
47
+
48
+ ### Quick start (script)
49
+
50
+ [`scripts/chunk-finetune.ts`](../../scripts/chunk-finetune.ts) is the worked
51
+ example (MiniCPM5 on the chunking task). It calls `finetuneRunner` directly,
52
+ driven by env vars:
53
+
54
+ ```bash
55
+ # MODEL unset → defaults to the MiniCPM5-1B-OptiQ-4bit snapshot
56
+ DATA=/path/to/chunk ITERS=300 RANK=16 SEQ=2048 SEG=4 \
57
+ bun scripts/chunk-finetune.ts
58
+ ```
59
+
60
+ Env knobs — note the script applies its **own task-tuned defaults**, which
61
+ differ from the trainer/API defaults in the table below:
62
+
63
+ | Env | Maps to | Script default |
64
+ |---|---|---|
65
+ | `MODEL` | `model_dir` | MiniCPM5-1B-OptiQ-4bit snapshot (a **path** if set, not a name) |
66
+ | `DATA` | `data_dir` | the lucien chunk dataset path |
67
+ | `SEQ` | `max_seq_length` | `8192` |
68
+ | `ITERS` | `iters` | `2` (probe; use 300 for a real run) |
69
+ | `RANK` | `rank` | `16` |
70
+ | `LR` | `learning_rate` | `1e-5` |
71
+ | `SCALE` | `scale` | `20` |
72
+ | `SEG` | `segment_size` | `0` (off) |
73
+ | `EVAL_EVERY` | `steps_per_eval` | auto from `ITERS` |
74
+ | `ADAPTER` | `adapter_path` | `~/.cache/mlx-bun-finetunes/minicpm5-chunk-seq<SEQ>` |
75
+ | `CKPT` | `save_checkpoints` | on (`CKPT=0` disables) |
76
+ | `GRAD_CKPT` | `grad_checkpoint` | off (`GRAD_CKPT=1` enables) |
77
+
78
+ The script hard-codes `method=sft`, `batch_size=1`, `steps_per_report=1`, and
79
+ uses the default `ops.sdpa` training attention (set `MLX_BUN_TRAIN_ATTN=flash`
80
+ to override — but flash crashes e4b at multi-K; see Methodology).
81
+
82
+ ## Data formats
83
+
84
+ Each row of `train.jsonl` (and optional `valid.jsonl`) is auto-detected by its
85
+ keys ([`src/train/dataset.ts`](../../src/train/dataset.ts)):
86
+
87
+ | Format | Shape | Loss boundary |
88
+ |---|---|---|
89
+ | `messages` | `{"messages": [{"role","content"}, …]}` | response-only — loss on the final turn, prompt = chat-template render of all prior turns |
90
+ | `prompt-completion` | `{"prompt": "...", "completion": "..."}` | loss on the completion only |
91
+ | `text` | `{"text": "..."}` | full-sequence (no prompt mask) |
92
+ | `dpo` *(method=dpo or orpo)* | `{"prompt", "chosen", "rejected"}` | preference loss on chosen vs rejected (the same preference format serves both DPO and ORPO) |
93
+
94
+ Probe a file before submitting: `POST /api/finetune/inspect-dataset` with
95
+ `{"path": "..."}` returns `{ ok, n_train, n_valid, format }`.
96
+
97
+ ## SFT vs DPO vs ORPO
98
+
99
+ - **SFT** (`method: "sft"`, default) — supervised fine-tune; response-only
100
+ cross-entropy. Default LR `2e-4`. For instruction-following, formatting,
101
+ task adaptation.
102
+ - **DPO** (`method: "dpo"`) — Direct Preference Optimization on
103
+ chosen/rejected pairs; loss `-log σ(β·((π_c − ref_c) − (π_r − ref_r)))`
104
+ with reference log-probs computed at LoRA scale 0. Default LR `5e-5`. Tune
105
+ with `dpo_beta`, `dpo_warmup_iters`, `dpo_lr_schedule`.
106
+ - **ORPO** (`method: "orpo"`) — Odds Ratio Preference Optimization (Hong et
107
+ al. 2024): a **reference-free** monolithic objective combining the SFT term
108
+ and a preference term, `L = L_NLL(chosen) + λ·L_OR`, with
109
+ `L_OR = -log σ(log_odds)` and
110
+ `log_odds = (ℓ_w − ℓ_r) − (log1mexp(ℓ_w) − log1mexp(ℓ_r))`, where `ℓ` is the
111
+ **length-normalized** (mean over response tokens) log-prob. Uses the same
112
+ `{prompt, chosen, rejected}` preference data as DPO but needs **no reference
113
+ model** (2 forwards/step vs DPO's 4). Default LR `1e-5` (lower than DPO — the
114
+ loss carries a full SFT NLL term a high LR destabilizes). Tune with
115
+ `orpo_lambda`, `orpo_warmup_iters`, `orpo_lr_schedule`. Design + optimization
116
+ roadmap: [orpo-training](../design/orpo-training.md).
117
+
118
+ The ORPO head + long-context machinery compose into one stack (all B=1), each
119
+ piece independently toggled and each falling back cleanly:
120
+
121
+ - **`orpo_flash_ce`** — the [M,vocab]-free flash-CCE Metal head (steel GEMM fwd
122
+ **and** bwd; e4b backward 754 ms, peak 0.93 GB flat at M=8192). Implies the
123
+ fused head. Falls back to the MLX fused head when the tiling isn't clean.
124
+ - **`segment_size`** — segmented backward (gradient-checkpointed layer
125
+ activations) for long context; the head is ~free in memory, the activations
126
+ are the wall.
127
+ - **`orpo_prefix_shared`** — encode the shared prompt once; composes with both
128
+ the flash head and the segmented backward (MiniCPM5 **and** e4b/Gemma4). Per-row
129
+ two-forward fallback when chosen/rejected prompts differ.
130
+ - **warm-start** — `scripts/train-orpo.ts RESUME=<adapter-dir>` continues from a
131
+ checkpoint's weights (optimizer + LR schedule restart).
132
+
133
+ The one-command way to run all of it is the **ORPO launcher**
134
+ ([`scripts/train-orpo.ts`](../../scripts/train-orpo.ts)) — see
135
+ [orpo-quickstart](./orpo-quickstart.md) for the command, the per-model defaults,
136
+ and the measured memory/throughput (e.g. e4b full stack @ 8192 ≈ 13 GB,
137
+ ~70 s/step on the M1 Max dev box). Run long jobs detached from your shell
138
+ (`nohup … &`), not as a session-spawned background task.
139
+
140
+ ## Configuration reference
141
+
142
+ All fields optional except `model_dir` / `data_dir` / `adapter_path`. Defaults
143
+ are `DEFAULT_TRAIN_CONFIG` (trainer.ts:89).
144
+
145
+ | Field (API) | Type | Default | Effect |
146
+ |---|---|---|---|
147
+ | `method` | `sft` \| `dpo` \| `orpo` | `sft` | Training objective (see above) |
148
+ | `rank` | int ≥2 | `8` | LoRA rank per adapted linear |
149
+ | `scale` | float >0 | `1.0` | LoRA α (effective update = α·BA) |
150
+ | `rank_scaling` | `constant` \| `by_bits` \| `by_kl` | `by_bits` | Per-layer rank policy (see Methodology) |
151
+ | `target_modules` | string[] | `q,k,v,o,gate,up,down _proj` | Which linears get adapters |
152
+ | `num_layers` | int | `-1` | `-1` = all layers; `N` = last N only |
153
+ | `iters` | int >0 | `100` | Total training steps |
154
+ | `learning_rate` | float >0 | `2e-4` (sft) / `5e-5` (dpo) | AdamW LR |
155
+ | `max_seq_length` | int >0 | `512` | Truncate/pad sequences to this |
156
+ | `batch_size` | int ≥1 | `1` | Rows per step (B=1 is the safe path; B>1 length-sorts + pads to 32) |
157
+ | `grad_accumulation_steps` | int ≥1 | `1` | Accumulate grads over N micro-steps |
158
+ | `seed` | int | `0` | RNG for shuffling + LoRA init |
159
+ | `steps_per_report` | int >0 | `10` | Emit a train-loss metric every N steps |
160
+ | `steps_per_eval` | int >0 | `50` | Eval on `valid.jsonl` every N steps |
161
+ | `weight_decay` | float ≥0 | `0.01` | AdamW weight decay (β = `[0.9, 0.999]`, fixed) |
162
+ | `lora_dropout` | float [0,1) | `0.0` | LoRA-input dropout (PEFT-style), training-only regularizer for small sets. Recompute-safe (mask keyed by step+layer, so segmented/checkpointed backward reproduces it) |
163
+ | `rs_lora` | bool | `false` | rsLoRA — scale the update by α/√rank instead of α, so `rank_scaling` changes capacity not step size. Recorded in the adapter config; inference applies the same per-layer scale |
164
+ | `lora_plus_ratio` | float ≥1 | `1.0` | LoRA+ — LR multiplier for the B leaves (A stays at base LR). >1 speeds the B-driven early learning (B is zero-init). 1 = off. Wired in the ORPO loop |
165
+ | `grad_checkpoint` | bool | `false` | Recompute layer activations in backward (memory↔compute; bit-identical) |
166
+ | `segment_size` | int | `0` (off) | `>0` enables segmented backward — layers per segment (see below) |
167
+ | `save_checkpoints` | bool | `false` | Save every eval-step checkpoint + write `metrics.json` |
168
+ | `dpo_beta` | float >0 | `0.1` | DPO strength (dpo only) |
169
+ | `dpo_warmup_iters` | int ≥0 | `0` | DPO LR warmup (dpo only) |
170
+ | `dpo_lr_schedule` | `constant` \| `cosine` | `cosine` | DPO LR schedule (dpo only) |
171
+ | `orpo_lambda` | float >0 | `0.1` | ORPO λ — weights **only** the odds-ratio term; the SFT-NLL term stays unweighted (orpo only) |
172
+ | `orpo_warmup_iters` | int ≥0 | `0` | ORPO LR warmup (orpo only) |
173
+ | `orpo_lr_schedule` | `constant` \| `cosine` | `cosine` | ORPO LR schedule (orpo only) |
174
+ | `orpo_chunk_size` | int | `0` (off) | Token-chunk the response head, bounding the `[M,vocab]` logits to `[chunk,vocab]` (defaults to 512 when a fused head is on). Exact (orpo only) |
175
+ | `orpo_fused_ce` | bool | `false` | Fused linear-CE head: one CustomVjp with an analytic softmax−onehot backward (MLX `quantizedMatmul` both ways) — no autograd through the head, no retained `[M,vocab]`. Exact; `[chunk,vocab]` transient (orpo only) |
176
+ | `orpo_flash_ce` | bool | `false` | Route the fused head through the **flash-CCE Metal kernel** (verbatim MLX steel GEMM + ORPO epilogue, fwd **and** bwd): neither `[M,vocab]` nor a dequantized head touches HBM → `[M,vocab]`-free, fastest on large vocab (e4b bwd 754 ms, 0.93 GB flat @ M=8192). Implies the fused head. The Apple-CCE coeff filter / blockMax skip are opt-in via `MLX_BUN_CCE_BWD_FILTER_EPS` / `_BLOCK_EPS` (default off — exact). B=1 (orpo only) |
177
+ | `orpo_prefix_shared` | bool | `false` | Shared prompt-prefix: one forward over `[prompt; chosen; rejected]` with a block-sparse mask + block-wise RoPE, so the shared prompt is encoded **once** (a big win when the prompt dominates). Falls back to two-forward per row on prompt mismatch. Composes with the flash head and the segmented backward (MiniCPM5 + e4b). B=1 (orpo only) |
178
+
179
+ ### Environment variables (training)
180
+
181
+ | Env var | Set for training | Default | Why |
182
+ |---|---|---|---|
183
+ | `MLX_BUN_FUSED_GELU` | **`0` (required for Gemma)** | on | The fused GeGLU is a **CustomKernel with no gradient (vjp)** ([`fused-geglu-kernel.ts`](../../src/model/fused-geglu-kernel.ts)); the Gemma forward uses it ([`gemma4.ts:277`](../../src/model/gemma4.ts)), so a Gemma (e4b/12B/26B) backward fails unless it's off. MiniCPM5 (Llama-arch SwiGLU) never hits it, so the `.ts` script's MiniCPM5 default doesn't need it. |
184
+ | `MLX_BUN_PERF_KERNEL` | **`0` for training** | on | The fused quantized-decode kernel likewise has no vjp. It only fires at decode L=1 (rare in the L>1 training forward), but the e4b recipe sets `0` to be safe. |
185
+ | `MLX_BUN_TRAIN_ATTN` | **leave unset** | unset → `ops.sdpa` | Default `ops.sdpa` **is** mlx's fused flash-attention kernel — the correct, working path. `flash` selects a *different* hand-rolled custom kernel that crashes e4b at multi-K; do not set it. |
186
+ | `MLX_BUN_MEM_LOG` | `1` to profile | off | Print per-step peak/active/cache memory |
187
+
188
+ > Important: the trainer itself ([`trainer.ts`](../../src/train/trainer.ts))
189
+ > only reads `MLX_BUN_TRAIN_ATTN` and `MLX_BUN_MEM_LOG` — but the **model
190
+ > forward** it runs reads `MLX_BUN_FUSED_GELU` / `MLX_BUN_PERF_KERNEL`. The
191
+ > trainer does **not** disable those itself, so **the caller must** export
192
+ > them. The [e4b recipe](#what-we-actually-run-the-e4b-recipe) does this; if
193
+ > you train a Gemma model by hand, set `MLX_BUN_FUSED_GELU=0` yourself.
194
+
195
+ ## What we actually run (the e4b recipe)
196
+
197
+ Everything above is the full surface (*what you can do*). In practice the
198
+ fine-tune we run is [`scripts/ft-e4b-v2.sh`](../../scripts/ft-e4b-v2.sh):
199
+ e4b (gemma-4-e4b-it-OptiQ-4bit, pinned snapshot) on the lucien `chunk-v2-500`
200
+ curated set (450 train convs) through the segmented-backward trainer. It wraps
201
+ `chunk-finetune.ts` with the e4b-required env and a two-step workflow:
202
+
203
+ ```bash
204
+ scripts/ft-e4b-v2.sh probe # 2-iter memory/stability check (~1 min) — RUN FIRST
205
+ scripts/ft-e4b-v2.sh train # the real run (~900 iters ≈ 2 epochs, batch_size 1)
206
+ ITERS=750 SEQ=4096 SEG=1 scripts/ft-e4b-v2.sh train # override any knob inline
207
+ ```
208
+
209
+ What the recipe pins (and why it differs from the bare defaults):
210
+
211
+ | Knob | Recipe value | Why |
212
+ |---|---|---|
213
+ | model | e4b OptiQ-4bit, pinned snapshot `fcdb12d7…` | the validated e4b snapshot |
214
+ | data | `chunk-v2-500` (450 train convs) | the curated chunk set |
215
+ | `SEQ` | `8192` | long context |
216
+ | `SEG` | `4` | **segmented backward**, 4 layers/segment — so 8K-ctx activations fit |
217
+ | `RANK` / `SCALE` / `LR` | `16` / `20` / `1e-5` | task-tuned |
218
+ | `ITERS` | `2` (probe) / `900` (train) | ~2 epochs over 450 examples |
219
+ | `MLX_BUN_PERF_KERNEL` / `MLX_BUN_FUSED_GELU` | `0` / `0` | **required** — the fused kernels have no vjp (see env table) |
220
+ | attention | default `ops.sdpa` | mlx's fused flash kernel; **not** `MLX_BUN_TRAIN_ATTN=flash` (that one crashes e4b) |
221
+
222
+ The two non-negotiables for e4b: **segmented backward** (`SEG>0`, so the
223
+ long-context activations fit) and the **fused kernels off** (so the backward
224
+ has gradients). Always run `probe` before `train`.
225
+
226
+ ## Methodology
227
+
228
+ ### LoRA
229
+ Adapters attach to the target linears; A is initialized uniform, B is zeros,
230
+ so the adapted model equals the base model at step 0. Only A/B are
231
+ differentiated — base quantized weights are frozen. Default targets are the 7
232
+ attention+MLP projections per block (`q/k/v/o_proj`, `gate/up/down_proj`),
233
+ following Unsloth. See [`src/train/lora-params.ts`](../../src/train/lora-params.ts).
234
+
235
+ ### Rank scaling (`rank_scaling`)
236
+ - `constant` — every target gets `rank`.
237
+ - `by_bits` *(default)* — `rank × (bits / 4)`, clamped ≥2; gives **wider
238
+ adapters to the more-sensitive (higher-bit) layers** (mixed precision: a
239
+ 4-bit layer gets the base rank, an 8-bit layer 2×). This is optiq's "one
240
+ sensitivity signal, two optimizations" — the same KL pass that assigns the
241
+ bits also sets the rank. The per-layer bits are read straight from the loaded
242
+ model's quant specs (auto-wired; no metadata file needed).
243
+ - `by_kl` — scales by per-layer KL importance, clamped to [0.5×, 2×]; reads the
244
+ KL values from `optiq_metadata.json` and falls back to `by_bits` if none are
245
+ recorded.
246
+
247
+ ### Long-context memory: segmented backward vs gradient checkpointing
248
+ At long `max_seq_length`, activation memory dominates. Two levers:
249
+
250
+ - `grad_checkpoint: true` — recompute each layer's activations during
251
+ backward. Bit-identical; trades compute for memory.
252
+ - `segment_size: N` — **segmented backward**: run the layer stack forward
253
+ detaching the residual stream into graph-free boundary leaves every N
254
+ layers, then backprop segment-by-segment via `mlx_vjp` (cotangent passed
255
+ directly, *not* a surrogate-loss `value_and_grad`, which leaked). Only one
256
+ segment's activations live at a time. This is the path to multi-K context;
257
+ full mechanism, proofs, and measured peaks (e.g. MiniCPM5 10.91→3.29 GB
258
+ @2048) are in
259
+ [segmented-backward-training](../design/segmented-backward-training.md).
260
+
261
+ ### Training attention kernel (`MLX_BUN_TRAIN_ATTN`)
262
+ - **default `ops.sdpa`** — mlx's fused SDPA; correct (0.00% vs autograd),
263
+ O(L²) backward memory. Use this.
264
+ - **`flash`** — opt-in O(L) memory path, but the hand-rolled dK kernel is
265
+ slow and **crashes e4b at multi-K (≥2K)**; do not use it for e4b LoRA
266
+ training. Detail in segmented-backward-training §6.
267
+
268
+ ## Outputs
269
+
270
+ A finished run writes a PEFT-compatible adapter directory:
271
+
272
+ - `adapters.safetensors` — the `lora_a` / `lora_b` tensors
273
+ - `optiq_lora_config.json` — mlx-bun/optiq adapter metadata (per-layer ranks)
274
+ - `adapter_config.json` — PEFT-compatible config
275
+
276
+ When `save_checkpoints: true`, each eval step also writes
277
+ `checkpoints/step-<NNNNN>-val<loss>/` and a durable `metrics.json` (config,
278
+ wall seconds, peak GB, final/best train+val loss, full val trajectory).
279
+
280
+ **Serving the adapter:** hot-swap it into a running server via the adapter
281
+ API and select it per-request — see
282
+ [adapters-end-to-end](../design/adapters-end-to-end.md) and the adapter
283
+ endpoints in [server-api](server-api.md). Or fold it into the base weights
284
+ with `POST /api/finetune/merge`.
285
+
286
+ ## Memory & performance tips
287
+
288
+ - Start at `batch_size: 1` (the no-padding path); raise only with headroom.
289
+ - OOM → lower `max_seq_length`, set `segment_size` (e.g. 2–4), or reduce
290
+ `rank` / `num_layers`.
291
+ - Set `MLX_BUN_MEM_LOG=1` to watch per-step peak memory.
292
+ - `grad_accumulation_steps` raises effective batch without the memory cost.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mlx-bun",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Native MLX inference for Bun on Apple Silicon — local LLM server (OpenAI/Anthropic-compatible) and TypeScript library. No Python.",
5
5
  "type": "module",
6
6
  "license": "MIT",