mlx-bun 0.0.4 → 0.0.6

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 (90) hide show
  1. package/README.md +175 -93
  2. package/docs/dag/training-inference-map.html +575 -0
  3. package/docs/design/adapters-end-to-end.md +50 -46
  4. package/docs/design/compat-cli-surface-design.md +10 -8
  5. package/docs/design/docs-reorg-plan.md +14 -1
  6. package/docs/design/hlg-sampling.md +12 -2
  7. package/docs/design/memory-system.md +193 -295
  8. package/docs/design/optimization_plan.md +4 -2
  9. package/docs/design/orpo-dynamic-lambda.md +179 -0
  10. package/docs/design/orpo-future-enhancements.md +340 -0
  11. package/docs/design/orpo-training.md +1212 -0
  12. package/docs/design/parallel-slots.md +34 -26
  13. package/docs/design/parity-tier-dag.md +143 -0
  14. package/docs/design/runtime-isolation.md +1 -1
  15. package/docs/design/segmented-backward-training.md +58 -39
  16. package/docs/design/tauri-desktop-app.md +127 -0
  17. package/docs/design/w4a16-compute-precision-spike.md +232 -0
  18. package/docs/investigations/orpo-base-uf-experiment-and-directions.md +120 -0
  19. package/docs/investigations/orpo-flash-cce-pin-leak.md +167 -0
  20. package/docs/investigations/orpo-uf-testing-handoff.md +126 -0
  21. package/docs/{segmented-backward-handoff.md → investigations/segmented-backward-handoff.md} +1 -1
  22. package/docs/investigations/starter-model-port-handoff.md +32 -13
  23. package/docs/investigations/steel-flash-cce-handoff.md +489 -0
  24. package/docs/investigations/trainer-validation-experiment.md +173 -0
  25. package/docs/planning/IDEAS.md +29 -0
  26. package/docs/planning/PRODUCT_ROADMAP.md +147 -27
  27. package/docs/planning/chunk-task-roadmap.md +1 -1
  28. package/docs/planning/journal.md +161 -0
  29. package/docs/reference/distribution.md +61 -5
  30. package/docs/reference/library-api.md +29 -14
  31. package/docs/reference/memory.md +77 -0
  32. package/docs/reference/orpo-quickstart.md +130 -0
  33. package/docs/reference/server-api.md +105 -7
  34. package/docs/reference/server-config.md +12 -5
  35. package/docs/reference/training.md +296 -0
  36. package/package.json +1 -1
  37. package/src/cli.ts +651 -18
  38. package/src/eval/ifeval.ts +138 -0
  39. package/src/eval/runner.ts +76 -6
  40. package/src/eval/tasks/bfcl.ts +49 -15
  41. package/src/eval/tasks/gsm8k.ts +21 -9
  42. package/src/eval/tasks/hashhop.ts +33 -4
  43. package/src/eval/tasks/humaneval.ts +30 -8
  44. package/src/eval/tasks/ifeval.ts +52 -18
  45. package/src/eval/tasks/mmlu.ts +85 -74
  46. package/src/eval/ultrafeedback.ts +60 -0
  47. package/src/jobs/runner.ts +6 -0
  48. package/src/lora.ts +12 -7
  49. package/src/memory/chunk.ts +25 -0
  50. package/src/memory/cluster.ts +24 -0
  51. package/src/memory/db.ts +114 -0
  52. package/src/memory/ingest.ts +22 -0
  53. package/src/memory/pipeline.ts +63 -0
  54. package/src/memory/prompts.ts +23 -0
  55. package/src/memory/schedule.ts +163 -0
  56. package/src/memory/synthesize.ts +29 -0
  57. package/src/memory/tools.ts +399 -0
  58. package/src/memory/vault.ts +694 -0
  59. package/src/memory/wikify.ts +23 -0
  60. package/src/mlx/metal-kernel.ts +31 -4
  61. package/src/model/flash-attention.ts +37 -19
  62. package/src/model/fused-geglu-kernel.ts +111 -0
  63. package/src/model/gemma4-base.ts +39 -1
  64. package/src/model/gemma4.ts +196 -21
  65. package/src/model/minicpm5.ts +44 -3
  66. package/src/pi-provider.ts +29 -13
  67. package/src/pi-session.ts +81 -0
  68. package/src/pi-terminal.ts +13 -24
  69. package/src/pi-web.ts +246 -166
  70. package/src/quantize/job.ts +17 -2
  71. package/src/quantize/quantizer.ts +26 -2
  72. package/src/server.ts +216 -47
  73. package/src/tokenizer.ts +6 -2
  74. package/src/train/dataset.ts +29 -6
  75. package/src/train/flash-cce.ts +1175 -0
  76. package/src/train/job.ts +64 -3
  77. package/src/train/lora-params.ts +53 -4
  78. package/src/train/loss.ts +947 -0
  79. package/src/train/optimizer.ts +10 -3
  80. package/src/train/prefix-shared.ts +435 -0
  81. package/src/train/rank.ts +36 -0
  82. package/src/train/segmented.ts +1148 -3
  83. package/src/train/steel-qmm-header.ts +1461 -0
  84. package/src/train/trainer.ts +570 -54
  85. package/src/train/watch.ts +542 -0
  86. package/src/web/app.html +285 -19
  87. package/src/web/skills/memory/SKILL.md +97 -0
  88. package/src/web/skills/web-research/SKILL.md +3 -1
  89. package/src/web/skills.ts +46 -8
  90. /package/{docs → src/assets}/curve-designer.html +0 -0
@@ -1,13 +1,17 @@
1
1
  # Parallel slots — configurable batched serving
2
2
 
3
- Status: **design + phasing** (S0 landed; S1+ not started)
3
+ Status: **S0 + S1b SHIPPED** (S2 not started; S3+ deferred)
4
4
  Owner: serving layer
