mlx-bun 0.0.8 → 0.0.10

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 (144) hide show
  1. package/README.md +112 -20
  2. package/docs/design/batching-perf-path.md +232 -0
  3. package/docs/design/batching-v2-plan.md +515 -0
  4. package/docs/design/bucketing-stage.md +196 -0
  5. package/docs/design/compat-cli-surface-design.md +11 -11
  6. package/docs/design/docs-reorg-plan.md +1 -1
  7. package/docs/design/dreaming-nightly-pipeline.md +291 -0
  8. package/docs/design/dspark-speculative-decoding.md +106 -0
  9. package/docs/design/generic-model-support.md +381 -0
  10. package/docs/design/memory-inference-path.md +422 -0
  11. package/docs/design/memory-synthesis.md +287 -0
  12. package/docs/design/minicpm5-decode-megakernel.md +484 -0
  13. package/docs/design/mlx-lm-tool-parity-plan.md +535 -0
  14. package/docs/design/omlx-adoption-map.md +90 -0
  15. package/docs/design/orpo-dynamic-lambda.md +41 -0
  16. package/docs/design/orpo-training.md +35 -5
  17. package/docs/design/parallel-slots.md +12 -1
  18. package/docs/design/parity-tier-dag.md +13 -5
  19. package/docs/design/segmented-backward-training.md +15 -0
  20. package/docs/design/spec-decode-larger-targets.md +229 -0
  21. package/docs/design/ssd-kv-cold-tier.md +158 -0
  22. package/docs/design/tauri-desktop-app.md +2 -2
  23. package/docs/design/the-dreaming-handoff.md +141 -0
  24. package/docs/design/the-dreaming-master-plan.md +446 -0
  25. package/docs/design/write-pipeline-entity.md +468 -0
  26. package/docs/investigations/curve-bisector-routing.md +1 -1
  27. package/docs/investigations/decode-roofline-lookagain.md +348 -0
  28. package/docs/investigations/dspark-handoff.md +58 -0
  29. package/docs/investigations/kernel-perf-review-2026-07.md +171 -0
  30. package/docs/planning/PRODUCT_ROADMAP.md +2 -2
  31. package/docs/planning/curve-sampler-research-plan.md +331 -0
  32. package/docs/planning/memory-docs-and-dag-plan.md +185 -0
  33. package/docs/planning/pi-integration-review.md +178 -0
  34. package/docs/planning/release-notes-v0.0.10.md +57 -0
  35. package/docs/planning/release-notes-v0.0.9.md +122 -0
  36. package/docs/planning/web-ui-pass-plan.md +213 -0
  37. package/docs/planning/website-readme-pass-plan.md +224 -0
  38. package/docs/reference/cli.md +255 -0
  39. package/docs/reference/embedding.md +7 -2
  40. package/docs/reference/library-api.md +22 -3
  41. package/docs/reference/memory.md +5 -5
  42. package/docs/reference/models.md +137 -0
  43. package/docs/reference/orpo-quickstart.md +6 -0
  44. package/docs/reference/server-api.md +187 -9
  45. package/docs/reference/server-config.md +43 -9
  46. package/docs/reference/training.md +31 -12
  47. package/package.json +1 -1
  48. package/src/anthropic.ts +22 -0
  49. package/src/assets/curve-designer.html +1 -1
  50. package/src/chat-template.ts +25 -7
  51. package/src/cli.ts +1077 -90
  52. package/src/config.ts +4 -1
  53. package/src/download.ts +99 -8
  54. package/src/embed.ts +77 -0
  55. package/src/eval/perplexity.ts +164 -0
  56. package/src/eval/runner.ts +15 -6
  57. package/src/eval/tasks/bfcl.ts +1 -1
  58. package/src/generate.ts +149 -18
  59. package/src/harness-pi.ts +43 -17
  60. package/src/index.ts +2 -0
  61. package/src/kv-store.ts +240 -98
  62. package/src/lora.ts +2 -2
  63. package/src/memory/article.ts +558 -0
  64. package/src/memory/chunk-validation.ts +150 -0
  65. package/src/memory/chunk.ts +305 -12
  66. package/src/memory/cluster.ts +344 -17
  67. package/src/memory/coverage.ts +134 -0
  68. package/src/memory/crosslink.ts +0 -0
  69. package/src/memory/db.ts +184 -45
  70. package/src/memory/entity.ts +203 -0
  71. package/src/memory/gate.ts +187 -0
  72. package/src/memory/history.ts +152 -0
  73. package/src/memory/ingest.ts +324 -11
  74. package/src/memory/model.ts +321 -0
  75. package/src/memory/normalize.ts +671 -0
  76. package/src/memory/parse.ts +22 -0
  77. package/src/memory/pipeline.ts +242 -30
  78. package/src/memory/prompts.ts +42 -0
  79. package/src/memory/query.ts +334 -0
  80. package/src/memory/reconcile.ts +734 -0
  81. package/src/memory/redirect.ts +108 -0
  82. package/src/memory/reindex.ts +325 -0
  83. package/src/memory/resolve.ts +307 -0
  84. package/src/memory/route.ts +272 -0
  85. package/src/memory/schedule.ts +6 -4
  86. package/src/memory/stages.ts +774 -0
  87. package/src/memory/synthesize.ts +1629 -20
  88. package/src/memory/tools.ts +446 -96
  89. package/src/memory/vault.ts +462 -35
  90. package/src/memory/wikify.ts +1057 -16
  91. package/src/mlx/array.ts +21 -0
  92. package/src/mlx/ffi.ts +3 -0
  93. package/src/mlx/ops.ts +72 -0
  94. package/src/model/compiled-decode.ts +43 -8
  95. package/src/model/factory.ts +29 -8
  96. package/src/model/flash-attention.ts +4 -2
  97. package/src/model/fused-decode-kernel.ts +14 -7
  98. package/src/model/fused-mlp-kernel.ts +169 -0
  99. package/src/model/fused-swiglu-kernel.ts +240 -0
  100. package/src/model/gemma4-base.ts +96 -7
  101. package/src/model/gemma4.ts +33 -2
  102. package/src/model/minicpm5.ts +106 -8
  103. package/src/model/qwen3-delta.ts +48 -1
  104. package/src/model/qwen3.ts +276 -0
  105. package/src/model/qwen3_5.ts +11 -2
  106. package/src/model/steel-linear-kernel.ts +96 -0
  107. package/src/model/support.ts +38 -8
  108. package/src/model/universal/archs.ts +332 -0
  109. package/src/model/universal/dense.ts +576 -0
  110. package/src/model/universal/modules.ts +163 -0
  111. package/src/model/universal/rope.ts +366 -0
  112. package/src/pi-provider.ts +1 -1
  113. package/src/pi-terminal.ts +29 -8
  114. package/src/pi-web.ts +19 -2
  115. package/src/prompt-cache.ts +55 -9
  116. package/src/quantize/config-writer.ts +1 -1
  117. package/src/registry.ts +219 -8
  118. package/src/responses.ts +23 -0
  119. package/src/sampler.ts +220 -9
  120. package/src/serve/batch-scheduler.ts +307 -83
  121. package/src/serve/generation-gateway.ts +156 -29
  122. package/src/server.ts +746 -73
  123. package/src/spec/drafter.ts +81 -49
  124. package/src/spec/dspark/data-dflash.ts +156 -0
  125. package/src/spec/dspark/data.ts +201 -0
  126. package/src/spec/dspark/generate-dflash.ts +193 -0
  127. package/src/spec/dspark/generate.ts +290 -0
  128. package/src/spec/dspark/loss.ts +213 -0
  129. package/src/spec/dspark/module-dflash.ts +361 -0
  130. package/src/spec/dspark/module.ts +540 -0
  131. package/src/spec/dspark/sample.ts +112 -0
  132. package/src/spec/generate.ts +40 -19
  133. package/src/ssd-cache.ts +213 -0
  134. package/src/status-page.html +1 -1
  135. package/src/tokenizer.ts +7 -0
  136. package/src/train/flash-cce.ts +81 -23
  137. package/src/train/fuse.ts +243 -0
  138. package/src/train/index.ts +3 -0
  139. package/src/train/job.ts +8 -0
  140. package/src/train/loss.ts +341 -40
  141. package/src/train/prefix-shared.ts +62 -11
  142. package/src/train/segmented.ts +215 -55
  143. package/src/train/trainer.ts +112 -38
  144. package/src/web/skills/memory/SKILL.md +40 -17
