mlx-bun 0.0.9 → 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.
@@ -12,7 +12,7 @@ area reviews + full mechanisms): `reports/kernel-perf-review-2026-07-01.json`
12
12
  vendored MLX steel machinery, exact-by-default backward with autograd + FD
13
13
  parity — marred by decision drift: the PLAN-decided 1.35× filter win was
14
14
  never landed and the in-code comments argued three contradictory positions
15
- (comments fixed 2026-07-01; the FLIP itself is still backlog #1).
15
+ (comments fixed 2026-07-01; the FLIP LANDED 2026-07-02 — see backlog #1).
16
16
  - **Training flash-attention kernel**: faithful port with exemplary post-bug
17
17
  discipline, correctly opt-in — but it copies a naive oracle (~30× slower)
18
18
  and its tests covered none of the production configs (extended 2026-07-01:
@@ -47,35 +47,109 @@ evalSftLoss error swallowing · tokens_per_sec metric · inverted comments
47
47
  (gemma4.ts:298, flash-cce filter trio) · silent steel fallback (now warns;
48
48
  H%128 throw narrowed) · flash-attn test blindness.
49
49
 
50
- STILL OPEN: **FUSED_DECODE=1 inside whole-graph compiled decode** bakes the
51
- tile loop at trace-time N — q-cat adapters grow N per step under shapeless
52
- replay, so the newest KV rows are silently never attended (gemma4-base.ts
53
- quantizedSdpaTiled; deferred for file-ownership at fix time — guard the combo
54
- or make the tile loop trace-safe).
50
+ FIXED 2026-07-02: **FUSED_DECODE=1 inside whole-graph compiled decode** baked
51
+ the tile loop at trace-time N — q-cat adapters grow N per step under shapeless
52
+ replay, so the newest KV rows were silently never attended. REPRODUCED on e4b
53
+ (42 layers traced tile=true at N=17; diverges within 3 tokens then
54
+ repetition-loops). The 12B was never affected — its segmented form runs concat
55
+ layers as uncompiled js layers. Fix: generate() refuses to compile under
56
+ MLX_BUN_FUSED_DECODE=1 (explicit opt-in wins, like LoRA/MoE) + quantizedSdpa
57
+ throws if the tiled path is ever selected inside a trace (lands in the
58
+ transactional fallback, never silent). Regression in
59
+ tests/compiled-decode.test.ts, whose e4b block also now uses the dynamic
60
+ snapshot resolver — the old hard-coded hash silently skipped it on this
61
+ machine, which is how the combo went unexercised.
62
+
63
+ NEW BUG (found 2026-07-02 by the backlog-#8 gate test; FIXED in mlx-bun,
64
+ UPSTREAM in mlx): **mx.quantized_matmul(transpose=false) returns wrong values
65
+ at row count 2 or 3** — relnorm ~0.33-0.39 on our dylib, ~0.98-1.14 on stock
66
+ mlx 0.31.2 (oracle venv repro); M=1 and M>=4 exact, transpose=true exact at
67
+ every M. mlx's autograd computes dh for a transpose=true head qmm via an
68
+ internal transpose=false call at the cotangent's row count, so EVERY training
69
+ head silently produced wrong dh for 2-3-token responses, 2-3-token tail
70
+ chunks, and tiny prompt spans (f32 ground truth: dh relnorm up to 0.99 at the
71
+ affected positions, cos ~0.2). Serving/decode unaffected (transpose=true).
72
+ Fix: (a) loss.ts `logitsFromHiddenPadM` pads differentiated head spans to 4
73
+ rows (pad cotangents are exactly zero) — applied at all seven head sites;
74
+ (b) ops.quantizedMatmul pads direct transpose=false calls (the fused head's
75
+ analytic backward). Verified vs f32-dequant ground truth (chunk-4 dh
76
+ 0.47→0.006) + M-sweeps. Worth filing upstream with the 10-line python repro.
55
77
 
56
78
  ## The ranked optimization backlog (task #12)
57
79
 
58
- 1. **[S] Land the coeff filter at eps~1e-5** (+ measure blockMax skip on real
59
- data) — 1.35× ORPO head backward, ALREADY MEASURED
60
- (flash-cce-filter-realdata.ts, PLAN.md "filter-on-real-data"); gates:
61
- E4B=1 rerun + teacher-forced grad fidelity + eps=0 byte-identical.
62
- 2. **[M] planSegments full-attention isolation** — e4b@8K ~17.5→10 GB;
63
- unblocks 8K training on the 24 GB M4 Pro.
64
- 3. **[S] Auto-dispatch the training head by M** — exact fused QM head for
65
- short-M (~1.9×: 481 ms vs 934 ms at e4b M=512), flash only when memory
66
- demands; both heads already share the fusedRespLogpMean interface.
67
- 4. **[S] Fused decode full-capacity KV buffers + activeN** — kills 6
68
- ensureRowContiguous copies/layer/step; 1-3% @8k, ~4× that at 32k.
80
+ 1. ~~**[S] Land the coeff filter at eps~1e-5**~~ **LANDED 2026-07-02** — BOTH
81
+ skips default 1e-5 (coeff filter + blockMax block skip; env=0 restores exact).
82
+ All gates run on the M1 Max with real chunk-ORPO data
83
+ (flash-cce-filter-realdata.ts, extended: E4B=1 + blockEps sweep + Vjp
84
+ full-logits fidelity): filter CPM5 0.343%/1.41×, e4b 0.158%/1.70×; blockMax
85
+ skip ~lossless (≤0.004%) and real text DOES go cold (the synthetic probe was
86
+ the artifact) — **combined 1.71× (CPM5) / 3.16× (e4b)**; teacher-forced
87
+ fidelity cosine ≥0.99993, filter adds ≤0.07% over the eps=0 reassociation
88
+ floor (standing test in tests/train-orpo-fused-ce.test.ts). The "eps=0
89
+ byte-identical" gate resolved structurally: eps=0 compiles the identical
90
+ pre-flip kernel, but run-to-run dh is never byte-stable (atomic-add
91
+ reassociation) — byte-replay was the wrong spelling of that gate.
92
+ 2. ~~**[M] planSegments full-attention isolation**~~ **REFUTED BY MEASUREMENT
93
+ 2026-07-02** — built, A/B'd @8K on real chunk rows: ZERO peak win (18.09 vs
94
+ 18.02 GB; scripts/experiments/seg-isolation-smoke.ts + MLX_BUN_SEG_MEM_LOG
95
+ phase probes). The §5 cost-model premise is false: mlx's sdpa BACKWARD
96
+ materializes O(L²) scores for EVERY layer (~3.5 GB/layer @8K e4b — the
97
+ sliding window is just an additive mask; sliding pair +7.1 GB ≈ full+sliding
98
+ +7.15). Worst segment = layer count alone, so **segment_size is the whole
99
+ knob: seg1 measures 14.59 GB @8K (+3% step time, loss identical) — which
100
+ DOES fit the 24 GB M4 Pro today, no code needed.** Inter-segment clearCache
101
+ also measured no-effect (watermark is live memory). Real next levers: the
102
+ head vjp's ~3 GB [M,V] spike (backlog #8) and an O(L)-memory attention
103
+ backward (the flash-attention track).
104
+ 3. ~~**[S] Auto-dispatch the training head by M**~~ **LANDED 2026-07-02** —
105
+ fusedRespLogpMean routes requested-flash batches with M <
106
+ MLX_BUN_FLASH_MIN_M (default 1024) to the exact fused head. Fresh crossover
107
+ sweep (head-dispatch-sweep.ts, M1 Max — the review's 1.9× @M=512 was
108
+ pre-filter-flip): fused wins e4b M=64 1.6× / M=512 1.13×, tie @1024; the
109
+ #1-flip made flash FASTER at long M too (e4b ≥2048; 8K 10.7 vs 13.2 s) on
110
+ top of its memory win. CPM5: fused faster at every M — flash stays a pure
111
+ memory lever there (≥1024 honors the caller's intent). Dispatch proven by
112
+ bit-identical-to-fused loss in tests/train-orpo-fused-ce.test.ts.
113
+ 4. ~~**[S] Fused decode full-capacity KV buffers + activeN**~~ **REFUTED BY
114
+ MEASUREMENT 2026-07-02** — the variant was built (an int32 activeN input,
115
+ capacity-strided rows, fetch attaches full buffers), gated BYTE-IDENTICAL
116
+ on all three recorded dispatch shapes and perf-oracle-green — and then
117
+ measured SLOWER end-to-end: interleaved in-process A/B on the 12B uniform
118
+ kv4 (tokens identical across arms, M1 Max) gave 24.64 vs 26.18 tok/s @8k
119
+ (0.94) and 23.07 vs 23.83 @22k (0.97) — the "~4× at 32k" scaling is
120
+ refuted too. Two holes in the estimate: the 12B's full-attention dispatch
121
+ shape is KV=1 (H16 D512), where the fetch view is a CONTIGUOUS PREFIX and
122
+ the six-copies model never applied; and kernel-only micro-A/Bs proved
123
+ unstable (±50% run-to-run) — the numbers behind "1-3%" were never
124
+ end-to-end. REVERTED; evidence + re-application recipe:
125
+ scripts/experiments/fused-decode-activen-ab.ts.
69
126
  5. **[M] Collapse per-token host syncs in the spec/DSpark loop** — ~2γ+1
70
127
  round-trips → ~2 per verify cycle; directly attacks DSpark's fixed draft
71
128
  overhead.
72
129
  6. **[M] Pipeline the batched decode loop + clearCache cadence** — folded into
73
130
  the batching-v2 wave (docs/design/batching-v2-plan.md).
74
131
  7. **[S] Enable the 26B perf kernel** (open PLAN Phase E box; oracle frozen).
75
- 8. **[S] Bound the SFT segmented head** — replace full-[M,V] responseOnlyCe in
76
- the segmented head vjp; ~2-4 GB peak on long-response e4b SFT.
77
- 9. **[S] Segmented-step overhead pass** — detachLeaf single-copy, one evalAll
78
- barrier/segment, memoize the CPM5 prefix block-sparse mask.
132
+ 8. ~~**[S] Bound the SFT segmented head**~~ **LANDED 2026-07-02** — both SFT
133
+ segmented classes now use boundedSftCe (token-chunked Checkpoint head, the
134
+ proven ORPO plumbing; MLX_BUN_SEG_HEAD/MLX_BUN_SEG_HEAD_CHUNK knobs).
135
+ Head-only A/B at e4b M=6000: whole-vocab head 16.60 GB peak vs bounded
136
+ 6.60 GB (**~10 GB head-vjp transient eliminated**), loss identical, dh
137
+ relnorm 0.00000; head time +45% (Checkpoint recompute, small share of a
138
+ step). The @8K smoke's short responses were never head-bound (peak set by
139
+ the per-layer sdpa backward — see #2), so its wall peak moved little; the
140
+ win is exactly the long-response regime the review named. Gate test:
141
+ tests/train-orpo-fused-ce.test.ts "bounded SFT head".
142
+ **Landing this exposed a NEW upstream mlx bug (see ledger below).**
143
+ 9. ~~**[S] Segmented-step overhead pass**~~ **LANDED 2026-07-02** — all three
144
+ fixes: MlxArray.detachCopy (ONE mlx→mlx copy; the old rawBytes+
145
+ fromBytesCopy route was two copies via the JS heap), detachLeaves (ONE
146
+ evalAll barrier per segment's whole vjp set, applied in all six segmented
147
+ backward loops), and a step-shared block-sparse-mask memo for the CPM5
148
+ prefix classes (prefixSharedCaches — makeMask hands out slice views).
149
+ Gate: loss + grads BYTE-IDENTICAL on CPM5-SFT / CPM5-ORPO-prefix / e4b-SFT
150
+ fixed steps. Step time (short-seq fixtures, M1 Max): CPM5 ORPO-prefix
151
+ 238→158 ms (−34%), e4b SFT 2065→1272 ms (−38%); e4b @8K flat (GPU-bound,
152
+ as predicted) with peak unchanged (14.49 GB).
79
153
  10. **[M] e4b prefill gap** — profile-first; NOTE: the decode look-again could
80
154
  NOT reproduce RESULTS.md's 304-vs-373 row on the M1 Max (no eval-DB
81
155
  backing) — re-bench on the M4 Pro before building anything.
@@ -85,8 +159,13 @@ or make the tile loop trace-safe).
85
159
  The review's framing assumed the megakernel post-mortem's "M=1 decode is at
86
160
  the floor". The follow-up investigation (docs/investigations/
87
161
  decode-roofline-lookagain.md) OVERTURNED that: only the 12B is at the wall