5
- Default: **off** (`--slots 1` = today's serialized path, untouched)
5
+ Default: **off** (`--batch 1` = today's serialized path, untouched)
6
+
7
+ > **Flag rename note:** This document was drafted with `--slots N` as the design name.
8
+ > The shipped flag is `--batch N` (`--decode-concurrency` accepted as an mlx_lm.server alias).
9
+ > Occurrences of `--slots` in pre-ship text below refer to today's `--batch N`.
6
10
 
7
11
  ## Goal
8
12
 
9
13
  Let the server process more than one generation request concurrently,
10
- gated entirely behind a `--slots N` flag. `N=1` runs exactly what we
14
+ gated entirely behind a `--batch N` flag (originally designed as `--slots N`). `N=1` runs exactly what we
11
15
  have today (the serialized promise chain). `N>1` loads a batch
12
16
  scheduler that runs multiple sequences through one forward pass.
13
17
 
@@ -78,7 +82,7 @@ concurrent ≈ arrival_rate × mean_generation_time).
78
82
 
79
83
  When it does **not** help: traffic so light that requests never overlap
80
84
  in time. Then there's only ever one live sequence and batching is idle
81
- — which is exactly why `--slots 1` is and stays the default.
85
+ — which is exactly why `--batch 1` is and stays the default.
82
86
 
83
87
  ## Current code: what's batch=1 and what isn't
84
88
 
@@ -98,7 +102,7 @@ Good news: the hot path is already written over a batch dim `B`.
98
102
  | `src/generate.ts:295,315,378` | prompt/decode tensors built `[1, L]` / `[1, 1]` |
99
103
  | `src/generate.ts:320,235` | `hLast` slice `[1, …]`; `sampleStep` reshape `[1, V]` |
100
104
  | `src/generate.ts:247` | sampler history `[1]`, one sampled token/step |
101
- | `src/model/gemma4.ts:647-653` | per-layer-input reshapes `[1, L, …]` |
105
+ | `src/model/gemma4.ts:647-653` | per-layer-input reshapes `[1, L, …]` *(pre-S1b inventory — this entry was fixed in S1b; e4b per-layer-input is now B-generic)* |
102
106
  | `src/model/gemma4-base.ts:210` | **one scalar `offset` per cache, shared across rows** |
103
107
  | `src/model/gemma4.ts:174,206` | **scalar RoPE offset** for the whole batch |
104
108
  | `src/model/gemma4-base.ts:256` | `makeMask` from that single offset (returns empty mask at N=1) |
@@ -237,7 +241,7 @@ Degradation levers: `--perf-kernel off` / `--fused-* off` drops L3→L2; bf16 KV
237
241
  drops L2→L1. Each layer must stand alone and stay reachable (don't-delete-
238
242
  optionality as a fallback ladder).
239
243
 
240
- So a feature like batched decode (`--slots`) is "done" only when green at
244
+ So a feature like batched decode (`--batch`) is "done" only when green at
241
245
  **every (model × layer)** cell — and each cell may need its own fix:
242
246
 
243
247
  | Model · path | L1 (bf16 / mlx-lm) | L2 (quant / optiq) | L3 (perf / KL) |
@@ -323,7 +327,7 @@ Batched-decode landmines found by the modes analysis (2026-06-14):
323
327
  required), so quant batched decode falls to `quantizedSdpaUnfused` — correct
324
328
  but the perf kernel is bypassed (perf debt; a batched-aware fused decode kernel
325
329
  is a later item). `quantizedSdpaUnfused` must be validated with a 4-D bool mask.
326
- - **All cells**: compiled-decode forced off under `--slots>1`; LoRA batches only
330
+ - **All cells**: compiled-decode forced off under `--batch>1`; LoRA batches only
327
331
  within one adapter group (else drain).
328
332
 
329
333
  ## KV memory: dynamic allocation, not static partition
@@ -357,11 +361,12 @@ Budget accounting is total-bytes from day one.
357
361
  **Current state (2026-06-14):** the batched *primitive* is **rung 2** —
358
362
  one shared `[B, H, S, D]` buffer per layer (`KVCache`/`RotatingKVCache`),
359
363
  growing in 256-token steps, width = longest live sequence, left-pad
360
- "waste" on short rows. But the **budget/admission half of rung 2 is NOT
361
- built** (no `B × S_max` projection, no KV budget enforcement) — that's
362
- scheduler work (S2), and the scheduler itself isn't built (`--slots`
363
- inert). And **rung 3 (paged) is a deferred spike**. So today: rung-2
364
- *allocation shape*, no *budget control*, not wired into serving.
364
+ "waste" on short rows. Rung-2 allocation IS wired into serving via
365
+ `BatchScheduler` + `GenerationGateway` (S1b SHIPPED). But the
366
+ **budget/admission half of rung 2 is NOT built** (no `B × S_max`
367
+ projection, no KV budget enforcement) — that's scheduler work (S2, NOT
368
+ STARTED). And **rung 3 (paged) is a deferred spike**. So today: rung-2
369
+ *allocation + scheduling wired*, no *budget/admission control* yet.
365
370
 
366
371
  KV quantization is a force multiplier: 4-bit KV (`generate.ts:67`,
367
372
  already supported) is ~4× smaller, so the same budget buys ~4× the slots