@@ -1,9 +1,50 @@
1
1
  # Dynamic λ controller for ORPO (adaptive preference pressure)
2
2
 
3
3
  **Status:** proposed (design only — not built). **Opened:** 2026-06-20.
4
+ **Reaffirmed + reframed by Josh 2026-07-01** (see "The band framing" below).
4
5
  **Surface:** `src/train/loss.ts` (orpoMetrics/orpoLoss), `src/train/trainer.ts`
5
6
  (orpoLoop, the λ config), the segmented ORPO classes in `src/train/segmented.ts`.
6
7
 
8
+ ## The band framing (Josh, 2026-07-01 — the product statement)
9
+
10
+ > "Vary the lambda term based on the loss or another signal — maintain the
11
+ > interest inside a band: learn preference HARDER while you are still able to
12
+ > stay coherent. A self-stabilizing AND self-discovering hyperparameter, which
13
+ > optimizes the training to get as much preference as possible while not
14
+ > resulting in degeneracy."
15
+
16
+ That is the controller's contract in one sentence: **λ is not a hyperparameter
17
+ to tune, it is a process variable to control** — push preference pressure to
18
+ the maximum the model can currently bear (self-discovering), retreat on
19
+ confirmed coherence damage (self-stabilizing), and hold the system inside the
20
+ healthy band rather than at a fixed point. Everything below (phases, AIMD/PID,
21
+ displacement decomposition) is the mechanism for that contract.
22
+
23
+ **Interplay with `sft_scope` (landed 2026-07-01):** the default chosen-NLL is
24
+ now full prompt+response. The controller's displacement signal must key on the
25
+ **response-only `lw`** (which `orpoMetrics` computes for the odds ratio in both
26
+ modes) — full-scope NLL mixes prompt modeling into the level and would mask a
27
+ crushed `logp(chosen-response)`. Instrument both, control on response-only.
28
+
29
+ ## Paper lens (2026-07-01): is this citable?
30
+
31
+ Closest prior: **β-DPO (arXiv 2407.08639)** — dynamic β for DPO, calibrated
32
+ per-batch from the reward gap. Ours differs on three axes worth claiming:
33
+ (1) **closed-loop trajectory control** (a controller tracking a non-stationary
34
+ optimum across the run) vs per-batch heuristic calibration; (2) the control
35
+ signal is **displacement-aware** — the (dLw, dLr) decomposition targets the
36
+ known ORPO/DPO failure mode directly (cf. likelihood displacement,
37
+ arXiv 2410.08847 "Unintentional Unalignment"); (3) **objective-agnostic**
38
+ actuator (ORPO λ, SimPO β/γ). The incremental P→PD→PID build IS the ablation
39
+ section. The claim to stake: *"the optimal preference pressure is
40
+ non-stationary, so a controller strictly dominates any fixed λ a sweep can
41
+ find — and a displacement-aware control law finds the coherence cliff without
42
+ a priori knowledge of it."* Referees will demand: ≥2 datasets, ≥2 model sizes,
43
+ the static-λ sweep as the baseline family (the controller must beat the BEST
44
+ constant, not λ=0.1), seeds, and the positive-control sanity run this doc
45
+ already specifies. Workshop/arXiv-preprint tier as a single-model study;
46
+ main-track needs the multi-model sweep.
47
+
7
48
  ## Motivation
8
49
 
9
50
  ORPO's loss is `L = NLL(chosen) + λ·OR`. The two terms learn on different
@@ -260,9 +260,38 @@ intra-layer MLP split, shared prompt-prefix.
260
260
 
261
261
  $$\mathcal{L}_{ORPO} = \mathcal{L}_{NLL}(y_w) + \lambda \cdot \mathcal{L}_{OR}$$
262
262
 