88
- (~92-93% of measured roofline); CPM5 58-64%, e4b 64-70%, 26B 60-62%, with the
89
- host JS graph build as the #1 recoverable term. Its ranked fixes (CPM5
90
- compiled-decode port; build-ahead graph overlap spike; 26B gather-qmm
91
- bandwidth; e4b dispatch batching) COMPOSE with the backlog above and should be
92
- sequenced first where they overlap.
162
+ (~92-93% of measured roofline); CPM5 58-64%, e4b 64-70%, 26B 60-62%.
163
+ **2026-07-02 second correction (lookagain §7):** the "host JS graph build =
164
+ #1 recoverable term" part of that verdict was itself wrong — the overlap
165
+ spike (decode-overlap-probe.ts, spin-injection + serial anchor) proved the
166
+ pipelined loop ALREADY hides the build; wall = GPU step time. The lookagain's
167
+ host-side fixes (1a–1d: CPM5 compiled-decode port, build-ahead spike, SharedKv
168
+ segmented-compile, 12B concat-phase compile) are refuted or de-prioritized;
169
+ its GPU-side fixes (26B gather-qmm bandwidth, e4b dispatch batching, CPM5
170
+ KV-path, backlog #4 contiguity, spec decode) are now the ENTIRE recoverable
171
+ gap and still compose with the backlog above.
@@ -0,0 +1,57 @@
1
+ # mlx-bun v0.0.10 — batching parity, SSD KV cache, sharper serving
2
+
3
+ ## Highlights
4
+
5
+ **Concurrent serving now matches the fastest MLX batching servers.** With
6
+ `--batch 4`, aggregate throughput at 4 parallel requests is at parity with
7
+ (or ahead of) oMLX on every shared model we benchmarked on an M1 Max —
8
+ MiniCPM5-1B 349 vs 339 tok/s, gemma-4-e4b within 3%, Qwen3.5-4B within 1% —
9
+ with 2–3× better time-to-first-token under load, and per-token SSE
10
+ streaming (no burst batching of your tokens).
11
+
12
+ - **Qwen3.5 joins the batch lane.** Hybrid gated-DeltaNet (SSM) caches grew
13
+ the dynamic-batching ops; the batched decode path is verified
14
+ token-for-token identical against mlx-lm's B=2 batched oracle.
15
+ - **Sampler features batch too.** Repetition/presence/frequency penalties,
16
+ `logit_bias`, `min_p`, and XTC now run per-row inside the batch instead
17
+ of forcing requests onto the serial path. (Qwen3.5 ships a default
18
+ repetition penalty — before this fix it silently serialized every
19
+ request under `--batch N`.)
20
+
21
+ **SSD cold tier for the prompt cache** (`--ssd-cache <dir>`). Long-context
22
+ prefix KV now spills to disk on eviction, is snapshotted after requests
23
+ settle, and **survives server restarts**: re-attaching a 13.7k-token
24
+ coding-agent conversation went from a 12.1 s full re-prefill to a 0.24 s
25
+ zero-copy mmap restore in our measurements — with 0% steady-state decode
26
+ overhead (nothing runs on the token loop). Entries are keyed by model
27
+ fingerprint + KV scheme + tokenizer hash + adapter namespace; corrupt or
28
+ incompatible files self-quarantine; the tier is always safe to delete.
29
+ Companion flags: `--ssd-cache-max <GB>` (default 32), `--ssd-cache-verify`.
30
+
31
+ ## Fixes
32
+
33
+ - `serve`/`benchmark` now honor `--model <path-or-query>` as an explicit
34
+ override (it was previously accepted but ignored — auto-pick could
35
+ silently load a different model). A directory containing `config.json`
36
+ loads directly from that path.
37
+ - The server stays responsive during long serial generations: `/stats`,
38
+ `/health`, and new connections previously stalled until the generation
39
+ finished (measured 2.5 s on a 512-token run); they now answer in tens of
40
+ milliseconds, with no measurable decode cost.
41
+
42
+ ## Internals
43
+
44
+ - KV persistence format v2: all cache kinds round-trip (bf16, per-layer
45
+ quantized, sliding-window, quantized sliding-window, SSM), with
46
+ integrity hashes, invalidation metadata, and atomic writes.
47
+ - Golden `.bin` parity fixtures are no longer tracked in git (they are
48
+ machine-specific and regenerable); JSON manifests remain.
49
+ - New design docs: `docs/design/omlx-adoption-map.md` (feature adoption
50
+ scoreboard), `docs/design/batching-perf-path.md`,
51
+ `docs/design/ssd-kv-cold-tier.md`.
52
+
53
+ Full details and measured numbers: STATUS.md and the design docs above.
54
+ Credit where due: the SSD-tier and batching comparisons were driven by
55
+ studying [oMLX](https://github.com/jundot/omlx) (Apache 2.0) — a strong
56
+ project whose ideas we benchmarked and adapted where they made mlx-bun
57
+ faster.
@@ -23,6 +23,9 @@ the design rationale is in
23
23
  | `--port` | n | `8080` | Listen port. A pre-flight probe refuses to start if the port is already serving. |
24
24
  | `--memory-budget` | GB | machine RAM × 0.75 | Admission ceiling. Requests whose `prompt + max_tokens` exceed the budget's max safe context are **rejected with 400** (`type: memory_admission`) instead of risking an uncatchable GPU OOM. Also caps the mlx allocator (`mlx_set_memory_limit`) as defense in depth. **Decimal GB (×10⁹).** |
25
25
  | `--prompt-cache` | GB | `2` (≈2e9 bytes) | Byte cap for the prompt (prefix-reuse KV) cache. **Binary GiB (×2³⁰)** on input. Unset ⇒ the 2 GB default; an explicit `--prompt-cache 0` **disables** the cache (maxBytes 0 evicts every entry). |
26
+ | `--ssd-cache` | dir | off | SSD cold tier under the prompt cache ([docs/design/ssd-kv-cold-tier.md](../design/ssd-kv-cold-tier.md)): prefix KV spills to disk on RAM eviction, is snapshotted after requests settle (debounced 1 s), and **survives restarts** — a long-context agent re-attach restores via zero-copy mmap instead of re-prefilling (measured 13.7k-token prefix: 12 s → 0.24 s TTFT; 0% decode overhead). Entries are keyed by model fingerprint **+ effective kv scheme** + tokenizer hash + adapter ns; incompatible/corrupt files self-quarantine. Serial lane only. Requires the RAM cache. |
27
+ | `--ssd-cache-max` | GB | `32` | SSD tier byte cap (binary GiB); oldest-mtime entries evicted at the cap. |
28
+ | `--ssd-cache-verify` | (bool) | off | Verify every tensor hash on restore. Reads all bytes eagerly (defeats lazy page fault-in) — integrity paranoia only; the header hash is always verified. |
26
29
  | `--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). `--decode-concurrency` is accepted for drop-in compatibility, but the semantics differ: in mlx_lm.server it caps per-BatchGenerator decode parallelism (default 32); in mlx-bun it enables continuous batching with this cap (mlx-bun's default is the optimized serial path). |
27
30
  | `--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
31
  | `--adapter` | dir | none | Mount a LoRA adapter at startup (same machinery as `POST /v1/adapters`; the adapter id is the directory's basename) and make it the **default** for requests that send no `adapter` field. A request's explicit `adapter` — including `"none"` — always wins, and hot-swap via `/v1/adapters` is unchanged. `--adapter-path` is accepted as the mlx_lm.server-named alias. A bad adapter fails startup loudly rather than silently serving the base model. This is the flag `mlx-bun train`'s completion message points at. |
@@ -132,6 +132,11 @@ piece independently toggled and each falling back cleanly:
132
132
  - **`orpo_flash_ce`** — the [M,vocab]-free flash-CCE Metal head (steel GEMM fwd
133
133
  **and** bwd; e4b backward 754 ms, peak 0.93 GB flat at M=8192). Implies the
134
134
  fused head. Falls back to the MLX fused head when the tiling isn't clean.
135
+ **Auto-dispatch by M** (2026-07-02): batches with M below `MLX_BUN_FLASH_MIN_M`
136
+ (default 1024) take the exact MLX fused head instead — it is faster at short
137
+ M (e4b M=64 1.6×, M=512 1.13×; CPM5 at every M), while flash wins memory
138
+ everywhere and time at long M on e4b (M≥2048; 8K: 10.7 vs 13.2 s with the
139
+ filter/blockMax defaults). `MLX_BUN_FLASH_MIN_M=0` always honors flash.
135
140
  - **`segment_size`** — segmented backward (gradient-checkpointed layer
136
141
  activations) for long context; the head is ~free in memory, the activations
137
142
  are the wall.
@@ -184,7 +189,7 @@ are `DEFAULT_TRAIN_CONFIG` (trainer.ts:89).
184
189
  | `orpo_lr_schedule` | `constant` \| `cosine` | `cosine` | ORPO LR schedule (orpo only) |
185
190
  | `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) |
186
191
  | `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) |
187
- | `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) |
192
+ | `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 (e4b 0.93 GB flat @ M=8192). Implies the fused head. The Apple-CCE coeff filter + blockMax skip default ON at 1e-5 (2026-07-02; combined bwd 1.71× CPM5 / 3.16× e4b vs exact — `MLX_BUN_CCE_BWD_FILTER_EPS=0` / `_BLOCK_EPS=0` restore exact). Batches with M < `MLX_BUN_FLASH_MIN_M` (default 1024) auto-take the exact fused head (faster at short M; `=0` always honors flash). B=1 (orpo only) |
188
193
  | `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) |
189
194
  | `sft_scope` | `full` \| `response` | `full` | Scope of ORPO's chosen-NLL term. `full` (paper/TRL-faithful): token-mean CE over the **full prompt+response** (only padding excluded), from the same chosen forward. `response`: `L_NLL = −ℓ_w` — reproduces pre-2026-07 runs bit-exactly. The odds-ratio `ℓ_w/ℓ_r` are response-only in both modes. Applies to every ORPO path (naive/chunked/fused/flash/prefix/segmented) (orpo only). CLI spelling: `mlx-bun train … --sft-scope full\|response` |
190
195
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mlx-bun",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
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",
package/src/cli.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  // mlx-bun evals recent benchmark runs
10
10
  // mlx-bun harness pi [--base-url <url>] [--remove] connect your own pi to the local server
11
11
 
12
+ import { existsSync } from "node:fs";
13
+ import { basename, join, resolve } from "node:path";
12
14
  import { Registry } from "./registry";
13
15
  import { loadModelConfig } from "./config";
14
16
  import { fit, skuMatrix, thisMachine } from "./fit";
@@ -66,6 +68,10 @@ Examples:
66
68
  mlx-bun get mlx-community/gemma-4-12B-it-OptiQ-4bit`;
67
69
 
68
70
  const SERVER_FLAGS = `Server options:
71
+ --model <path|query> Explicit model override (mlx_lm.server spelling):
72
+ a directory containing config.json loads from that
73
+ path; anything else resolves like the positional
74
+ query. Wins over the positional and auto-pick.
69
75
  --host <addr> Interface to bind [default: 127.0.0.1, loopback
70
76
  only; use 0.0.0.0 to expose on your network]
71
77
  --port <n> Listen port [default: 8080]
@@ -74,6 +80,14 @@ const SERVER_FLAGS = `Server options:
74
80
  GPU [default: machine RAM × 0.75, check-only]
75
81
  --prompt-cache <GB> Prompt (KV) cache byte cap [default: 2 GB;
76
82
  --prompt-cache 0 disables the cache]
83
+ --ssd-cache <dir> SSD cold tier for the prompt cache: prefix KV
84
+ spills to disk on eviction and persists across
85
+ restarts (long-context agent TTFT drops from a
86
+ full re-prefill to a zero-copy mmap restore).
87
+ Off unless set.
88
+ --ssd-cache-max <GB> SSD tier byte cap [default: 32 GB]
89
+ --ssd-cache-verify Verify tensor hashes on every restore (reads all
90
+ bytes eagerly — integrity paranoia only)
77
91
  --batch <n> Max concurrent requests batched through the
78
92
  mlx-lm-parity engine [default: 1 = serial].
79
93
  >1 opts the whole server into bf16 continuous
@@ -285,6 +299,9 @@ and runs the mlx-lm/optiq comparison legs.
285
299
  positional) [default: auto-pick]
286
300
 
287
301
  Options:
302
+ --model <path|query> Explicit model override: a directory containing
303
+ config.json loads from that path; anything else
304
+ resolves like the positional query. Wins over both.
288
305
  --tokens <n> Tokens to decode per run [default: 256]
289
306
  --runs <n> Runs (median reported) [default: 3]
290
307
  --prompt-tokens <n> Pad the prompt to ~n tokens (long-context decode)
@@ -785,6 +802,14 @@ function serverRuntimeFlags(): { port: number; serverOptions: import("./server")
785
802
  if (budgetGB > 0) serverOptions.memoryBudgetBytes = budgetGB * 1e9;
786
803
  const pcRaw = opt("prompt-cache"); // null if absent; an explicit value (incl. 0) wins → `--prompt-cache 0` DISABLES the cache
787
804
  if (pcRaw !== null) serverOptions.promptCacheBytes = Math.max(0, Number(pcRaw)) * 2 ** 30;
805
+ // SSD cold tier (docs/design/ssd-kv-cold-tier.md) — off unless a dir is given.
806
+ const ssdDir = opt("ssd-cache");
807
+ if (ssdDir !== null) {
808
+ serverOptions.ssdCacheDir = ssdDir;
809
+ const maxRaw = opt("ssd-cache-max");
810
+ if (maxRaw !== null) serverOptions.ssdCacheMaxBytes = Math.max(1, Number(maxRaw)) * 2 ** 30;
811
+ if (flag("ssd-cache-verify")) serverOptions.ssdCacheVerify = true;
812
+ }
788
813
  // --batch N: max concurrent requests batched through the mlx-lm-parity
789
814
  // engine (N=1 = today's serial path). --decode-concurrency is accepted for
790
815
  // drop-in compatibility with mlx_lm.server, but the semantics differ: there
@@ -893,8 +918,21 @@ function runtimeSummary(o: import("./server").ServerOptions): string {
893
918
 
894
919
  /** Shared model resolution: explicit query wins; otherwise e4b (the default
895
920
  * model everywhere) if it's downloaded, else the largest supported model that
896
- * fits this machine. Downloads the recommended model first on a fresh install. */
921
+ * fits this machine. Downloads the recommended model first on a fresh install.
922
+ * A query that is a DIRECTORY containing config.json loads directly via
923
+ * scanSnapshot (no registry dependency) — the `--model <path>` bench/CI shape. */
897
924
  async function resolveModelAuto(query: string | null): Promise<{ m: import("./registry").ModelRecord; picked: boolean }> {
925
+ if (query && existsSync(join(query, "config.json"))) {
926
+ const { scanSnapshot } = await import("./registry");
927
+ const dir = resolve(query);
928
+ // Recover the repo id from an HF-cache path (…/models--org--name/snapshots/<rev>);
929
+ // otherwise the directory basename is the display id.
930
+ const hf = /models--([^/]+)--([^/]+)\/snapshots\//.exec(dir);
931
+ const repoId = hf ? `${hf[1]}/${hf[2]}` : basename(dir);
932
+ const m = await scanSnapshot(dir, repoId);
933
+ if (!m) throw new Error(`${dir} has config.json but no weights (*.safetensors)`);
934
+ return { m, picked: false };
935
+ }
898
936
  const reg = new Registry();
899
937
  if (reg.list().length === 0) await reg.scan();
900
938
  if (query) return { m: reg.resolve(query), picked: false };
@@ -1220,7 +1258,12 @@ switch (cmd) {
1220
1258
  process.exit(1);
1221
1259
  }
1222
1260
  }
1223
- const { m, picked } = await resolveModelAuto(positional(0) ?? opt("query"));
1261
+ // --model <path-or-query> is the explicit override (mlx_lm.server's
1262
+ // spelling — previously accepted-but-IGNORED here, which silently
1263
+ // auto-picked the wrong model). A directory with config.json loads
1264
+ // straight from that path; anything else resolves like the positional
1265
+ // fuzzy query. Precedence: --model > positional > --query > auto-pick.
1266
+ const { m, picked } = await resolveModelAuto(opt("model") ?? positional(0) ?? opt("query"));
1224
1267
  const sFit = step("assessing fit");
1225
1268
  const config = await loadModelConfig(m.path);
1226
1269
  const report = fit(config, m.sizeBytes, 8192, undefined, undefined, m.expertsBytes);
@@ -1385,7 +1428,7 @@ switch (cmd) {
1385
1428
  const { banner, step, box, style } = await import("./tui");
1386
1429
  banner(pkg.version);
1387
1430
  serverRuntimeFlags(); // env levers (--compiled-decode, --perf-kernel, ...) apply to the run
1388
- const { m, picked } = await resolveModelAuto(positional(0) ?? opt("query"));
1431
+ const { m, picked } = await resolveModelAuto(opt("model") ?? positional(0) ?? opt("query"));
1389
1432
  const tokens = Number(opt("tokens", "256"));
1390
1433
  const runs = Number(opt("runs", "3"));
1391
1434
  const promptTokens = Number(opt("prompt-tokens", "0"));
@@ -1573,13 +1616,14 @@ switch (cmd) {
1573
1616
  // (src/pi-terminal.ts) — pi is bundled, nothing to install. Users who
1574
1617
  // already run their own pi connect it with `mlx-bun harness pi`.
1575
1618
  const OURS_VAL = new Set([
1576
- "--query", "-q", "--port", "--host", "--memory-budget", "--prompt-cache", "--kv-quant",
1619
+ "--query", "-q", "--model", "--port", "--host", "--memory-budget", "--prompt-cache",
1620
+ "--ssd-cache", "--ssd-cache-max", "--kv-quant",
1577
1621
  "--batch", "--decode-concurrency", "--adapter", "--adapter-path",
1578
1622
  "--compiled-decode", "--perf-kernel", "--fused-decode", "--fused-sdpa", "--thinking",
1579
1623
  "--temperature", "--temp", "--top-p", "--top-k", "--max-tokens",
1580
1624
  "--hlg-sampling", "--hlg-width", "--hlg-shoulder", "--hlg-toe", "--hlg-pivot-offset",
1581
1625
  ]);
1582
- const OURS_BOOL = new Set(["--force-wire", "--expert-offload", "--no-open", "--l1", "--l2", "--l3"]);
1626
+ const OURS_BOOL = new Set(["--force-wire", "--expert-offload", "--no-open", "--ssd-cache-verify", "--l1", "--l2", "--l3"]);
1583
1627
  const passthrough: string[] = [];
1584
1628
  for (let i = 1; i < argv.length; i++) {
1585
1629
  const a = argv[i]!;
package/src/generate.ts CHANGED
@@ -508,8 +508,16 @@ async function* generateInner(
508
508
  // 0.6.0, and shapeless replay re-infers the whole tape whenever the
509
509
  // growing attention windows change shape (= every step). Remove this
510
510
  // when upstream implements GatherQMM::output_shapes.
511
+ // MLX_BUN_FUSED_DECODE=1 (the N-tiled fused decode experiment) cannot
512
+ // compose with compiled decode: the tile loop's bounds bake at trace-time
513
+ // N, so growing quantized caches silently stop attending their newest
514
+ // rows under shapeless replay (kernel-perf-review 2026-07; reproduced on
515
+ // e4b). The explicit opt-in wins over the default optimization — same
516
+ // policy as LoRA and MoE below. quantizedSdpa also throws on the combo
517
+ // as a backstop.
511
518
  let compiled =
512
519
  process.env.MLX_BUN_COMPILED_DECODE !== "0" &&
520
+ process.env.MLX_BUN_FUSED_DECODE !== "1" &&
513
521
  !options.adapters?.length &&
514
522
  model.config.modelType.startsWith("gemma4") &&
515
523
  !model.config.text.enableMoeBlock