@@ -462,34 +467,37 @@ predicate (full-attention model + no vision/adapter/repetition-penalty/user-seed
462
467
 
463
468
  ## Config surface
464
469
 
465
- - `ServerOptions.slots` (default 1); later `kvBudgetBytes`,
470
+ - `ServerOptions.batch` (default 1); later `kvBudgetBytes`,
466
471
  `promptConcurrency`.
467
- - CLI: `--slots N` (S0); later `--kv-budget <GB>`.
468
- - `slots === 1` → today's serialized promise chain, untouched.
469
- `slots > 1` → scheduler.
472
+ - CLI: `--batch N` (shipped; `--slots N` was the original design name, `--decode-concurrency` accepted as mlx_lm.server alias); later `--kv-budget <GB>`.
473
+ - `batch === 1` → today's serialized promise chain, untouched.
474
+ `batch > 1` → scheduler.
470
475
 
471
476
  ## Phasing (incremental, parity-gated)
472
477
 
473
- - **S0 — config seam (DONE).** `--slots N` / `ServerOptions.slots`
478
+ - **S0 — config seam (DONE).** `--slots N` (now shipped as `--batch N`) / `ServerOptions.slots`
474
479
  plumbed, validated, surfaced (ready card + `/stats`). `N>1` warns that
475
480
  batched execution lands in S1 and runs serially. No behavior change.
476
- - **S1 — static 2-wide, base model only.** Split by the reuse finding:
481
+ - **S1 — static 2-wide, base model only. (DONE — S1b LIVE as `--batch N`)** Split by the reuse finding:
477
482
  - **S1a (prefill)** — reuse the training machinery (`buildBatchedPadMask`
478
483
  / `BatchedMaskCache`); wire the serving path to prefill B prompts in one
479
484
  forward. Mostly generalization + consolidating the two mask builders.
480
- - **S1b (decode)** — the new work: a batched KV cache that grows with
481
- per-row offsets, per-row RoPE via the array-offset path, and the per-row
482
- `[B,1,1,S]` decode mask (`buildBatchedDecodeMask`, landed 2026-06-14).
483
- Plus the B-token-per-step decode loop + per-row stream fan-out.
485
+ - **S1b (decode) — SHIPPED 2026-06-14.** `BatchScheduler`
486
+ (`src/serve/batch-scheduler.ts`) + `GenerationGateway`
487
+ (`src/serve/generation-gateway.ts`) wired into `createServer` behind
488
+ `--batch N`. L1 oracle-verified for CPM + all 3 Gemmas (full-attn +
489
+ sliding-window via `BatchedRotatingCache`). Per-row SSE fan-out live.
490
+ See STATUS block above for details.
484
491
  **Teacher-forced gate**: a 2-row batch must produce per-row logits matching
485
492
  two solo runs within bf16 tolerance (the train-batch-e2e methodology, now
486
493
  applied to decode).
487
- - **S2 — N-wide + continuous injection/eviction.** Rows retire on EOS;
488
- queued requests prefill into freed rows. Dynamic byte-budget admission.
489
- - **S3+ — paged KV** (custom paged-attention kernel + block manager),
494
+ - **S2 — N-wide + continuous injection/eviction. (NOT STARTED)** Rows retire on EOS;
495
+ queued requests prefill into freed rows. Dynamic byte-budget admission
496
+ (`B × S_max` projection, KV budget enforcement) still TODO.
497
+ - **S3+ — paged KV (DEFERRED)** (custom paged-attention kernel + block manager),
490
498
  KV-quant under batch, LoRA-group batching.
491
499
 
492
- Every phase ships default-off behind `slots=1`; the serialized path is
500
+ Every phase ships default-off behind `batch=1`; the serialized path is
493
501
  never removed (only bypassed).
494
502
 
495
503
  ## Open questions / to measure
@@ -0,0 +1,143 @@
1
+ # Parity-tier DAG → meaningful CLI flags
2
+
3
+ **Status:** vision / roadmap (2026-06-21). The first artifact exists — a zoomable,
4
+ tier-tagged map of the training + inference DAGs at
5
+ [`docs/dag/training-inference-map.html`](../dag/training-inference-map.html). This
6
+ doc records where it goes and *why*: to turn a sprawling pile of CLI flags into a
7
+ small, coherent, tier-organized surface.
8
+
9
+ ## The framework (recap)
10
+
11
+ Every node in the compute graph sits in the **lowest parity tier whose oracle it
12
+ matches bit-for-bit**:
13
+
14
+ - **L1** — drop-in for **mlx-lm** (its op is the oracle; bit-exact or it's a bug).
15
+ - **L2** — drop-in for **mlx-optiq** (optiq's behavior is the oracle).
16
+ - **L3** — **our originals** (flash-CCE head, ORPO loss, prefix-share, segmented
17
+ backward, HLG sampler, expert offload). **No oracle ships**, so correctness is
18
+ gated by finite-difference / KL / quality, *not* parity.
19
+
20
+ **The gate is the oracle.** A node can only live in a lower tier if that oracle
21
+ exists AND it matches bit-exactly; otherwise it bubbles up. Note the subtlety the
22
+ map already shows: an *optimization* that still matches the oracle stays low —
23
+ `compiled-decode` is ours but is L1 because it replays the same ops bit-for-bit.
24
+ Only nodes with **no** oracle (flash-CCE) are *forced* to L3. (Background +
25
+ post-mortem: [orpo-flash-cce-pin-leak.md](../investigations/orpo-flash-cce-pin-leak.md).)
26
+
27
+ ## Two axes: parity gates performance
28
+
29
+ Every optimization has **two independent coordinates**, and conflating them is what
30
+ made the flag surface unreadable:
31
+
32
+ 1. **Parity** — *which reference does it reproduce bit-for-bit?* This sets the
33
+ **lowest level** it may live in: matches mlx-lm → L1; matches mlx-optiq but not
34
+ mlx-lm → L2; matches neither → L3 (gated by KL + tests, no oracle).
35
+ 2. **Performance** — *is it the fastest correct way?* This sets whether it's the
36
+ **default (on)** within its level, or a kept-but-**off** opt-in.
37
+
38
+ **Parity gates performance.** A faster kernel that breaks mlx-lm parity is simply
39
+ *not in L1* — it bubbles up to whatever level its parity allows. You never trade the
40
+ guarantee for speed silently; you change levels, and the user opts in. The level is
41
+ your floor of trust; the default within it is the best way to honor that floor.
42
+
43
+ ### Decision procedure for any new optimization
44
+
45
+ 1. **Measure its parity** (run it both ways, compare — "one flag, one measurement
46
+ per lever"). The lowest oracle it matches bit-for-bit is its level ceiling.
47
+ 2. **Measure its performance** vs that level's current default:
48
+ - **faster AND holds the guarantee** → it becomes the **default (on)** for that level.
49
+ - **slower but still correct** → **keep it as a documented default-off flag**
50
+ (optionality for A/B; never the default, never deleted).
51
+ - **breaks the guarantee but offers a real tradeoff** (e.g. 2× tok/s at KL
52
+ 0.0015) → an **L3 opt-in**; the user chooses exact vs fast.
53
+
54
+ Worked examples:
55
+ - *5% less memory, still bit-for-bit with mlx-lm* → **default-on in L1.**
56
+ - *2× tok/s, but KL 0.0015 to mlx-lm* → **L3 opt-in** (user picks exact or 2×).
57
+ - *another way, 3% slower, still correct* → **kept, off in every level.**
58
+
59
+ ### Our decode levers under this rule (today)
60
+
61
+ | lever | parity | perf | placement |
62
+ |---|---|---|---|
63
+ | `compiled-decode` | bit-exact w/ uncompiled (`compiled-decode.test`) | faster | **default-on, every level** |
64
+ | `perf-kernel` | matches optiq, not mlx-lm (`perf-kernel-oracle.test`) | faster | **default-on from L2** (the L2 bridge) |
65
+ | `fused-sdpa` | matches optiq, not mlx-lm (`fused-sdpa.test`) | faster | **default-on from L2** (the L2 bridge) |
66
+ | `fused-decode` | (would be L3) | ~3% slower (`optimization_plan.md`) | **kept, off everywhere** |
67
+
68
+ So `--l1/--l2/--l3` is the **parity** axis (the guarantee), and the defaults within
69
+ each tier are the **performance** axis (the fastest kernel that still holds it). For
70
+ the *decode path* L2 == L3 today (every decode kernel matches mlx-lm or optiq); L3's
71
+ real territory is the no-oracle **features** — HLG sampler, expert offload, batched
72
+ mixed-precision — which live off the decode axis and are gated by KL + quality.
73
+
74
+ ## Why this matters for CLI flags
75
+
76
+ We are accumulating a **lot** of flags (`--grad-accum`, `--grad-clip`, `--seg`,
77
+ `--no-flash`, `--no-prefix`, `--no-segment`, `--lambda`, `--val-size`, kv-quant
78
+ knobs, sampler knobs, …). The problem isn't any one flag — it's the **combinatorics**
79
+ and the fact that **some are never really a choice** (always-on guards, or the only
80
+ viable route). The tier-tagged DAG makes each flag legible by asking: **what route
81
+ does this flag push the computation down, and what tier is that route?**
82
+
83
+ A flag falls into exactly one of three buckets:
84
+
85
+ | flag selects… | tier effect | what it IS | action |
86
+ |---|---|---|---|
87
+ | the only viable route (no alternative node) | — | not a choice | **always-on default; drop the flag** |
88
+ | between two routes at the SAME tier / same oracle | none | cosmetic / safety guard | **default it; expose only if it ever fails** |
89
+ | between a **lower-tier** route and an **L3** route | **L1/L2 ↔ L3** | a real **parity ⇄ optimization** knob | **keep, document as such, group by tier** |
90
+
91
+ So the DAG lets us answer "**what flags push down THAT route**" for any path, and
92
+ from that:
93
+
94
+ 1. **Prune always-on flags to defaults** — anything whose alternative route is
95
+ never selected, or whose toggle doesn't change the tier, shouldn't be a flag.
96
+ 2. **Surface the real axis.** The flags that genuinely matter all toggle
97
+ *bit-exact-lower-tier ⇄ our-L3-optimization*. Those could collapse into a small
98
+ number of intent-level switches (e.g. a single `--exact` that forces every node
99
+ down to its lowest tier, vs the optimized default) instead of N independent
100
+ kernel toggles the user has to understand individually.
101
+ 3. **Group the rest by what they trade** — memory (`--seg`, all bit-exact → not a
102
+ parity choice, a memory↔compute knob), hyperparameters (`--lambda`, `--lr` →
103
+ training knobs, orthogonal to tier), etc.
104
+
105
+ ### Worked classification (current training flags)
106
+
107
+ - `--grad-clip 1.0` → standard guard, L1, **always-on default** (not a route choice).
108
+ - `--seg N` / `--no-segment` → memory↔compute, **bit-exact either way** → a *memory*
109
+ knob, **not** a parity choice. Should read as "memory budget," not "exactness."
110
+ - `--grad-accum N` → changes the accumulation route (L1; smoother grads) → a real
111
+ *training-quality* knob.
112
+ - `--no-flash` → **L3 flash-CCE → L2 MLX fused head** → a genuine *parity ⇄ perf*
113
+ fallback (the one place the head leaves L3).
114
+ - `--no-prefix` → **L3 prefix-share → bit-exact two-forward** → a *perf* knob with no
115
+ numerical cost.
116
+ - `--lambda` → ORPO hyperparameter (pure L3) → a *training* knob.
117
+
118
+ The pattern: most flags are **memory/training knobs or always-on guards**; only a
119
+ couple are true **parity ⇄ optimization** toggles. Today they're a flat list the
120
+ user must reason about individually; tier-tagging lets us present them as what they
121
+ actually are.
122
+
123
+ ## Roadmap to make it executable
124
+
125
+ The map is hand-authored today. To make the framework *enforce* itself:
126
+
127
+ 1. **Derive the graph from code** — extract the call graph + per-node op/oracle tags
128
+ so it stays in sync and is **queryable** ("every L3 node", "paths that cross into
129
+ L3", "the pure-L1 subgraph", "flags that select route X").
130
+ 2. **CI-enforce the gate** — a node tagged L1 must have a passing bit-exact parity
131
+ test vs mlx-lm; if it can't, it's a bug or must be re-tagged L3. "The oracle is
132
+ the gate" becomes a check, not a principle.
133
+ 3. **Shrink the L3 surface** — audit whether anything in the L3 island is actually
134
+ bit-exact-able and just unproven (e.g. the prefix-shared *forward* is already
135
+ bit-exact — `prefix-shared-parity.ts` — so only the encode-once *fold* is truly
136
+ L3). Minimizing L3 minimizes what needs KL/quality gates.
137
+ 4. **Generate the flag surface from the graph** — once flags map to routes, the CLI
138
+ help + defaults can be derived: always-on routes have no flag, parity⇄opt routes
139
+ collapse to intent switches, knobs group by what they trade.
140
+
141
+ End state: instead of a growing flat list of flags the user memorizes, a small set
142
+ of intent-level switches whose meaning is exactly "which tier route does the compute
143
+ take," backed by a DAG that's checked in CI.
@@ -1,6 +1,6 @@
1
1
  # Runtime isolation — the AI may crash, the UI never may
2
2
 
3
- Status: **design** (not started)
3
+ Status: **design** (not started — deferred future work; no scheduled PLAN.md phase)
4
4
  Owner: serving layer / runtime
5
5
  Default today: inference runs **in-process**, on the server's event-loop
6
6
  thread. This doc proposes moving it to a **subprocess**.
@@ -15,8 +15,10 @@ it holds all 42 layers' recompute activations at once, so e4b's training backwar
15
15
  spikes to **23 GB live @2048** (resting is only 6.95 GB) and **crashes at 4096+**.
16
16
  We proved a fix — **segmented backward** — that forces per-segment streaming:
17
17
  forward saves detached boundary activations at segment edges, then backprop runs
18
- segment-by-segment via a surrogate-loss `value_and_grad`. Only one segment's
19
- activations are ever live. On an activation-dominated toy it beat per-layer
18
+ segment-by-segment via `mlx_vjp` (the shipped production implementation — see §9.4;
19
+ an earlier surrogate-loss `value_and_grad` approach was tried but leaked ~32 MB/seg/step
20
+ and was replaced). Only one segment's activations are ever live.
21
+ On an activation-dominated toy it beat per-layer
20
22
  checkpointing (4.26 vs 5.97 GB) with **bit-exact gradients**. Extrapolated to e4b
21
23
  this lands at **~10 GB @8K** — fitting on the 32 GB M1 Max, where the optiq/mlx-lm
22
24
  reference cannot (it crashes on e4b @2048+). Next: wire it into the trainer,
@@ -97,12 +99,16 @@ Algorithm:
97
99
  small (one [B,L,hidden] per edge) and cheap.
98
100
  2. **Compute the loss** from the final hidden (LM head + response-only CE) and its
99
101
  gradient w.r.t. the final hidden — this `dh_out` seeds the last layer segment.
100
- 3. **Backward, reverse over segments.** For each segment k (last→first), build a
101
- `value_and_grad` over `[boundary_leaf_k, ...segment_LoRA_params]` whose loss is
102
- the surrogate `sum(stop_gradient(dh_out) ⊙ segment_forward(boundary_leaf, params))`
103
- (the optiq chunked-CE surrogate, generalized to the whole backward). It returns
102
+ 3. **Backward, reverse over segments.** For each segment k (last→first), call
103
+ **`mlx_vjp`** (bound as `Vjp` in `src/mlx/autograd.ts`) over
104
+ `[boundary_leaf_k, ...segment_LoRA_params]` with cotangent `dh_out`. It returns
104
105
  `[dh_in, ...dLoRA_segment]`. `dh_in` becomes `dh_out` for segment k−1; accumulate
105
- `dLoRA`. Only segment k's activations are live during its `value_and_grad`.
106
+ `dLoRA`. Only segment k's activations are live during its vjp.
107
+ **NOTE:** an earlier design used a surrogate-loss `value_and_grad` here
108
+ (`sum(stop_grad(dh)⊙segment_forward(...))`). That proved to leak ~32 MB per
109
+ segment per step (linear, no plateau) and was replaced by `mlx_vjp` in §9.4.
110
+ The production code uses `Vjp`/`mlx_vjp` throughout — not surrogate-loss
111
+ `value_and_grad`.
106
112
 
107
113
  Why it's exact: `d/dx sum(stop_grad(dh)⊙y) = (dy/dx)ᵀ dh = dLoss/dx`, and likewise
108
114
  for params — this is reverse-mode AD done in segments. Verified **0.000%** vs the
@@ -140,9 +146,14 @@ quant. The trivial case to validate the machinery on a real model.
140
146
  2. **Trainer:** add a `segmented` training path (flag `TrainConfig.segments` or auto;
141
147
  gate alongside the existing `gradCheckpoint`). It:
142
148
  - runs the segmented forward (save detached boundaries),
143
- - computes response-only CE loss + `dFinalHidden` (already memory-bounded),
144
- - runs the reverse per-segment surrogate `value_and_grad`, threading the LoRA
145
- params for each segment as that `value_and_grad`'s argnums, accumulating grads,
149
+ - computes response-only CE loss and `dFinalHidden` via a `Vjp` of the loss head
150
+ with scalar cotangent 1.0 (already memory-bounded),
151
+ - runs the backward per-segment using **`mlx_vjp`** (`Vjp` in
152
+ `src/mlx/autograd.ts`): for each segment k (last→first), calls
153
+ `vjp.apply([boundary_k, ...LoRA_k], [dh_out])` to get `[dh_in, ...dLoRA_k]`,
154
+ threads `dh_in` as `dh_out` for segment k−1, and accumulates `dLoRA` — no
155
+ surrogate scalar, no `value_and_grad` (see §9.4 for why that approach leaked
156
+ ~32 MB/seg/step and was replaced),
146
157
  - also handles the embedding + (e4b) per-layer-input grads from the first
147
158
  segment's `dh_in`.
148
159
  3. **Validate:** grads bit-match the current trainer (response-only CE, same seed);
@@ -232,17 +243,24 @@ returns the split indices + which donor K/V to save, computed from the above.
232
243
  - `scripts/ft-chunk-smoke.ts` — e4b train/fwd/infer smoke (GRAD_CKPT, MEM_LIMIT_GB, MLX_BUN_MEM_LOG).
233
244
  - `scripts/sdpa-vs-manual.ts`, `scripts/flash-dk-debug.ts`, `scripts/flash-grad-test.ts` — gradient correctness.
234
245
  - `scripts/chunk-{eval,finetune,filter}.ts` — the chunk experiment pipeline.
235
- - `src/train/trainer.ts` — training loop (combined-eval, mem-log, gradCkpt ctx). Add the segmented path here.
246
+ - `src/train/trainer.ts` — training loop (combined-eval, mem-log, gradCkpt ctx); contains the segmented path (`TrainConfig.segmentSize`).
236
247
  - `src/train/forward.ts`, `src/train/loss.ts` — training forward + response-only CE.
237
- - `src/model/gemma4.ts` — e4b model: `forwardLayers`, `runCheckpointedLayer`, `computePerLayerInputs`, KV-sharing (`numDonors`, `previousKvs`, `cacheIndex`), `layer_types`. Add `runLayerRange`.
238
- - `src/model/minicpm5.ts` — MiniCPM5 (Phase A target).
239
- - `src/mlx/autograd.ts` (`ValueAndGrad`), `src/mlx/checkpoint.ts` (`Checkpoint`), `src/mlx/ffi.ts` (memory fns, `stopGradient`, `evalAll`).
248
+ - `src/model/gemma4.ts` — e4b model: `forwardLayers`, `runCheckpointedLayer`, `computePerLayerInputs`, KV-sharing (`numDonors`, `previousKvs`, `cacheIndex`), `layer_types`, `runLayerRange` (added for segmented backward).
249
+ - `src/model/minicpm5.ts` — MiniCPM5 (Phase A).
250
+ - `src/mlx/autograd.ts` (`ValueAndGrad`, `Vjp`), `src/mlx/checkpoint.ts` (`Checkpoint`), `src/mlx/ffi.ts` (memory fns, `stopGradient`, `evalAll`, `mlx_vjp`).
240
251
  - Memory: `[[e4b-lora-training-seqlen-ceiling]]` (full findings), `[[opssdpa-dk-vjp-bug]]`, `[[three-level-fidelity-tree-model]]`.
241
252
 
242
- ## 8. First action on resume
243
- Phase A step 1: add `runLayerRange` to `MiniCPM5Model`, then a segmented training
253
+ ## 8. Original first action (COMPLETED)
254
+ ~~Phase A step 1: add `runLayerRange` to `MiniCPM5Model`, then a segmented training
244
255
  path in `trainer.ts`, validate bit-exact grads + measure peak on
245
- `scripts/ft-chunk-smoke.ts`-style MiniCPM5 run. Then Phase B (e4b) with the §5 plan.
256
+ `scripts/ft-chunk-smoke.ts`-style MiniCPM5 run. Then Phase B (e4b) with the §5 plan.~~
257
+
258
+ **Both Phase A and Phase B are done — see §9 and §10.** The genuine remaining work
259
+ is `planSegments` full-attention isolation for the ~10 GB @8K e4b target: put each
260
+ O(L²) full-attn layer (5,11,17,23,29,35,41) in its own short segment so it isn't
261
+ materialized alongside sliding-layer activations. Currently 17.5 GB @8K (seg2);
262
+ resting is ~6.6 GB, so ~3.4 GB of transient budget remains. The mechanism and tunable
263
+ segmentation knob are proven; this is a peak-optimization step. See §10 close.
246
264
 
247
265
  ---
248
266
 
@@ -339,11 +357,11 @@ memory win confirmed, no leak, trains end-to-end through `scripts/chunk-finetune
339
357
  baseline (11.89). json/schema/labels/nonempty 100%, anchors 86%. Segmented
340
358
  training produces a quality adapter. Adapter:
341
359
  `~/.cache/mlx-bun-finetunes/minicpm5-chunk-segmented`.
342
- 2. **Phase B (e4b)** with the §5 plan (KV-sharing, per-layer-input, full-attn O(L²)
343
- isolation). e4b adds `runLayerRange` to `Gemma4Model` with donor-KV /
344
- per-layer-input boundary threading; the `SegmentedBackward` + vjp machinery and
345
- the §5 segmentation strategy carry over. This is the path to the original goal:
346
- e4b @8K under ~10 GB where the optiq/mlx-lm reference crashes.
360
+ 2. ✅ **DONE — see §10.** **Phase B (e4b)** with the §5 plan (KV-sharing,
361
+ per-layer-input, full-attn O(L²) isolation). e4b `runLayerRange` added to
362
+ `Gemma4Model` with donor-KV / per-layer-input boundary threading;
363
+ `SegmentedBackwardGemma4` + vjp machinery wired into trainer, validated on real
364
+ e4b. Full details in §10.
347
365
 
348
366
  ---
349
367
 
@@ -467,42 +485,43 @@ are proven; this is a peak-optimization, not a blocker.
467
485
  (Config note: mlx-lm's default LoRA scale is 20.0; mlx-bun's `DEFAULT_TRAIN_CONFIG`
468
486
  is 1.0 — a config difference, not correctness. The chunk runs used 1.0.)
469
487
 
470
- **Trainer side — `SegmentedBackwardGemma4` (to build + validate):** same skeleton as
471
- the MiniCPM5 `SegmentedBackward`, with TWO additions.
488
+ **Trainer side — `SegmentedBackwardGemma4` (BUILT + VALIDATED, `src/train/segmented.ts`):**
489
+ same skeleton as the MiniCPM5 `SegmentedBackward`, with TWO additions — both are
490
+ implemented and verified in the Phase B results above.
472
491
 
473
492
  (a) **per-layer-input is a pure constant boundary — NO gradient threading.** The
474
493
  `per_layer_input_gate` / `per_layer_projection` LoRA live INSIDE the layer (applied
475
494
  to the sliced `pls`), so they're differentiated within each segment's vjp normally.
476
495
  The `perLayer` tensor itself feeds in as a constant (its grad would flow to the
477
- embed / `per_layer_model_projection`, neither a LoRA target) — so detach it once and
478
- slice per segment; discard its cotangent. Easy.
496
+ embed / `per_layer_model_projection`, neither a LoRA target) — detached once and
497
+ sliced per segment; its cotangent is discarded.
479
498
 
480
499
  (b) **the KV-shared donor K/V is a SECOND boundary stream WITH its own cotangent.**
481
500
  The donor's K/V depend on the donor layer's q/k/v LoRA and are attended by every
482
501
  sharer, so `dLoss/d(donor LoRA)` gets contributions from the donor's own attention
483
502
  AND all sharers. Threading:
484
503
  - **Forward (boundary-saving):** when a segment produces a reused donor's K/V
485
- (`donorKvOut`), detach it (rope-applied K + V, tiny — ~1 KB/tok) and save as a
486
- boundary; later sharer segments pass it in via `donorKvIn`.
504
+ (`donorKvOut`), it is detached (rope-applied K + V, tiny — ~1 KB/tok) and saved as
505
+ a boundary; later sharer segments pass it in via `donorKvIn`.
487
506
  - **Backward (reverse), two extra pieces:**
488
- - keep a cotangent accumulator `dKV[d]` per reused donor d (init zero).
507
+ - a cotangent accumulator `dKV[d]` per reused donor d (init zero).
489
508
  - a SHARER segment's vjp differentiates `[boundary_h, donorK, donorV, ...LoRA]`
490
509
  with cotangent `[dh]`; its vjps for `donorK/donorV` are ACCUMULATED into `dKV[d]`.
491
510
  - a DONOR segment (produces donor d's K/V) has a MULTI-OUTPUT forward
492
511
  `[h_out, donorK, donorV]`; its vjp takes cotangents `[dh, dKV[d].k, dKV[d].v]`
493
- (the `Vjp` class already supports nOut>1 + multiple cotangents) and returns
512
+ (the `Vjp` class supports nOut>1 + multiple cotangents) and returns
494
513
  `[dh_in, ...donor LoRA]`. This folds the sharers' gradient back into the donor.
495
514
  - donors 22 (sliding) and 23 (full) are each reused by all sharers of their type;
496
515
  if both live in one segment, that segment's forward outputs 5 arrays
497
516
  `[h, k22, v22, k23, v23]` with 5 cotangents.
498
- - **Segmentation (the §5 plan):** respect the donor/sharer split at layer 24 and
499
- ISOLATE the 7 full-attention layers (5,11,17,23,29,35,41) — each is O(L²) in the
500
- backward under ops.sdpa (~1 GB bf16 @8K), so put each full layer in a short
501
- segment and group the sliding layers. The natural first cut is the 6-layer
502
- `sssssF` period → 7 segments of 6 (one full layer each). `planSegments` from §5
503
- returns the split indices + which donor K/V to save.
517
+ - **Segmentation (the §5 plan):** the donor/sharer split at layer 24 is respected and
518
+ the 7 full-attention layers (5,11,17,23,29,35,41) — each O(L²) in the backward
519
+ under ops.sdpa (~1 GB bf16 @8K) — are candidates for isolation into short segments;
520
+ the natural first cut is the 6-layer `sssssF` period → 7 segments of 6 (one full
521
+ layer each). `planSegments` from §5 returns the split indices + which donor K/V to
522
+ save. Full-attention ISOLATION for the ~10 GB @8K target remains the next
523
+ optimization step (currently 17.5 GB @8K, seg2 — see §10 close above).
504
524
 
505
525
  Everything else (forward boundaries via `detachLeaf`, head vjp with cotangent 1.0,
506
- reverse `dh` chain, `mlx_vjp` not surrogate) is identical to the MiniCPM5 path.
507
- Target: e4b @8K ≤ ~10 GB, then `chunk-eval` quality on e4b (the original goal —
508
- chunk data is mostly 3.4–8.2K, useless at the ≤2K the reference is capped to).
526
+ reverse `dh` chain, `mlx_vjp` not surrogate) is identical to the MiniCPM5 path and
527
+ is verified end-to-end: peak 10 GB @2048, active flat (no leak), adapter saved.
@@ -0,0 +1,127 @@
1
+ # Design: mlx-bun desktop app (Tauri)
2
+
3
+ A native macOS desktop app that wraps the existing mlx-bun chat UI in a
4
+ window — double-click an icon, get local AI, no terminal. The app is a
5
+ **thin shell**: it spawns `mlx-bun serve` as a sidecar and points a
6
+ WKWebView at the chat UI the server already serves. Almost no new code;
7
+ the value is packaging + UX, not new functionality.
8
+
9
+ Companion docs: [embedding.md](../reference/embedding.md) (the sidecar
10
+ layout this builds on), [distribution.md](../reference/distribution.md)
11
+ (the sign/notarize pipeline this reuses).
12
+
13
+ ## Where it lives — separate repo (recommended)
14
+
15
+ Ship the app from its **own repo** (`mlx-bun-desktop`), not inside this
16
+ one.
17
+
18
+ - **Toolchain isolation.** This repo is Bun/TS. Tauri drags in Rust,
19
+ Cargo, and `src-tauri/target/` (heavy build artifacts). Keeping it out
20
+ preserves the core repo's clean dependency surface and scope.
21
+ - **Different product, cadence, audience.** The app's artifact is a
22
+ signed `.dmg` / Homebrew **Cask** for end users; the core repo ships a
23
+ CLI tarball + npm package for developers. They version independently.
24
+ - **The sidecar is already a published artifact.** The app pulls a
25
+ pinned, signed, notarized `mlx-bun` bundle from a core-repo release
26
+ (the versionless `mlx-bun-arm64.tar.gz`, or a tagged
27
+ `mlx-bun-v<ver>-arm64.tar.gz`) — no source coupling needed.
28
+ - **Precedent.** The Homebrew tap is already a separate repo; this fits.
29
+
30
+ In-repo alternative (a `desktop/` subdir) is viable if tight version
31
+ lockstep ever matters more than toolchain cleanliness — the mechanics
32
+ below are identical, only the sidecar source changes (local `dist/`
33
+ instead of a downloaded release). Default to separate.
34
+
35
+ ## Architecture
36
+
37
+ ```
38
+ mlx-bun-desktop.app
39
+ ├─ src-tauri (Rust shell)
40
+ │ on launch → pick a free port, spawn sidecar:
41
+ │ mlx-bun serve <model> --port <p> --no-open
42
+ │ readiness → poll GET http://127.0.0.1:<p>/v1/models until 200
43
+ │ window → WKWebView → http://127.0.0.1:<p>/#/chat
44
+ │ on quit → SIGKILL the sidecar (restart→ready is ~0.4s; safe)
45
+ └─ Contents/Resources/
46
+ mlx-bun + libmlxc.dylib + libmlx.dylib + libjaccl.dylib
47
+ + mlx.metallib + pi assets (the bundle build-binary.sh produces)
48
+ ```
49
+
50
+ The window renders the **same page** `mlx-bun serve` serves today
51
+ (`/#/chat`, the embedded pi web chat). No bespoke UI.
52
+
53
+ ## Components
54
+
55
+ 1. **Sidecar binary.** Tauri `externalBin` (target-triple-suffixed, e.g.
56
+ `mlx-bun-aarch64-apple-darwin`); the dylibs + metallib + pi assets go
57
+ in `bundle.resources` beside it. mlx-bun resolves its libs via
58
+ `dirname(realpath(execPath))`, so the flat Resources layout works
59
+ unchanged (verified: Bun realpaths execPath through symlinks).
60
+ 2. **Spawn / teardown** (`main.rs`). Use Tauri's shell/sidecar API (or
61
+ `std::process::Command`) to launch on `setup`, kill on window close /
62
+ app exit. One generation runs at a time server-side, so no client
63
+ concurrency to manage.
64
+ 3. **Port.** Bind an ephemeral port in Rust, pass it to both the spawn
65
+ (`--port`) and the webview URL, to avoid 8090 collisions with a
66
+ user's own `mlx-bun serve`.
67
+ 4. **Readiness gate.** Show a lightweight "starting…/downloading model…"
68
+ splash until `/v1/models` returns 200. Warm start ~0.4s; first-ever
69
+ run downloads the starter model (~0.9 GB) — the existing onboarding
70
+ page already narrates this, so the splash can hand off to it.
71
+
72
+ ## Distribution
73
+
74
+ A `.app` is a better Gatekeeper citizen than the flat CLI binary:
75
+
76
+ - **Stapleable.** Unlike a bare Mach-O, a `.app`/`.dmg` accepts a stapled
77
+ notarization ticket → works **offline**, no online check needed.
78
+ - **Channels:** signed `.dmg` direct download, and/or a Homebrew **Cask**
79
+ (`brew install --cask mlx-bun`) — a natural 5th install path.
80
+ - **Not App Store.** JIT + a spawned child process + the Metal model
81
+ don't fit the sandbox. Developer-ID + notarized + DMG/Cask outside the
82
+ store is the same flow this project already uses.
83
+
84
+ ### Signing / entitlements
85
+
86
+ Reuse the core pipeline. The nested sidecar `mlx-bun` still needs the
87
+ same entitlements as the standalone build
88
+ ([packaging/entitlements.plist](../../packaging/entitlements.plist)):
89
+
90
+ - `com.apple.security.cs.allow-jit` + `allow-unsigned-executable-memory`
91
+ (JavaScriptCore JIT) — without these the sidecar is killed on launch.
92
+ - `com.apple.security.cs.disable-library-validation` — the sidecar
93
+ dlopens the bundled MLX dylibs.
94
+
95
+ Tauri's bundler signs nested binaries during the `.app` signing pass;
96
+ point these entitlements at the sidecar specifically. Notarize the
97
+ `.app` (or `.dmg`) with the existing `AC_PROFILE` notary credentials,
98
+ then `xcrun stapler staple`.
99
+
100
+ ## Milestones
101
+
102
+ 1. **Spike** — `tauri init` in a new repo; hardcode port 8090; spawn a
103
+ locally-built `mlx-bun serve --no-open`; load `/#/chat` in the window;
104
+ `cargo tauri dev`. Proves the wrap end-to-end.
105
+ 2. **Robust runtime** — ephemeral port; readiness poll + splash;
106
+ clean teardown on quit; surface sidecar stderr to a log.
107
+ 3. **Bundle + sidecar** — wire `externalBin` + `bundle.resources` to a
108
+ pinned core-repo release tarball (download in a build script, or check
109
+ in a fetch step); first-run model-download UX.
110
+ 4. **Sign + notarize + package** — Developer-ID sign with nested
111
+ entitlements, notarize, staple, emit `.dmg`.
112
+ 5. **Cask** — formula in a tap pointing at the `.dmg` release asset;
113
+ `brew install --cask`.
114
+
115
+ ## Open questions / risks
116
+
117
+ - **Tauri sidecar entitlement plumbing** — confirm Tauri applies the
118
+ per-binary entitlements to `externalBin` (may need a custom signing
119
+ hook in the bundler config). Validate early in milestone 4.
120
+ - **Window vs. menubar** — a normal window is the MVP; a menubar/tray app
121
+ ("always-on local model") is a possible later mode.
122
+ - **Model picker** — MVP serves one model (the starter). Switching models
123
+ means restarting the sidecar; defer a UI for that.
124
+ - **Version pinning** — decide whether the app tracks `latest` or pins a
125
+ specific `mlx-bun` release per app version (pin is safer for repro).
126
+ - **Universal binary** — arm64-only today; Tauri can target both, but
127
+ MLX is Apple-Silicon-only, so x86 is moot.