263
- - $\mathcal{L}_{NLL}(y_w)$ — standard SFT cross-entropy on the **chosen**
264
- response (prompt positions masked out), i.e. the negative mean response
265
- log-prob $-\ell_w$.
263
+ - $\mathcal{L}_{NLL}(y_w)$ — the SFT cross-entropy on the **chosen** sequence.
264
+ Scope is selected by **`sft_scope`** (TrainConfig `sftScope`, default
265
+ **`full`**):
266
+ - **`full`** (default; paper/TRL-faithful): the token-mean cross-entropy over
267
+ **all non-pad supervised positions — prompt AND response** (label-shifted;
268
+ `nn.CrossEntropyLoss reduction="mean"`), exactly TRL ORPOTrainer's
269
+ `chosen_nll_loss` (labels = input ids with only padding masked to −100) and
270
+ xfactlab/orpo's `outputs_pos.loss`. Computed from the **same chosen
271
+ forward** (no extra forward): the head additionally runs over the prompt
272
+ span and the two span means combine as
273
+ $\text{NLL}_{full} = -\frac{(P{-}1)\,\text{pm} + M\,\ell_w}{(P{-}1)+M}$
274
+ (`combineFullNll`, `src/train/loss.ts`).
275
+ - **`response`**: $-\ell_w$, the negative mean response log-prob — the
276
+ pre-2026-07 mlx-bun behavior (the house convention of masking the prompt),
277
+ kept **bit-exact** for reproducing older runs.
278
+
279
+ > **Resolved (2026-07-01).** The review found the then-hardcoded
280
+ > response-only scope diverged from the reference implementations (TRL /
281
+ > xfactlab/orpo supervise the full prompt+response, only padding masked).
282
+ > Decision (Josh): `sft_scope: full | response`, **default `full`** — loss
283
+ > curves are apples-to-apples with TRL/paper ORPO by default; `response`
284
+ > reproduces old runs. The odds-ratio $\ell_w/\ell_r$ terms stay
285
+ > **response-only length-normalized means in BOTH modes** — that also matches
286
+ > TRL (its `get_batch_logps` uses prompt-masked labels for the odds ratio;
287
+ > only the NLL term is full-sequence). Implemented across every ORPO path
288
+ > (naive / chunked / fused / flash-CCE / prefix-shared / all four segmented
289
+ > classes): the span-parameterized heads run once over the prompt span and
290
+ > once over the response span (disjoint — no token headed twice), keeping
291
+ > $\ell_w$ bit-identical to the `response` path. Cross-path full-scope parity
292
+ > + an independent oracle (full NLL ≙ `sftLoss` with `promptLen=1`, bf16
293
+ > class) are wired in `tests/train-orpo-fused-ce.test.ts`; loss-math units +
294
+ > exact response-scope regression pins in `tests/train-orpo.test.ts`.
266
295
  - $\mathcal{L}_{OR} = -\log \sigma(\text{log\_odds})$, the odds-ratio term:
267
296
 
268
297
  $$\text{log\_odds} = (\ell_w - \ell_r) - \big(\text{log1mexp}(\ell_w) - \text{log1mexp}(\ell_r)\big)$$
@@ -983,8 +1012,9 @@ Metal path**. Two openings stand out:
983
1012
 
984
1013
  - **Correctness:** the current MLX SOTA, `mlx-lm-lora`, ships a **non-canonical
985
1014
  ORPO** — it uses `log_sigmoid(chosen_logp − rejected_logp)` and **omits the
986
- additive SFT-NLL term** ORPO requires. Our paper-faithful loss (true log-odds
987
- + λ·NLL, length-normalized) is *already* more correct than anything shipping.
1015
+ additive SFT-NLL term** ORPO requires. Our loss (true log-odds + λ·NLL,
1016
+ length-normalized; one known scope divergence — see the note under "The
1017
+ objective") is *already* closer to the paper than anything shipping.
988
1018
  **Do not copy their loss.**
989
1019
  - **Efficiency:** a **Metal fused/chunked CE + fused LoRA** would be the first
990
1020
  fused preference loss in the MLX world, on a quantized base, with grad
@@ -18,6 +18,16 @@ scheduler that runs multiple sequences through one forward pass.
18
18
  This is a server-path feature — serving speed is the user metric.
19
19
  Direct/embedding mode stays batch=1.
20
20
 
21
+ > **v2 execution plan:** this document remains the PRIMITIVE-design record
22
+ > (masks, caches, lanes). The living gap/debt plan and sequencing for the
23
+ > batching engine is [batching-v2-plan.md](batching-v2-plan.md). Landed from
24
+ > it (2026-07-01, steps 1–3): cache-capability gate (hybrid/SSM models route
25
+ > serial), slice-before-head admit, ONE lock domain (curve `/generate`,
26
+ > `/signal`, adapter mount/unmount via `gateway.runExclusive`), per-row
27
+ > onToken failure containment, drain-on-serial-waiter (no starvation), and
28
+ > the engine-hygiene transplant (pipelined decode via asyncEval, clearCache
29
+ > 1/256 steps, chunked admit prefill interleaved with decode steps).
30
+
21
31
  ## STATUS (2026-06-14) — LIVE for full-attention models
22
32
 
23
33
  **`--batch N` now serves B>1** for full-attention models (CPM). The numerics
@@ -266,7 +276,8 @@ Harness: `tests/batched-decode-parity.test.ts` has two paths — (a)
266
276
  NOT the gate); (b) **`realBatchedGreedy` = the REAL gate**: left-pad → one
267
277
  `BatchedDecodeMaskCache` (handles prefill at offset 0 AND per-step decode) →
268
278
  batch-prefill → greedy batch-decode, trajectory compared bit-for-bit to the
269
- mlx-lm B=N oracle fixture (`tests/fixtures/batched-golden-*.json`, generated by
279
+ mlx-lm B=N oracle golden (`goldens/batched-golden-*.json`, machine-layered via
280
+ `tests/goldens.ts` — per-GPU overrides in `goldens/<machine>/` — generated by
270
281
  `scripts/gen-batched-golden.py` in the oracle venv).
271
282
 
272
283
  **CPM L1 oracle-verified 2026-06-14d:** mlx-bun's real batched greedy trajectory
@@ -61,16 +61,24 @@ Worked examples:
61
61
  | lever | parity | perf | placement |
62
62
  |---|---|---|---|
63
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) |
64
+ | `perf-kernel` | **envelope-gated, no bit-exact oracle** — mlx-bun-original kernel; gate is ≥56/64 teacher-forced argmax vs *our own* frozen compat trajectory (`perf-kernel-oracle.test`, `freeze-perf-oracle.ts`), and the optiq kv-quant goldens track the **stock unfused L=1 decode** (`regen-kvq-goldens.ts`) | faster | **L3 / explicit opt-in only** (off in bare `--l2`; on in `--l3` and the no-tier default) |
65
+ | `fused-sdpa` | matches optiq bit-for-bit, not mlx-lm (`fused-sdpa.test` tier-a goldens) | faster | **default-on from L2** (the L2 bridge) |
66
66
  | `fused-decode` | (would be L3) | ~3% slower (`optimization_plan.md`) | **kept, off everywhere** |
67
67
 
68
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
69
+ each tier are the **performance** axis (the fastest kernel that still holds it). On
70
+ the *decode path* L2 and L3 now differ by exactly one node — the perf kernel (bare
71
+ `--l2` = the optiq-golden composition: fused N-tiled prefill + stock unfused L=1
72
+ decode; `--l3` adds the envelope-gated fused decode kernel). L3's remaining
73
+ territory is the no-oracle **features** — HLG sampler, expert offload, batched
72
74
  mixed-precision — which live off the decode axis and are gated by KL + quality.
73
75
 
76
+ > History: commit `f1bf5cc` put the perf kernel in the L2 preset claiming
77
+ > "perf-kernel-oracle.test asserts the fused kernel == the frozen optiq goldens".
78
+ > Both halves were wrong — that golden is frozen from mlx-bun's own compat engine,
79
+ > and the gate is argmax-agreement, not equality — so 2026-07-01 restored the tier
80
+ > rule: the bare tier is the guarantee; perf kernels are opt-ins within it.
81
+
74
82
  ## Why this matters for CLI flags
75
83
 
76
84
  We are accumulating a **lot** of flags (`--grad-accum`, `--grad-clip`, `--seg`,
@@ -184,6 +184,21 @@ quant. The trivial case to validate the machinery on a real model.
184
184
 
185
185
  ## 5. The segmentation strategy (the part to get right)
186
186
 
187
+ > **2026-07-02 CORRECTION — this section's cost model is WRONG, measured.**
188
+ > The full-attention isolation planner was built and A/B'd @8K on real chunk
189
+ > rows (scripts/experiments/seg-isolation-smoke.ts, MLX_BUN_SEG_MEM_LOG=1
190
+ > phase probes): ZERO peak win (18.09 vs 18.02 GB seg2). The premise "sliding
191
+ > layers are O(L·window) in the backward" is false — mlx's sdpa BACKWARD
192
+ > materializes the O(L²) scores for EVERY layer (~3.5 GB/layer @8K e4b; the
193
+ > sliding window is only an additive mask; a sliding PAIR costs +7.1 GB ≈
194
+ > sliding+full's +7.15). The worst segment is therefore set by LAYER COUNT
195
+ > alone and `segment_size` is the whole knob: **seg1 = 14.59 GB @8K, +3% step
196
+ > time, loss identical — fits the 24 GB M4 Pro today.** The head vjp adds
197
+ > ~3 GB (full-[M,V] responseOnlyCe — kernel-review backlog #8). Going below
198
+ > ~14 GB needs that head fix plus an O(L)-memory attention backward (flash
199
+ > track), not smarter segmentation. Section kept for the (still-correct)
200
+ > donor/boundary constraints; ignore its sliding-vs-full peak arithmetic.
201
+
187
202
  NOT a naive "divide by N". Compute the split points from the model:
188
203
 
189
204
  **Constraints & inputs (all readable from the model/config):**
@@ -0,0 +1,229 @@
1
+ # Spec decode on larger targets — does the Gemma drafter pay on 12B / 26B?
2
+
3
+ Status: **12B γ=1 is a WIN after the verify fix; matches optiq bit-exactly** (2026-06-14; 26B not run)
4
+ Owner: decode / spec
5
+ Default: **off** (speculation is opt-in; non-spec greedy is untouched)
6
+
7
+ ## Result (2026-06-14, v2) — fixed the wrong-oracle bug; γ=1 now wins
8
+
9
+ The first 12B measurement (v1, below) was made through a verify path that
10
+ targeted the **wrong oracle**. Fixing it changed the conclusion.
11
+
12
+ ### The wrong-oracle bug
13
+
14
+ Assistant-drafter speculation is an **optiq** feature — mlx-lm has only
15
+ generic standalone-`draft_model` two-model spec and structurally cannot
16
+ drive a KV-borrowing drafter. So the correctness oracle is optiq's
17
+ `spec_generate`. **Both optiq and mlx-lm BATCH the verify lm-head** (one
18
+ matmul over the γ+1 window, then argmax per position); neither is bit-
19
+ exact to stock token-at-a-time decode. Our port instead verified
20
+ **per-position** to be bit-exact to *stock decode* — a target no real
21
+ implementation has, and one that re-reads the ~1 GB tied lm-head matrix
22
+ γ+1× per iteration instead of once. So we were exact to the wrong thing
23
+ *and* paying a γ-scaling tax for it. Fix: `picksBatched` in
24
+ `src/spec/generate.ts` — one batched verify lm-head, matching optiq.
25
+
26
+ ### Do we match optiq? YES — bit-exact (e4b)
27
+
28
+ Cross-checked our `specGenerate` against optiq's `spec_generate` on the
29
+ identical prompt ids (`scripts/oracle-spec.py` + `scripts/spec-dump.ts`),
30
+ e4b γ=2: **identical 48-token output AND identical accept/reject trace**
31
+ (drafted 60, accepted 17, target calls 31 in both). Before the fix we
32
+ matched *stock*, not optiq, at knife-edges; now we match optiq.
33
+
34
+ ### Is it still a loss? Not at γ=1 (12B)
35
+
36
+ 12B pair, batched verify (loaded machine — ratios paired/trustworthy,
37
+ absolutes depressed & not quotable):
38
+
39
+ | γ | v2 speedup (batched) | v1 (per-position) | acceptance |
40
+ |---|----------------------|-------------------|-----------|
41
+ | 1 | **1.09× (WIN)** | 0.96× | 42% |
42
+ | 2 | 0.91× (near break-even) | 0.77× | 29% |
43
+ | 3 | 0.72× | 0.67× | 23% |
44
+ | 4 | 0.56× | 0.52× | 17% |
45
+
46
+ Acceptance is unchanged (the fix only touches verify cost, not which
47
+ tokens are accepted). The per-position verify was a large self-inflicted
48
+ tax: removing it flips γ=1 from loss to **win** and moves γ=2 to the edge.
49
+ γ≥2 still loses because the *drafter's own* cost dominates there — the
50
+ 12B drafter is heavy (hidden 1024 vs e4b 256, 16 heads, and a full 262k
51
+ tied-embedding head **every draft step**) and acceptance falls with depth.
52
+
53
+ **So the v1 "hypothesis refuted / net loss at every γ" was itself partly
54
+ an artifact of the wrong-oracle verify.** Corrected conclusion: γ=1
55
+ speculation on 12B is a real ~9% decode win (pending clean-machine
56
+ confirmation before quoting); γ≥2 doesn't pay with the shipped heavy
57
+ drafter.
58
+
59
+ ### Bonus finding: we're ahead of optiq on the unified drafters
60
+
61
+ optiq's *installed* runtime can't actually drive the 12B/26B
62
+ `gemma4_unified_assistant` drafters: its head detection is config-based
63
+ (`use_ordered_embeddings = num_centroids and top_k`), but those artifacts
64
+ declare `num_centroids` while shipping **no** centroid tensors and **no**
65
+ `lm_head` (tied `embed_tokens` instead). optiq loads the centroid path
66
+ with zero-init weights → garbage drafts → **0% acceptance** (measured:
67
+ drafted 46, accepted 0 on 12B). Our `drafter.ts` detects the head by
68
+ **tensor presence** and uses the tied-embedding path → 29% acceptance on
69
+ the same artifact.
70
+
71
+ ### Remaining levers / next
72
+
73
+ - **Clean-machine rerun** before promoting any number to RESULTS.md
74
+ (baseline jumped 15.4→24.4 between runs under memory pressure — exactly
75
+ why only paired ratios are trusted here).
76
+ - **Cheaper drafter head**: the 262k tied-embed argmax every draft step is
77
+ the dominant remaining γ≥2 cost; capping/approximating it (e4b-style)
78
+ could extend the win past γ=1.
79
+ - **26B-MoE**: still unlikely — MoE decode reads only top-8/128 experts
80
+ (~2.4 GB active), so per-token target cost is *lower* than its size →
81
+ harder to beat. Measure if curious.
82
+ - **Optional strict mode**: the old per-position verify gave "spec output
83
+ identical to spec-off" — a product nicety no oracle provides. Could be
84
+ kept as a default-off `strictVerify` flag rather than deleted
85
+ ([[dont-delete-optionality-on-one-measurement]]) — not implemented; ask.
86
+
87
+ ## v1 (superseded) — per-position verify, "net loss at every γ"
88
+
89
+ > Kept for the record. The numbers (0.96/0.77/0.67/0.52×) were real but
90
+ > measured through the wrong-oracle per-position verify; v2 above is the
91
+ > optiq-faithful measurement.
92
+
93
+ ## TL;DR
94
+
95
+ The Gemma `-assistant-bf16` drafters are Gemma's substitute for an MTP
96
+ head: a 4-layer Q-only module that reuses the target's KV + hidden state
97
+ to draft tokens (`src/spec/drafter.ts`), verified by the target's own
98
+ greedy pick (`src/spec/generate.ts`). True MTP (a trained
99
+ `mtp.safetensors` head + an `optiq/runtime/mtp` backend) **does not exist
100
+ for Gemma** and never will — that's the Qwen path (Phase 14). The
101
+ assistant drafter is the functional equivalent we already have.
102
+
103
+ It was measured a **net loss on e4b** (Phase 6): γ=2 ran at 0.78× the
104
+ 54 tok/s baseline, ~23% acceptance — because e4b decode is already too
105
+ fast for a low-acceptance drafter to beat. **This doc is the experiment
106
+ to find out whether the same drafter wins on a slower target**, where
107
+ each skipped target-forward is worth more.
108
+
109
+ ## The hypothesis
110
+
111
+ The drafter's per-step cost is roughly fixed. The *value* of each
112
+ accepted draft = one skipped target forward, which grows as the target
113
+ gets slower per token. e4b is the worst case for speculation (fastest
114
+ target). So the SAME acceptance rate can flip net-positive as the target
115
+ slows:
116
+
117
+ | target | non-spec decode | per-accepted-draft value | drafter | status |
118
+ |--------|-----------------|--------------------------|---------|--------|
119
+ | e4b | ~54 tok/s | low | 160 MB (cached) | **measured net loss** (0.91/0.78/0.63/0.51× @ γ=1–4) |
120
+ | 12B | ~25 tok/s | ~2× e4b | 846 MB (cached) | **measured net loss** — see Result above (hypothesis refuted) |
121
+ | 26B-A4B (MoE) | ~32 tok/s | high (MoE forward) | 839 MB (HF, **not downloaded**) | untested (follow-on; expect loss) |
122
+
123
+ > The reasoning below is the *pre-measurement* hypothesis — kept for the
124
+ > record. It predicted the crossover assuming a fixed-cost drafter; the
125
+ > 12B drafter is not fixed-cost (it scales with the target), which is
126
+ > exactly why the Result section above refutes it.
127
+
128
+ Crossover intuition: a drafter is a net win when
129
+ `acceptance × (target_step_cost − draft_step_cost) > verify_overhead`.
130
+ On e4b the target step is cheap, so the bar isn't cleared at 23%. On 12B
131
+ the target step is ~2× more expensive while the drafter cost barely
132
+ moves — same acceptance, very different sign. **Measure, don't assume**
133
+ (it could still lose; this is exactly the kind of thing the e4b result
134
+ warned against assuming).
135
+
136
+ Acceptance itself may also differ on 12B (a stronger target predicts
137
+ itself differently). We don't know the direction — that's why we run it.
138
+
139
+ ## What is already wired (this change)
140
+
141
+ - **`scripts/spec-bench.ts`** — now multi-pair, **one pair per process**
142
+ (memory-safe; 12B+drafter ≈ 8 GB, 26B ≈ 17 GB, never co-resident):
143
+ - `bun scripts/spec-bench.ts e4b` (default) — reproduces the baseline.
144
+ - `bun scripts/spec-bench.ts 12B` — the experiment.
145
+ - `bun scripts/spec-bench.ts 26B` — follow-on.
146
+ - Auto-detects whether each `(target, drafter)` snapshot pair is
147
+ present; exits with a clear "download with: hf download …" message
148
+ otherwise. Records baseline + γ-sweep (acceptance + tok/s) to the
149
+ eval DB tagged `spec-bench[<id>]`.
150
+ - **`tests/spec-decode.test.ts`** — added a `(12B)` parity block,
151
+ auto-skipped until the drafter is downloaded. Gate = long-prefix
152
+ agreement (greedy spec vs greedy non-spec). It guards the shared
153
+ accept/reject/**rollback + cache-trim** machinery against 12B's
154
+ distinct layer/cache shapes — NOT drafter correctness (see the
155
+ important nuance below). The calibrated e4b block (exact `toEqual` +
156
+ knife-edge) is unchanged.
157
+
158
+ The port (`drafter.ts` + `generate.ts`) is already generic over the
159
+ target's `backbone_hidden_size` — drafters are target-bonded artifacts
160
+ (e4b 2560, 12B 3840, 26B 2816) but there's no per-pair code, just the
161
+ matching weights. Expect to *validate* each cell per the project's
162
+ per-model-specialization reality, not assume it's free.
163
+
164
+ ## Important nuance: a broken drafter still produces correct tokens
165
+
166
+ Speculation can't corrupt output. The target verifies every position and
167
+ replaces every rejected draft with its own greedy pick; drafts only ever
168
+ *skip* target forwards. So a mis-wired drafter (wrong KV borrow, wrong
169
+ projection) shows up as **low/zero acceptance — a perf signal**, not bad
170
+ tokens. This splits the validation cleanly:
171
+
172
+ - **Correctness** (parity test): does the shared accept/reject/rollback
173
+ + cache-trim logic still hold on 12B's cache shapes? A trim bug *would*
174
+ corrupt output, shortly after the first rejection — that's what the
175
+ long-prefix gate catches. (Shape-incompatible KV borrow throws, it
176
+ doesn't silently corrupt.)
177
+ - **Payoff** (bench): is acceptance high enough, and the target slow
178
+ enough, that γ>0 beats the non-spec baseline? This is the actual
179
+ question.
180
+
181
+ ## Procedure (once the 12B drafter is downloaded)
182
+
183
+ 1. `hf download mlx-community/gemma-4-12B-it-assistant-bf16`
184
+ (846 MB; `HF_HUB_DISABLE_XET=1`). The 12B OptiQ-4bit target is
185
+ already cached.
186
+ 2. **Correctness first** — `bun test tests/spec-decode.test.ts`
187
+ (the `(12B)` block now runs). Long-prefix gate must pass for γ=1,2,3.
188
+ If it fails early → rollback/trim bug on 12B caches, fix before
189
+ trusting any number.
190
+ 3. **Calibrate + promote (optional)** — find a tie-free prompt
191
+ on-device, promote the 12B gate from long-prefix to exact `toEqual`
192
+ like e4b, for a stronger standing guard.
193
+ 4. **Measure** — `bun scripts/spec-bench.ts 12B` on a clean machine
194
+ (preflight matters: dirty-machine numbers are garbage). Read
195
+ acceptance + speedup-vs-baseline per γ.
196
+ 5. **Record + decide** — numbers land in the eval DB. Promote into
197
+ `benchmarks/RESULTS.md` only if quotable (clean machine).
198
+ 6. **26B follow-on** — repeat with `26B` if 12B is encouraging; MoE
199
+ target adds a validation cell (donor KV borrow through the MoE
200
+ decoder), and a slower/heavier forward that should favor speculation
201
+ even more — but the MoE forward also reads only top-8/128 experts at
202
+ decode, so the per-token cost advantage is smaller than weight size
203
+ suggests. Measure.
204
+
205
+ ## Exit criteria
206
+
207
+ - 12B spec parity gate green (rollback/trim correct on 12B caches).
208
+ - 12B acceptance + tok/s-vs-baseline recorded in the eval DB across
209
+ γ=1–4.
210
+ - A decision written down: ship speculation as the **default** only for
211
+ the (model, context) cells where it measurably wins; otherwise record
212
+ it as a characterized default-off lever (same disposition as the e4b
213
+ net-loss result — a documented option, never a deletion).
214
+
215
+ ## Caveats / interactions
216
+
217
+ - **Batched serving (Phase 18).** Speculation needs trimmable caches for
218
+ partial-accept rollback — `generate.ts` throws once a `RotatingKVCache`
219
+ ring wraps past the sliding window — and variable accept-lengths per
220
+ slot break a uniform-B batch. Speculation is naturally a **B=1 latency**
221
+ optimization; batched-throughput serving is a different regime. Layering
222
+ both is real work, not free composition.
223
+ - **One serve path, two backends.** The assistant-drafter loop and the
224
+ Qwen MTP runtime are two backends of the *same* "draft γ → verify in one
225
+ forward → accept-longest-prefix → rollback" abstraction. As Phase 14
226
+ (MTP) and Phase 18 (slots) land, build one verify/accept executor, not
227
+ two — the drafter vs MTP-head difference is just what fills the draft.
228
+ - **Denominator discipline.** Speedups are only ever vs the *same model's*
229
+ non-spec baseline. e4b's 54 tok/s is the wrong denominator for 12B.
@@ -0,0 +1,158 @@
1
+ # SSD cold tier for the prompt/KV cache
2
+
3
+ Status: **LANDED P1–P3 (2026-07-02, same day)** — P4 hardening extras remain.
4
+
5
+ **Measured (M1 Max, MiniCPM5, 13.7k-token prefix, kv_config quant):**
6
+ - Restart TTFT: **12,083 ms (full prefill) → 236 ms** restored — 2% of full
7
+ prefill, 13,675 tokens back via a 3 ms zero-copy mmap. Beats oMLX's 1–3 s
8
+ claim and both exit criteria (<2.5 s, ≤10%).
9
+ - Steady-state decode: **0% overhead** (192.0 vs 192.3 tok/s, flag off/on) —
10
+ nothing runs on the token loop, as designed (vs oMLX's measured ~20%).
11
+ - Correctness: SSD-restored + trimmed continuation is **identical to the RAM
12
+ tier's cache-hit output** (control experiment); divergence from a fresh
13
+ full prefill is the pre-existing bf16 prefix-reuse property both tiers
14
+ share, not introduced by persistence.
15
+
16
+ What landed: kv-store v2 (`src/kv-store.ts` — all five cache kinds incl.
17
+ quantized + SSM, invalidation metadata, per-tensor hashes, atomic writes;
18
+ `restoreState` added to both quantized cache classes), `src/ssd-cache.ts`
19
+ (SsdCacheStore per D4), tier integration (`src/prompt-cache.ts` spill hook +
20
+ retain thunks; `src/server.ts` tiered take with post-restore trim,
21
+ debounced write-behind snapshots under the gateway lock, `/stats.ssd_cache`),
22
+ flags `--ssd-cache <dir>` / `--ssd-cache-max <GB>` / `--ssd-cache-verify`.
23
+ Tests: `tests/kv-store.test.ts` (fork-compare token-identical for bf16,
24
+ quantized, SSM), `tests/ssd-cache.test.ts` (restart recovery, quarantine,
25
+ corruption, eviction, supersede). One deviation from the plan below: find()
26
+ returns PARTIAL prefix matches and the server trims after restore
27
+ (divergent-tail conversations — full-entry-only missed the real pattern). Inspired by oMLX's paged SSD cache
28
+ (Apache 2.0 — `omlx/cache/paged_ssd_cache.py`, `boundary_snapshot_store.py`;
29
+ we port ideas, not code — add a `THIRD_PARTY_LICENSES.md` note if any code
30
+ ends up derived). Their headline: coding-agent TTFT 30–90 s → 1–3 s by
31
+ restoring long prefixes from SSD across requests AND restarts. Their
32
+ measured costs (independent tests): ~2–3× cold TTFT and ~20% steady-state
33
+ decode overhead — this design exists to get the win without those costs.
34
+ Flag-gated, off by default.
35
+
36
+ **Head start**: `src/kv-store.ts` already implements page-aligned KV
37
+ persistence (`MLXBUNKV1`, 16 KiB-aligned tensors) with zero-copy COW mmap
38
+ reload (`MmapFile.open(path, "cow")` → `MlxArray.fromPointer` — exactly
39
+ the Metal page-alignment constraint, proven by expert-offload), including
40
+ mid-rotation `RotatingKVCache` restore (`restoreState`,
41
+ `src/model/gemma4-base.ts:880`). `scripts/experiments/cold-start.ts` is a
42
+ working sub-1 s cold-restore harness. The SSD tier is wiring +
43
+ quantized-cache support + an index/eviction/recovery layer.
44
+
45
+ ## Current state / gaps
46
+
47
+ - `src/prompt-cache.ts`: byte-capped LRU RAM tier (default 2 GB,
48
+ `--prompt-cache`), prefix-entry based, adapter-namespaced, serial lane
49
+ only, race-free under the gateway lock. **A restart loses everything.**
50
+ - `kv-store.ts` gaps: (1) no quantized kinds — `saveKvCache` throws for
51
+ `QuantizedKVCache`/`RotatingQuantizedKVCache` and neither has
52
+ `restoreState`, yet serving defaults to the model's `kv_config.json`
53
+ scheme, so the default production cache can't persist today; quantized
54
+ state is 6 arrays/layer (packed u32 + scales/biases bf16 × K/V);
55
+ (2) no invalidation metadata / checksums / atomic writes; (3) latent
56
+ bug: `loadKvCache` checks `caches.length !== model.layers.length` but
57
+ `makeCache()` returns donor layers only — wrong for KV-shared e4b
58
+ (verify in Phase 1).
59
+ - Versioning primitive exists: `configFingerprint`
60
+ (`src/model/fingerprint.ts`) — sha256 over graph-shaping config incl.
61
+ `kvQuant`.
62
+
63
+ ## Design decisions
64
+
65
+ **D1 — Whole-prefix-entry spill, not paged blocks.** oMLX pages for
66
+ multi-user shared-prefix dedup; we are single-user with a prefix-entry
67
+ cache that grows monotonically per conversation. Paging is exactly where
68
+ their overheads come from: per-block SHA256 + save-queue bookkeeping
69
+ *during decode* (their ~20% tax) and block-granular safetensors restore
70
+ with copies (their 2–3× cold TTFT). Ours: **zero work on the token loop**
71
+ (prefix tokens ARE the key), spill/persist only at request boundaries and
72
+ evictions, restore = one zero-copy mmap with lazy page fault-in (~5 GB/s
73
+ NVMe; 2 GB entry ≈ 0.4 s amortized into the first forward). Partial-prefix
74
+ reuse works without paging: restore + `Cache.trim()`. Revisit paging only
75
+ for multi-user batch dedup (v2, out of scope).
76
+
77
+ **D2 — One store, two tiers.** `PromptCache` grows a cold tier behind the
78
+ same `take`/`put` API:
79
+ - RAM→SSD: `put()`'s evict loop spills (snapshot bytes → async writer →
80
+ dispose GPU arrays) instead of just disposing.
81
+ - SSD→RAM: `take()` prefers a strictly-longer SSD prefix; restored entry
82
+ carries its `MmapFile`, unmapped on entry dispose (extensions copy into
83
+ fresh anonymous buffers via concat-grow anyway).
84
+ - **Restart persistence needs write-behind at request completion**, not
85
+ just spill-on-evict: after `promptCache.put(...)` in `runGeneration`
86
+ (`src/server.ts:1019`), a debounced, coalesced-per-ns async snapshot.
87
+ Byte extraction (the only GPU-thread work) runs on the idle serial lane
88
+ between requests; file I/O is async. (oMLX's boundary-snapshot idea at
89
+ whole-entry granularity.)
90
+
91
+ **D3 — Extend the existing format to `MLXBUNKV2`**, not safetensors
92
+ (safetensors offsets aren't page-aligned; oMLX pays a copy via `mx.load`).
93
+ Add: `"qkv"`/`"rotating-qkv"` kinds (six tensor slots + groupSize/bits;
94
+ slice quantized buffers to `[.., :offset, :]` — don't persist 256-step
95
+ slack), header fields `formatVersion`/`modelId`/`configFingerprint`/`ns`/
96
+ `tokenizerHash`/`createdAt`, per-tensor checksums + header checksum.
97
+ Integrity: header checksum always verified on scan; full verification
98
+ opt-in (`--ssd-cache-verify`) because eager reads destroy lazy fault-in;
99
+ default trusts atomic writes (tmp + fsync + rename). Corrupt/incompatible
100
+ → delete + log, never fatal. `loadKvCache` becomes model-generic
101
+ (accept `{ makeCache(): Cache[] }`, donor-count check).
102
+
103
+ **D4 — Files are the database.**
104
+ `<ssd-dir>/<configFingerprint>/<nsHash>/<uuid>.mlxkv`. Startup recovery =
105
+ header-only directory scan → in-memory `SsdIndex`
106
+ (`{ns, tokens, path, bytes, mtimeMs}[]`); tokens live in the header JSON.
107
+ LRU = file mtime (`utimes()` on hit); byte cap enforced at write time by
108
+ oldest-mtime eviction; disk-full → drop write, warn once, keep serving.
109
+
110
+ **D5 — Flags & interactions.** `--ssd-cache <dir>` +
111
+ `--ssd-cache-max <GiB>` (default 32) → `ServerOptions.ssdCacheDir/MaxBytes`.
112
+ Requires the RAM tier (`--prompt-cache 0` + `--ssd-cache` = startup
113
+ error). `--memory-budget` unchanged (restored caches are clean file-backed
114
+ pages until dirtied; admission already assumes worst-case bf16). `/status`
115
+ gains `ssd: { entries, bytes, max_bytes, restores, spills, restore_ms_last }`.
116
+ v1 is serial-lane only (where `PromptCache` lives); batch-lane interplay is
117
+ docs/design/batching-perf-path.md P3's problem.
118
+
119
+ ## Phases
120
+
121
+ - **P0 — Baseline bench (½ d)**: `scripts/experiments/ssd-cache-bench.ts`
122
+ grown from `cold-start.ts`: ≥16k-token prefix on the standard snapshot,
123
+ kv_config quant active; measure full-prefill TTFT / warm RAM-hit TTFT /
124
+ steady-state decode. Exit: baseline table committed.
125
+ - **P1 — kv-store v2 (2–3 d, highest uncertainty)**: `restoreState` on
126
+ both quantized cache classes; all four kinds in save/load; header v2;
127
+ atomic writes; model-generic load. Exit: round-trip tests all kinds
128
+ (incl. wrapped ring) + **greedy decode parity** — N tokens continued
129
+ from a restored cache bit-exact vs never-persisted; `tsc` 0.
130
+ - **P2 — `src/ssd-cache.ts` store (1–2 d)**: `SsdCacheStore
131
+ { scan, find(prompt, ns), store (async, extraction split from I/O),
132
+ evictToCap, remove, stats }`. Exit: unit tests — restart recovery,
133
+ incompatible-fingerprint quarantine, corrupt-header deletion, mtime cap
134
+ eviction, disk-full soft-fail.
135
+ - **P3 — Tier integration + flags (2–3 d)**: cold tier in `PromptCache`,
136
+ debounced end-of-request snapshots, CLI/`/status`. **Exit (headline)**:
137
+ (1) restart TTFT on a 16k prefix **< 2.5 s and ≤ 10% of full-prefill**
138
+ (beat oMLX's 1–3 s; a true-cold miss runs the unchanged prefill path);
139
+ (2) steady-state decode overhead **< 2%** vs flag off (vs their ~20%);
140
+ (3) flag absent ⇒ zero behavior change.
141
+ - **P4 — Hardening + docs (1–2 d)**: kill-during-write crash test,
142
+ adapter-ns isolation, kv-quant-scheme-change invalidation, README/help,
143
+ license note. Exit: suite green; on/off × cold/warm/restart matrix
144
+ recorded.
145
+
146
+ Total ~7–10 working days.
147
+
148
+ ## Risks
149
+
150
+ - **mmap lifetime vs mlx laziness**: unmap only in entry `dispose()` after
151
+ successor state is evaluated; COW mapping already covers buffer donation.
152
+ - **Byte-extraction stall on multi-GB entries**: runs between requests on
153
+ the idle lane; measure in P3; chunk with yields if needed.
154
+ - **COW dirty-page accounting**: extended restored caches migrate to
155
+ anonymous memory — probe with `phys_footprint` (expert-offload
156
+ discipline) before making footprint claims.
157
+ - **Stale-token poisoning**: `configFingerprint` + `tokenizerHash` + ns;
158
+ chat-template drift degrades to a shorter matched prefix (safe).
@@ -62,7 +62,7 @@ The window renders the **same page** `mlx-bun serve` serves today
62
62
  app exit. One generation runs at a time server-side, so no client
63
63
  concurrency to manage.
64
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
65
+ (`--port`) and the webview URL, to avoid 8080 collisions with a
66
66
  user's own `mlx-bun serve`.
67
67
  4. **Readiness gate.** Show a lightweight "starting…/downloading model…"
68
68
  splash until `/v1/models` returns 200. Warm start ~0.4s; first-ever
@@ -99,7 +99,7 @@ then `xcrun stapler staple`.
99
99
 
100
100
  ## Milestones
101
101
 
102
- 1. **Spike** — `tauri init` in a new repo; hardcode port 8090; spawn a
102
+ 1. **Spike** — `tauri init` in a new repo; hardcode port 8080; spawn a
103
103
  locally-built `mlx-bun serve --no-open`; load `/#/chat` in the window;
104
104
  `cargo tauri dev`. Proves the wrap end-to-end.
105
105
  2. **Robust runtime** — ephemeral port; readiness poll + splash;