mlx-bun 0.0.10 → 0.0.11

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 (173) hide show
  1. package/README.md +66 -21
  2. package/THIRD_PARTY_LICENSES.md +1 -0
  3. package/docs/archive/mlx-bun-lab-report.html +275 -0
  4. package/docs/archive/wwdc-mlx-bun.html +848 -0
  5. package/docs/design/audio-input-plan.md +356 -0
  6. package/docs/design/batching-perf-path.md +58 -35
  7. package/docs/design/batching-v2-plan.md +23 -9
  8. package/docs/design/bucketing-stage.md +2 -1
  9. package/docs/design/compat-cli-surface-design.md +4 -3
  10. package/docs/design/decode-speed-program.md +172 -0
  11. package/docs/design/docs-reorg-plan.md +1 -1
  12. package/docs/design/dspark-serving-program.md +371 -0
  13. package/docs/design/dspark-speculative-decoding.md +167 -5
  14. package/docs/design/faithful-l1-consolidation.md +310 -0
  15. package/docs/design/generic-model-support.md +8 -5
  16. package/docs/design/grammar-spec-batching-integration.md +301 -0
  17. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  18. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  19. package/docs/design/omlx-adoption-map.md +14 -6
  20. package/docs/design/orpo-training.md +34 -30
  21. package/docs/design/paged-kv-cache.md +214 -0
  22. package/docs/design/parallel-slots.md +82 -49
  23. package/docs/design/parity-tier-dag.md +43 -33
  24. package/docs/design/repo-cleanup-plan.md +189 -0
  25. package/docs/design/runtime-isolation.md +19 -3
  26. package/docs/design/segmented-backward-training.md +3 -2
  27. package/docs/design/ssd-kv-cold-tier.md +130 -0
  28. package/docs/design/structured-output.md +484 -0
  29. package/docs/design/the-dreaming-master-plan.md +2 -3
  30. package/docs/design/turboquant-kv.md +235 -0
  31. package/docs/design/unified-engine-frontier-plan.md +774 -0
  32. package/docs/design/web-chat-beat-matrix.md +315 -0
  33. package/docs/design/web-chat-redesign.md +1112 -0
  34. package/docs/investigations/dspark-handoff.md +182 -16
  35. package/docs/investigations/lab-build-journal.md +2 -2
  36. package/docs/investigations/openwiki-evaluation.md +249 -0
  37. package/docs/planning/IDEAS.md +14 -0
  38. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  39. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  40. package/docs/planning/chat-ui-vision.md +50 -0
  41. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  42. package/docs/planning/release-notes-v0.0.11.md +239 -0
  43. package/docs/reference/cli.md +35 -16
  44. package/docs/reference/distribution.md +13 -6
  45. package/docs/reference/embedding.md +35 -14
  46. package/docs/reference/features-matrix.md +97 -0
  47. package/docs/reference/library-api.md +30 -9
  48. package/docs/reference/memory.md +26 -6
  49. package/docs/reference/models.md +11 -1
  50. package/docs/reference/orpo-quickstart.md +3 -4
  51. package/docs/reference/server-api.md +634 -22
  52. package/docs/reference/server-config.md +250 -121
  53. package/docs/reference/training.md +14 -15
  54. package/package.json +5 -3
  55. package/src/anthropic.ts +13 -0
  56. package/src/audio/conformer.ts +647 -0
  57. package/src/audio/decode.ts +133 -0
  58. package/src/audio/features.ts +202 -0
  59. package/src/audio/transcode.ts +67 -0
  60. package/src/cli.ts +356 -105
  61. package/src/config.ts +49 -2
  62. package/src/download.ts +40 -0
  63. package/src/eval/README.md +7 -6
  64. package/src/eval/kl.ts +59 -3
  65. package/src/eval/quality-db.ts +2 -2
  66. package/src/eval/runner.ts +3 -3
  67. package/src/expert-offload.ts +15 -2
  68. package/src/flags.ts +13 -0
  69. package/src/generate.ts +330 -24
  70. package/src/grammar.ts +585 -0
  71. package/src/hub-rest.ts +242 -0
  72. package/src/kv-store.ts +442 -88
  73. package/src/lora.ts +8 -0
  74. package/src/md-modules.d.ts +42 -0
  75. package/src/media-fetch.ts +284 -0
  76. package/src/memory/rest.ts +245 -0
  77. package/src/memory/vault.ts +74 -0
  78. package/src/mlx/array.ts +106 -19
  79. package/src/mlx/autograd.ts +2 -2
  80. package/src/mlx/ffi.ts +27 -3
  81. package/src/mlx/metal-kernel.ts +7 -4
  82. package/src/mlx/ops.ts +153 -23
  83. package/src/mlx/turboquant-ops.ts +831 -0
  84. package/src/mlx/turboquant-tables.ts +237 -0
  85. package/src/model/batched-mask.ts +62 -1
  86. package/src/model/batched-quant.ts +206 -0
  87. package/src/model/batched-rotating-quant.ts +346 -0
  88. package/src/model/batched-rotating.ts +46 -1
  89. package/src/model/compiled-decode.ts +4 -15
  90. package/src/model/factory.ts +10 -4
  91. package/src/model/gemma4-base.ts +363 -39
  92. package/src/model/gemma4.ts +122 -33
  93. package/src/model/generated/gemma4-12b.ts +9 -13
  94. package/src/model/generated/gemma4-26b.ts +10 -5
  95. package/src/model/generated/gemma4-e4b.ts +14 -9
  96. package/src/model/minicpm5.ts +58 -102
  97. package/src/model/paged-kv.ts +253 -0
  98. package/src/model/qwen3-delta.ts +76 -15
  99. package/src/model/qwen3-moe.ts +404 -0
  100. package/src/model/qwen3.ts +9 -8
  101. package/src/model/qwen3_5.ts +88 -23
  102. package/src/model/support.ts +12 -1
  103. package/src/model/universal/dense.ts +40 -5
  104. package/src/model/universal/rope.ts +36 -21
  105. package/src/pi-web.ts +1299 -82
  106. package/src/preflight.ts +27 -8
  107. package/src/prompt-cache.ts +293 -33
  108. package/src/quantize/quantizer.ts +16 -1
  109. package/src/registry.ts +57 -2
  110. package/src/serve/batch-scheduler.ts +965 -72
  111. package/src/serve/generation-gateway.ts +214 -11
  112. package/src/serve/isolate.ts +417 -0
  113. package/src/serve/lane-registry.ts +44 -0
  114. package/src/serve/session-search.ts +326 -0
  115. package/src/server.ts +1380 -135
  116. package/src/spec/assistant-source.ts +150 -0
  117. package/src/spec/deepspec-source.ts +143 -0
  118. package/src/spec/dflash-source.ts +124 -0
  119. package/src/spec/dspark/ab-stats.ts +136 -0
  120. package/src/spec/dspark/calibration.ts +112 -0
  121. package/src/spec/dspark/deepspec-module.ts +801 -0
  122. package/src/spec/dspark/generate-dflash.ts +72 -20
  123. package/src/spec/dspark/loader.ts +23 -0
  124. package/src/spec/dspark/module-dflash.ts +278 -36
  125. package/src/spec/dspark/quantize-drafter.ts +91 -0
  126. package/src/spec/generate.ts +59 -25
  127. package/src/spec/ngram-source.ts +132 -0
  128. package/src/spec/serve-loop.ts +536 -0
  129. package/src/spec/source.ts +93 -0
  130. package/src/spec/two-model.ts +171 -0
  131. package/src/ssd-cache.ts +107 -35
  132. package/src/tokenizer.ts +288 -2
  133. package/src/tool-approvals.ts +131 -0
  134. package/src/tool-call.ts +408 -20
  135. package/src/train/flash-cce.ts +2 -2
  136. package/src/train/job.ts +0 -2
  137. package/src/train/trainer.ts +1 -4
  138. package/src/vision/prompt.ts +217 -48
  139. package/src/web/app.html +1184 -1789
  140. package/src/web/app.js +5667 -0
  141. package/src/web/icon.svg +13 -0
  142. package/src/web/manifest.webmanifest +24 -0
  143. package/src/web/src/adapters-panel.ts +288 -0
  144. package/src/web/src/api.ts +57 -0
  145. package/src/web/src/assistant.ts +485 -0
  146. package/src/web/src/chat.ts +1050 -0
  147. package/src/web/src/composer.ts +959 -0
  148. package/src/web/src/dataset.ts +132 -0
  149. package/src/web/src/finetune.ts +267 -0
  150. package/src/web/src/hub.ts +428 -0
  151. package/src/web/src/main.ts +98 -0
  152. package/src/web/src/markdown.ts +0 -0
  153. package/src/web/src/memory-panel.ts +572 -0
  154. package/src/web/src/model-picker.ts +159 -0
  155. package/src/web/src/palette.ts +346 -0
  156. package/src/web/src/protocol.ts +62 -0
  157. package/src/web/src/quantize.ts +167 -0
  158. package/src/web/src/rag.ts +274 -0
  159. package/src/web/src/sessions.ts +469 -0
  160. package/src/web/src/shell.ts +804 -0
  161. package/src/web/src/status.ts +252 -0
  162. package/src/web/src/ui-catalog.ts +92 -0
  163. package/src/web/sw.js +71 -0
  164. package/src/web/vendor/README.md +61 -0
  165. package/src/web/vendor/hljs-LICENSE +29 -0
  166. package/src/web/vendor/hljs-theme.css +59 -0
  167. package/src/web/vendor/hljs.js +26 -0
  168. package/src/model/fused-decode-kernel.ts +0 -312
  169. package/src/model/fused-geglu-kernel.ts +0 -198
  170. package/src/model/fused-mlp-kernel.ts +0 -169
  171. package/src/model/fused-swiglu-kernel.ts +0 -240
  172. package/src/model/steel-linear-kernel.ts +0 -96
  173. package/src/status-page.html +0 -497
@@ -22,18 +22,28 @@ the design rationale is in
22
22
  | `--host` | addr | `127.0.0.1` | Interface to bind. Loopback-only by default (mlx_lm.server parity); pass `--host 0.0.0.0` to expose the server on your network. |
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
- | `--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. |
25
+ | `--prompt-cache` | GB | `8` | RAM prefix-KV cache (byte-capped LRU; `0` disables). **PREFIX SHARING (2026-07-05): serves are NON-CONSUMING** — a hit hands out zero-copy clones and leaves the donor entry intact, so N agents (or a brand-new session) sharing a system prompt all reuse ONE prefill without destroying each other's conversation entries (the old consume-and-trim take cannibalized the donor). Extended entries supersede their prefix-ancestors (when trimmable), so a conversation stays one entry. Works on BOTH lanes and tiers over the SSD store (Layer 0). |
26
+ | `--isolate` | (bool) | off | Runtime isolation ([runtime-isolation.md](../design/runtime-isolation.md)): the inference engine runs as a CHILD process on a unix socket; this process stays a pure UI/API reverse proxy — instant under any GPU load, survives engine crashes (auto-respawn, in-flight requests get a clean 502). Measured cost ≈0 (−0.4% tok/s, +2 ms TTFT, per-token SSE granularity preserved). `/ws/chat` is not proxied yet (501). `/engine` reports child pid/restarts. |
27
+ | `--model-pool` | n | `1` | With `--isolate`: max RESIDENT model engines. Requests naming another model (**exact `/v1/models` id** — fuzzy strings keep mlx-lm's ignored-field semantics) spawn/route to that model's own engine child (SPAWN-OVERLAP: the new model loads while the old one keeps serving; nobody's stream is interrupted). Over the cap, the LRU engine drains, **demotes its prompt cache to the SSD tier**, and exits — switching back respawns it with state restored from disk. Measured (M1 Max, cpm5⇄qwen0.8b, pool 1): switch 1.5 s, switch-back 1.2 s with `cached_tokens` 103/104 — the conversation survived the eviction. The pool drives this eviction via `POST /admin/drain` — unix-socket-only (never exposed on the public TCP listener) — which quiesces the gateway and demotes the whole prompt cache to the SSD tier before eviction, responding `{ drained: true, demotions: N }`. |
28
+ | `--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 AND idle demotion, is snapshotted after requests settle (debounced 1 s; the flush is idle-gated per tensor so it never taxes an active decode — `MLX_BUN_SSD_WRITEBEHIND=0` disables), and **survives restarts** — a long-context agent re-attach restores via a bounded streamed copy instead of re-prefilling (measured 13.7k-token prefix: 12 s → ~0.25 s TTFT; 0% decode overhead; restore transient = live entry + one tensor, no mapping outlives the load). Entries are keyed by model fingerprint **+ effective kv scheme** + tokenizer hash + adapter ns; incompatible/corrupt files self-quarantine. **Layer 0 (2026-07-05): the tiering lives inside PromptCache.take(), so BOTH lanes restore from disk — batch-scheduler joiners included — and every put (batch rows too) schedules the write-behind snapshot.** Requires the RAM cache. |
27
29
  | `--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. |
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). |
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). |
30
+ | `--ssd-cache-verify` | (bool) | off | Verify every tensor hash on restore before it is copied in (roughly doubles restore reads) — integrity paranoia only; the header hash is always verified. |
31
+ | `--ssd-demote-idle` | sec | `300` (with `--ssd-cache`) | Idle demotion (Layer 0): prompt-cache entries unused this long spill to the SSD tier and **free their GPU memory** — unified memory drains between agent bursts while every prefix stays reachable (the next hit restores via the bounded streamed copy, ~0.25 s for a 13.7k-token entry vs a 12 s re-prefill). Swept only when the engine is fully idle (never drains a running batch). `0` disables. |
32
+ | `--batch` | n | `8` | Max concurrent requests decoded together. **Default flipped 1→8 (2026-07-05)** after GATE-B1-SPEED: a LONE request through the unified engine IS the serial engine — adopted serial-class caches, compiled decode, prompt cache + SSD restore, byte-identical output, 0.992–0.996 paired decode ratios — so the cap only changes behavior when concurrent requests actually arrive (the agentic sub-agent workload: 4–8 coding agents against one local server). `--batch 1` pins strict serial (arrival-independent numerics). See [Execution modes](#execution-modes-serial-vs---batch-n). `--decode-concurrency` is accepted for drop-in compatibility (mlx_lm.server's cap, default 32). |
33
+ | `--kv-quant` | `config`\|`off`\|`4`\|`8`\|`turbo[:k<bits>v<bits>]` | `off` (bf16) | KV-cache quantization. **Default flipped to bf16 2026-07-05** (naked = L1): quantized KV measured 5–20% slower decode than bf16 at ≤16k on every model — on mlx-lm too — so it pays only in **memory headroom** (e.g. ~1.3 GB on the 12B @16k) and is an explicit opt-in (`--kv-quant …` or `--l2`, whose preset passes `config`). `off` = bf16 (L1). `4`/`8` = **uniform** bits (group 64, start 0) — the scheme mlx-lm exposes as `--kv-bits`/`--kv-group-size`/`--quantized-kv-start`. In the L1 kernel set (`--l1 --kv-quant 8` = fused-sdpa off) our unfused quantized SDPA is **op-for-op identical** to mlx-lm's `quantized_scaled_dot_product_attention`, so uniform-quant is **bit-exact L1** (the fused-sdpa-ON path is optiq-aligned). `config` = **per-layer mixed-precision** from `kv_config.json` — optiq-only, no mlx-lm analog → **L2**. `turbo[:k<bits>v<bits>]` = **TurboQuant** ([docs/design/turboquant-kv.md](../design/turboquant-kv.md)): rotation-based KV quantization (asymmetric-affine int8/int4/int2/int5 keys, FWHT + Lloyd-Max values) — default `k8v3` (2.56× compression at head_dim 128); `kBits` ∈ {2,4,5,8}, `vBits` ∈ {2,3,4,5,8}. A separate axis from the scheme above (mutually exclusive with `config`/`4`/`8`); v1 is dequantize-on-fetch (stock `ops.sdpa`, no fused kernel) and **full-attention layers only** — sliding-window layers stay bf16 (one-time warning, never a throw). Under `--batch N`, a per-layer `config` scheme **batches** — full-attention layers since Phase 3.1 (MiniCPM5), rotating/sliding layers since milestone 2 (gemma's whole kv_config), so every shipped `kv_config.json` now batches. The scheduler applies the mixed scheme per row, gated bit-exact per row vs the serial composition (`tests/batched-kv-quant-parity.test.ts` — compositions no other stack ships). Uniform `4`/`8` still routes those requests serial (quantizedKvStart threshold semantics). **`turbo` is solo-only, unconditionally** — `TurboQuantKVCache` is a novel `Cache` implementation (no merge/filter/temporalView), so it's excluded from the batch scheduler by construction; `GenerationGateway.willBatch` also refuses it explicitly (belt + braces — both layers exist on purpose). |
34
+ | `--paged-kv` | (bool) | off | **OPTIONAL vLLM-style paged KV cache** ([docs/design/paged-kv-cache.md](../design/paged-kv-cache.md)): full-attention layers store K/V in fixed-size **block pools** (host-side block table, `ops.takeAxis` gather back to contiguous before the stock SDPA — no new attention math, no fused kernel in v1). Env: `MLX_BUN_PAGED_KV=1`. **Gated bit-exact vs the plain path** (`tests/paged-kv-parity.test.ts`: identical single-forward logits + identical 48-token greedy trajectory on the 12B). **v1 scope: serial batch=1, Gemma4-family, bf16** — with no explicit `--batch` the CLI pins `--batch 1`; the server **refuses** (fails startup, never silently downgrades) `--batch N>1`, any `--kv-quant`, and `--draft-model`; sliding-window layers keep the rotating scheme (mixed paged-full + rotating-sliding is the supported shape). Media (vision/audio) and LoRA-adapter **requests** are scoped OUT per request — they run the plain cache path (v1 non-goal cells), never a 400. Paged requests **bypass the prompt cache** (fresh caches per request; `--ssd-cache` sees nothing) and run **uncompiled decode** (a data-dependent block-list length can't shapeless-replay). Expect a small decode cost at batch=1 — the per-step gather copy buys nothing until the batched/CoW follow-ups; this ships the correctness-proven abstraction, honestly labeled. Pool sized from prompt+`max_tokens` at request setup; exhaustion is a typed error (accounting tripwire), never truncation. |
35
+ | `--paged-kv-block-size` | n | `256` | Tokens per KV block (`--paged-kv` only). 256 = the plain cache's growth step, so v1's allocation granularity is a permutation of today's into reusable slots, not a new tuning axis. |
31
36
  | `--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. |
32
- | `--thinking` | `true`\|`false` | model's own (false for CPM) | Server-wide default for the chat template's `enable_thinking` (MiniCPM5/CPM and Qwen3.5 hybrid reasoning). A request's `chat_template_kwargs.enable_thinking` overrides it. |
37
+ | `--draft-model` | path/query | none | **Speculative decoding**: a drafter proposes tokens the main model verifies in one forward — exact results, faster decode when drafts land. Resolves like the main model. The artifact's **kind is auto-detected**: a full same-tokenizer model (mlx_lm.server parity — L1: token-for-token vs mlx-lm's spec path; tokenizer-family mismatch fails startup, upstream silently accepts ~0%), a Gemma `-assistant` KV-borrowing drafter (L2 vs optiq spec_generate; ~1.09× γ=1 on 12B, no training), a locally-trained **DSpark** checkpoint (`dspark.json`), or one of **DeepSeek's released DSpark drafters** (DeepSpec `Gemma4DSparkModel` config stamp, e.g. `deepseek-ai/dspark_gemma4_12b_block7` for the 12B — oracle: DeepSpec's reference at temperature 0, token-for-token). All four share ONE serve loop. A confidence-calibrated DSpark checkpoint additionally self-schedules its draft length per round (threshold pruning — fewer wasted verify positions; uncalibrated checkpoints draft fixed-length). Mounting a draft routes **every** request to the serial lane (upstream `is_batchable = draft is None`). Pays on slow targets (12B+); fast small models lose to the draft overhead. Composes with structured output (the constrained verify walk). Prompt-cache reuse is bypassed on the spec path (v1). Telemetry: `usage.speculation` (`drafted`/`accepted`/`targetCalls`). |
38
+ | `--draft-kind` | `two-model`\|`assistant`\|`dspark`\|`deepspec`\|`ngram` | auto | Override the draft-artifact kind detection (`dspark.json` → dspark; `Gemma4DSparkModel` architecture stamp → deepspec; `*_assistant` config → assistant; else two-model). **`ngram` is never auto-detected — it has no artifact**: MODEL-FREE prompt lookup (drafts copied from the request's own prompt+generation when its trailing k-gram occurred earlier in the stream; port of prompt-lookup decoding / vLLM's `ngram` proposer). Mount it ALONE — `--draft-kind ngram` with a `--draft-model` is refused, as is any other kind without one. Zero weights/memory, lossless by the same verify (gated: serve-loop output token-identical to non-spec greedy on e4b, tests/spec-ngram.test.ts); a no-match round degrades to one plain target step. Best on agentic/RAG/code-edit traffic that re-emits context spans. |
39
+ | `--num-draft-tokens` | n | `3` (`ngram`: `10`) | Drafts per verify round (mlx_lm.server's default; `mlx_lm.generate`'s is 2). A DSpark draft pins this to its trained block width (`cfg.gamma`); ngram drafting is free so its default is the reference implementation's 10. |
40
+ | `--ngram-max` / `--ngram-min` | k | `3` / `1` | `--draft-kind ngram` only: longest/shortest trailing k-gram searched for a match (the reference values — longest first, first occurrence wins). |
41
+ | `--thinking` | `true`\|`false` | model's own (false for CPM) | Server-wide default for the chat template's `enable_thinking` (MiniCPM5/CPM and Qwen3.5 hybrid reasoning). Full precedence: an explicit request `chat_template_kwargs.enable_thinking` always wins; else `reasoning_effort` (`"none"` → off, any other level → on) if the request sent one; else this server default; else the model's own default. |
33
42
  | `--temperature` | n ∈ [0,5] | `generation_config.json` | Server-wide sampling default. Per-request `temperature` still wins; the browser chat (sends none) inherits this. `--temp` is accepted as an alias (mlx_lm.server compat); explicit `--temperature` wins if both are given. **Migration note:** mlx_lm.server's `--temp` *default* is `0.0` (unset-temperature requests are greedy there); mlx-bun falls back to the model's `generation_config.json`, then `0.7` — pass `--temp 0` for mlx-lm's behavior. |
34
43
  | `--top-p` | n ∈ [0,1] | `generation_config.json` | Server-wide top-p default (per-request `top_p` wins). |
35
44
  | `--top-k` | n ∈ [0,1e6] | `generation_config.json` | Server-wide top-k default (per-request `top_k` wins). |
36
45
  | `--max-tokens` | n | `65536` chat / `512` raw completion | Completion cap when a request omits `max_tokens` (mlx_lm.server flag; its default there is 512 — `--max-tokens 512` reproduces mlx_lm.server exactly). |
46
+ | `--allow-private-media` | (bool) | off | Let `image_url`/`audio_url` content parts fetch from **private/loopback/link-local** hosts (a NAS, another LAN box). Off by default: a request's URL is attacker-controlled input, so remote media fetches (`src/media-fetch.ts`) refuse non-http(s) schemes and destinations in private/loopback/link-local/CGNAT ranges — including hosts that *resolve* there and every **redirect hop** (each hop re-passes the whole policy) — which keeps the server from being steered at cloud metadata (`169.254.169.254`) or LAN-internal services (SSRF). Independent of the flag, every remote fetch has a **10 s wall-clock timeout** and a **64 MB response cap** (streaming-enforced, not just Content-Length); violations surface as clean `400`s (`prompt build failed: …`). `data:` URLs decode locally and are never policy-checked. |
37
47
  | `--no-open` | (bool) | off | Skip the automatic browser open on start. By default an interactive terminal session opens `http://<host>:<port>/#/chat` once the server is ready; pass this flag to suppress it (e.g. headless or non-TTY environments already skip it). |
38
48
  | `--hlg-sampling` | `on`\|`off` | off | Piecewise tone-curve (HLG) sampling: rolls off the top-token region, boosts the mids, gentles the tail. The overall gain folds from `--temperature`. See [docs/design/hlg-sampling.md](../design/hlg-sampling.md). |
39
49
  | `--hlg-width` | nats | `4` | HLG mid-region half-width (nats). Only meaningful with `--hlg-sampling on`. |
@@ -41,32 +51,34 @@ the design rationale is in
41
51
  | `--hlg-toe` | nats | `6` | HLG shadow rolloff scale (nats). Only meaningful with `--hlg-sampling on`. |
42
52
  | `--hlg-pivot-offset` | nats | `6` | HLG pivot point: nats below the top token. Only meaningful with `--hlg-sampling on`. |
43
53
  | `--expert-offload` | (bool) | off | **MoE models only.** Serve experts from a page-aligned file mmap (built on first use). Keeps the model out of memory pressure — physical footprint ≈ active params. Ignored with a warning on dense models. Bit-exact with the resident path. |
44
- | `--l1` | (bool) | — | **Parity tier preset:** bit-for-bit IDENTICAL to mlx-lm (bf16 KV, unfused SDPA, no perf kernel). Sets the whole decode route; any explicit per-fork flag (`--kv-quant`/`--perf-kernel`/…) overrides the preset. See [docs/design/parity-tier-dag.md](../design/parity-tier-dag.md). |
45
- | `--l2` | (bool) | — | **Parity tier preset:** bit-for-bit IDENTICAL to mlx-optiq (quantized KV per `kv_config.json` + fused N-tiled prefill SDPA + **stock unfused decode** — the composition the optiq goldens track, `scripts/regen-kvq-goldens.ts`). The perf kernel stays **off**: it is envelope-gated, not bit-exact (see `--l3`), and opting into it (`--l2 --perf-kernel on`) is an explicit choice that leaves the bare-tier guarantee. |
46
- | `--l3` | (bool) | — | **Parity tier preset:** best performance, no bit-exact oracle (KL + test gated). On the decode path L3 = L2 + the envelope-gated perf kernel; L3 also owns the no-oracle features (HLG sampler, expert offload). No tier given ⇒ the per-flag defaults below, which equal `--l3`. |
54
+ | `--l1` | (bool) | — | **Parity tier ALIAS:** bit-for-bit IDENTICAL to mlx-lm (bf16 KV, compiled decode + compiled activations — the faithful `@mx.compile` geglu/swiglu). **No tier given ⇒ `--l1`** (decided 2026-07-05: the L1 faithful kernel set matches mlx-lm 1.00× on every model and no output-changing lever has beaten that baseline in a paired A/B — each is opt-in until it does). Expands to the fastest set of per-fork flags that still holds the guarantee; any explicit per-fork flag (`--kv-quant`/`--fused-sdpa`/`--compiled-decode`/`--compiled-activations`) overrides one. See [docs/design/faithful-l1-consolidation.md](../design/faithful-l1-consolidation.md) and [parity-tier-dag.md](../design/parity-tier-dag.md). |
55
+ | `--l2` | (bool) | — | **Parity tier preset:** bit-for-bit IDENTICAL to mlx-optiq (quantized KV per `kv_config.json` + fused N-tiled prefill SDPA + **stock unfused decode** — the composition the optiq goldens track, `scripts/regen-kvq-goldens.ts`). |
47
56
  | `--compiled-decode` | on\|off | on | Replay the per-step decode graph in C++ (`MLX_BUN_COMPILED_DECODE`). Bit-exact A/B lever. **Serial lane only** (see note below). **Gemma4-dense only** — LoRA, MoE, and non-Gemma4 models (MiniCPM5 / Qwen3.5) run eager; an unsupported step falls back to eager for the rest of that generation. |
48
- | `--perf-kernel` | on\|off | **on** | Fused quantized-KV decode-SDPA Metal kernel (`MLX_BUN_PERF_KERNEL`), the perf side of the compat A/B. **Not bit-exact** — envelope-gated (≥56/64 teacher-forced argmax vs the frozen compat trajectory, `tests/perf-kernel-oracle.test.ts`), so it is an **L3 lever**: on in `--l3`/no-tier default, **off in bare `--l1`/`--l2`**. Engages on quantized caches at decode. **Serial lane only.** |
49
- | `--fused-decode` | on\|off | off | Experimental: tile the quantized decode SDPA (`MLX_BUN_FUSED_DECODE`). **Serial lane only.** |
50
- | `--fused-sdpa` | on\|off | on | Fused SDPA path for quantized prefill/continuation (inverted env `MLX_BUN_NO_FUSED_SDPA`). **Serial lane only.** |
57
+ | `--compiled-activations` | on\|off | on | Route the geglu/swiglu activation through mlx-lm's `@mx.compile` closure (`MLX_BUN_COMPILED_GEGLU` + `MLX_BUN_COMPILED_SWIGLU`) — the **faithful** kernel: same libmlx graph as mlx-lm → **bit-exact** AND one dispatch instead of ~9. `off` = the uncompiled composition (same L1 parity, slower). Toggles gemma geglu + MiniCPM5 swiglu; qwen3/qwen3.5/universal compile unconditionally. |
58
+ | `--fused-sdpa` | on\|off | follows `--kv-quant` | Fused SDPA path for quantized prefill/continuation (inverted env `MLX_BUN_NO_FUSED_SDPA`). Defaults to the composition its oracle uses: **on** under `--kv-quant config` (the optiq-golden composition), **off** under uniform `4`/`8` (mlx-lm's `quantized_scaled_dot_product_attention`, the bit-exact L1-eligible scheme) and bf16 (no-op there). **Serial lane only.** |
51
59
  | `--force-wire` | (bool) | off | Wire weights into memory for the whole generation (`MLX_BUN_FORCE_WIRE`). Near-ceiling models (e.g. 26B) need it. **Serial lane only.** |
52
60
 
53
61
  The default host/port (`127.0.0.1:8080`) match `mlx_lm.server`, so running
54
62
  mlx-bun alongside the Python reference server needs an explicit `--port`.
55
63
 
56
- The five performance levers are A/B knobs whose defaults are the measured
57
- winners; flip them to compare. They are set as `MLX_BUN_*` env vars
58
- before the model loads, so they apply to `mlx-bun pi` too. They affect
59
- the **serial** decode path (`generate()`); the batched scheduler calls
60
- `model.forwardHidden` directly and so is unaffected by all of them — see
64
+ The remaining levers are bit-exact A/B knobs; **the naked default is the
65
+ L1 set** (2026-07-05 decision: an output-changing lever earns a default
66
+ only by beating the L1 faithful baseline in a paired A/B, and none did —
67
+ the 2026-07-05 pass measured the custom fused-decode at 1.00×, fused-gelu
68
+ at +0–1%, the flash perf-kernel at 0.62–0.93× on e4b, and quantized KV
69
+ 5–20% *slower* decode than bf16 at ≤16k on both stacks; those losing
70
+ kernels and their flags were **deleted** the same day — Phase 1 of
71
+ [unified-engine-frontier-plan.md](../design/unified-engine-frontier-plan.md).
72
+ New output-changing experiments live in the **Lab**: env-flagged, with a
73
+ bench + expiry, until one beats the L1 baseline in a paired A/B). Flip
74
+ the survivors to compare. They are set as `MLX_BUN_*` env vars before the
75
+ model loads, so they apply to `mlx-bun pi` too. They affect the
76
+ **serial** decode path (`generate()`); the batched scheduler calls
77
+ `model.forwardHidden` directly and is unaffected by all of them except
78
+ `--compiled-decode`, which also covers a lone batch-lane request (Phase
79
+ 3.2) — see
61
80
  [Levers that don't reach the batched lane](#--batch-n-is-compat-mode--perf-flags-dont-apply-by-design).
62
81
 
63
- > **Note — `--perf-kernel` default.** The code defaults it **on**
64
- > (`perfKernelEnabled()` returns true unless `MLX_BUN_PERF_KERNEL=0`, and
65
- > the ready card / `/stats` report it on). `STATUS.md` still lists a
66
- > "default flip" as pending; treat that as stale — the engaged default
67
- > today is on. (If the intent is genuinely off-until-clean-machine-pass,
68
- > the *code* is what needs changing, not this doc.)
69
-
70
82
  ## Per-request overrides
71
83
 
72
84
  Most quality knobs can be set per request in the chat body and override
@@ -88,44 +100,61 @@ These ride all three protocol surfaces (`/v1/chat/completions`,
88
100
  ## Environment levers
89
101
 
90
102
  The CLI flags above set these; you can also export them directly (e.g.
91
- for `bun scripts/serve.ts` or paired A/B harnesses). One has no CLI flag.
103
+ for paired A/B harnesses).
92
104
 
93
105
  | Env var | CLI flag | Default | Effect |
94
106
  | --- | --- | --- | --- |
95
107
  | `MLX_BUN_COMPILED_DECODE` | `--compiled-decode` | on (`!=="0"`) | Compiled decode graph replay. |
96
- | `MLX_BUN_PERF_KERNEL` | `--perf-kernel` | on (`!=="0"`) | Fused quantized-KV decode kernel (not bit-exact; perf A/B). |
97
- | `MLX_BUN_FUSED_DECODE` | `--fused-decode` | off (`==="1"`) | Tile quantized decode SDPA. |
98
- | `MLX_BUN_NO_FUSED_SDPA` | `--fused-sdpa` (inverted) | fused on | `=1` forces the stock unfused SDPA everywhere. |
99
- | `MLX_BUN_FUSED_GELU` | *(none)* | on (`!=="0"`) | Fused GeGLU MLP kernel. Bit-exact, so it stays on both lanes; env-only opt-out. |
108
+ | `MLX_BUN_NO_FUSED_SDPA` | `--fused-sdpa` (inverted) | follows `--kv-quant` | `=1` forces the stock unfused SDPA everywhere. |
109
+ | `MLX_BUN_COMPILED_GEGLU` | `--compiled-activations` | on (`!=="0"`) | Gemma geglu via mlx-lm's `@mx.compile` closure — the faithful default (bit-exact vs mlx-lm, one kernel). `=0` → uncompiled composition (same parity, slower). |
110
+ | `MLX_BUN_COMPILED_SWIGLU` | `--compiled-activations` | on (`!=="0"`) | `mx.compile`'d SwiGLU (`silu(gate)·up` → one kernel) on MiniCPM5 decode (M=1), porting mlx-lm's `activations.py`. Bit-exact (passes the exact logit-parity gate), both lanes. +5.5% CPM5 decode. (qwen3/qwen3.5/universal compile swiglu unconditionally, independent of this flag.) |
100
111
  | `MLX_BUN_FORCE_WIRE` | `--force-wire` | off (`==="1"`) | Wire weights for the generation. |
112
+ | `MLX_BUN_PAGED_KV` | `--paged-kv` | off (`==="1"`) | Paged KV cache (see the flag row above — v1: serial, Gemma4, bf16; refusals + prompt-cache bypass apply identically). |
113
+ | `MLX_BUN_ALLOW_PRIVATE_MEDIA` | `--allow-private-media` | off (`==="1"`) | Permit `image_url`/`audio_url` fetches to private/loopback/link-local hosts (the SSRF guard's LAN escape hatch — see the flag row above; timeout + size cap still apply). |
114
+ | `MLX_BUN_PREFILL_TAIL_SPLIT` | — | on (`!=="0"`) | Oracle prefill convention (mlx-lm `generate_step` and its server's batched engine): drain the prompt only to len−1, then compute step-0 logits from a separate **L=1 forward of the last prompt token**. Both lanes. **The SPEC lane (`--draft-model`) follows its own oracle's shape under the same flag** (mlx-lm `speculative_generate_step`, re-anchored 2026-07-07): both target AND draft drain to len−1 and there is **no separate step-0 at all** — the un-drained last prompt token heads the first verify window. Gated live: token-for-token vs the oracle venv incl. a knife-edge cell (tests/spec-serve.test.ts "L1 knife-edge"). (The standalone optiq-oracled assistant loop `specGenerate` deliberately keeps FULL-prompt prefill — optiq's own convention.) `=0` restores the pre-2026-07-07 full-final-chunk convention everywhere (A/B lever + kill switch) — that convention is ulp-different in bf16 at step 0 AND in the last prompt token's stored KV, which flips near-tie greedy streams vs mlx-lm (the 2026-07-07 12B completion-probe divergence). |
115
+ | `MLX_BUN_GRAMMAR_JUMP` | — | **off** (`==="1"`) | Jump-forward decoding for structured output (SGLang's technique via xgrammar's `findJumpForwardString`): when the grammar forces a unique continuation (JSON punctuation/keys; longest under `any_whitespace:false` fixed formatting), the SERIAL lane emits its retokenized ids with ONE multi-token forward instead of one masked forward per token. Lossless in STRING space and always grammar-valid, but the token stream (and, conditioning on it, content after a forced span) can legally differ from an unjumped run — no oracle, hence opt-in. Excluded automatically for `logprobs` requests; SentencePiece-family tokenizers whose raw encode can't reproduce a mid-stream span simply never jump (guard degrades to normal decode). The batch lane's per-row grammar (`#stepGrammar`) does not jump yet. Tests: tests/grammar-jump.test.ts (contract + Llama-3.2-1B e2e). |
116
+ | `MLX_BUN_SSD_WRITEBEHIND` | — | on (`!=="0"`) | `--ssd-cache`'s debounced write-behind snapshot (restart survival). `=0` disables it entirely — the paired-A/B lever + kill switch for the 2026-07-07 idle-gating fix (eviction/demotion spills still write). The flush is **idle-gated**: every per-tensor step (blocking GPU sync + `writeSync`) waits for the engine to go idle and pauses when a request arrives mid-flush, so durability work never taxes an active decode (pre-fix: a ~16k entry's flush overlapping cached ctx repeats cost e4b ~9% decode@ctx vs mlx-lm). The snapshot timer also never holds batch admission — while rows are active it re-arms instead of grabbing the exclusive. |
117
+ | `MLX_BUN_SSD_SPILL_QUEUE_GB` | — | 2 | Byte cap on the write-behind queue's PENDING spill clones (they pin the evicted entries' GPU memory until the idle-gated flush runs). Over cap, the oldest queued spill drops — its clones free immediately and the entry becomes a future cache miss (never a wrong result). `=0` keeps only the newest + in-flight clone pinned (the soft cap never drops the item just enqueued). `/stats.ssd_cache` reports `pending_spills` / `pending_spill_bytes` / `dropped_spills`. Note: there is deliberately **no shutdown flush** — exiting under traffic loses queued write-behind entries (best-effort tier; cost = one re-prefill). |
118
+ | `MLX_BUN_BATCH_NO_PIPELINE` | — | off (`==="1"`) | Disables the batch lane's pipelined token readback (the A/B lever for the shipped pipelining, like its peers `MLX_BUN_BATCH_EXTEND` / `MLX_BUN_BATCH_SSM` / `MLX_BUN_BATCH_VEC_SAMPLE`). Numerically equivalent, slower steps. |
119
+ | `MLX_BUN_DSPARK_MINCONF` | — | checkpoint-driven | Overrides the DSpark/DeepSpec draft scheduler's minimum-confidence threshold (Alg-1 draft-length pruning). Spec lane only; no CLI flag — a calibration/A/B lever (see [dspark-speculative-decoding.md](../design/dspark-speculative-decoding.md)). |
120
+ | `MLX_BUN_EXPERT_OFFLOAD` | `--expert-offload` | off (`==="1"`) | Env mirror of the flag for direct/library runs (scripts that never parse serve flags). |
121
+
122
+ (`MLX_BUN_PERF_KERNEL`, `MLX_BUN_FUSED_GELU`, `MLX_BUN_FUSED_DECODE`,
123
+ `MLX_BUN_FUSED_SWIGLU*`, and `MLX_BUN_CPM5_FAITHFUL` were deleted
124
+ 2026-07-05 with their kernels — see
125
+ [unified-engine-frontier-plan.md](../design/unified-engine-frontier-plan.md);
126
+ exporting them now does nothing.)
101
127
 
102
128
  ## Execution modes: serial vs. `--batch N`
103
129
 
104
- `--batch 1` (default) is the serialized single-queue path: one GPU, one
105
- generation at a time, prompt-cache prefix reuse, mixed-precision KV by
106
- default. Untouched and unconditional.
107
-
108
- `--batch N` (N>1) opts the **whole server** into a continuous-batching
109
- engine that is **bit-parity with `mlx_lm.server` at `B=N`**. It is a
110
- *mode switch*, not a load-dependent fallback (results must not depend on
111
- concurrency).
112
-
113
- Parity is the guarantee, so the batched lane runs in **compat mode**: it
114
- exposes **none** of the serial lane's perf flags and runs the plain
115
- bit-exact forward — the same path mlx-lm runs, never the optional
116
- parity-breaking kernels. This is the mechanism of the guarantee, not a
117
- missing feature (see
118
- [compat mode](#--batch-n-is-compat-mode--perf-flags-dont-apply-by-design)).
119
- Because mlx-lm's batched path is bf16 (its quantized batching is NYI),
120
- bf16 continuous batching **is** the drop-in:
121
-
122
- - **KV quant unset ⇒ bf16** so the batch path engages out of the box
123
- ("Option B"). The serial default stays mixed-precision (optiq parity).
124
- - **Explicit `--kv-quant config|4|8` ⇒** those requests route to the
125
- serial lane (batched is bf16-only; a startup warning is printed). With
126
- an explicit `--kv-quant`, *every* request carries a quant scheme, so
127
- **nothing batches** — `--batch N --kv-quant config` is effectively
128
- serial-with-quant. Omit `--kv-quant` to actually batch.
130
+ Batching is **concurrency-driven** (default cap 8, flipped 2026-07-05): a
131
+ lone request runs the exact serial engine — its caches are ADOPTED
132
+ serial-class objects, compiled decode replays, the prompt cache and SSD
133
+ tier serve it — and only a second concurrent request causes a batch
134
+ layout to exist. "How many requests you send" is the batching decision;
135
+ the flag is just the cap.
136
+
137
+ `--batch 1` pins the strict serialized single-queue path: one generation
138
+ at a time, arrival-independent numerics (a request's bits never depend on
139
+ what else was in flight). Pin it for golden regeneration and
140
+ reproducibility work. Note the trade is real but small: batched rows
141
+ carry bf16 left-pad reduction-order noise vs their solo runs (calibrated
142
+ per model in the gated suites) — a request that never shares a step is
143
+ bit-identical either way.
144
+
145
+ The batched engine is **bit-parity with `mlx_lm.server` at `B=N`** per
146
+ row for bf16, and per-row oracle-gated for the quantized compositions
147
+ (Phase 3.1 + milestone 2 — compositions mlx-lm does not ship):
148
+
149
+ - **KV quant unset ⇒ bf16** — the batch path engages out of the box
150
+ (bf16 is the serial default too, since 2026-07-05).
151
+ - **`--kv-quant config`** (per-layer mixed precision) **batches** on
152
+ every shipped model — full-attention layers since Phase 3.1 (cpm5),
153
+ rotating layers since milestone 2 (gemma) — applied per row, gated
154
+ bit-exact/KL-0 for unpadded rows vs the serial composition.
155
+ - **`--kv-quant 4|8`** (uniform bits) routes those requests to the
156
+ serial lane (quantizedKvStart threshold semantics; a startup warning
157
+ is printed).
129
158
 
130
159
  ### The lane picker (`GenerationGateway.willBatch`)
131
160
 
@@ -140,25 +169,27 @@ concurrently with each other.
140
169
  | --- | --- |
141
170
  | vision (image parts) | ❌ serial — needs offset-0 single-seq prefill + bidirectional image mask |
142
171
  | LoRA `adapter` (resolves to ≥1) | ❌ serial — `loraState.active` is one per-generation field; per-row adapters unsupported |
143
- | `repetition_penalty` | ❌ serial — per-row logits processors are a later refinement |
144
- | `min_p` / `xtc_*` / `logit_bias` / `presence_penalty` / `frequency_penalty` | ❌ serial — one gate for the whole sampler/processor family (safe v1; min_p/XTC are per-row samplers and could batch later) |
172
+ | `logprobs` / `top_logprobs` | ❌ serial — the batched sampler doesn't capture logprob arrays yet |
145
173
  | explicit `seed` | ❌ serial — reproducibility ⇒ solo (matches mlx-lm) |
146
- | KV quant active (explicit `--kv-quant`) | ❌ serial — batched is bf16-only in v1 |
147
- | hybrid-cache model (Qwen3.5 gated-DeltaNet) | ❌ serial — model-level cache-capability gate (see below) |
174
+ | KV quant active (explicit `--kv-quant`) | ✅ batches for per-layer `config` schemes — full-attention layers (Phase 3.1) AND rotating layers (milestone 2: gemma's kv_config; unpadded row gated KL-0 vs serial); uniform bits → serial |
175
+ | `--kv-quant turbo` (TurboQuant) | ❌ serial, unconditionally — solo-only in v1 (novel `Cache` class, no batched merge/filter/temporalView; belt-and-braces refusal in `willBatch` on top of the automatic capability-gate exclusion) |
176
+ | `--draft-model` mounted | ❌ serial, server-wide — speculation is a B=1 latency mode (upstream `is_batchable = draft is None`) |
177
+ | `repetition_penalty` / `min_p` / `xtc_*` / `logit_bias` / presence+frequency penalties | ✅ batches — per-row logits processors over a per-row device-side history (since 2026-07-02; some models — Qwen3.5 — ship a *default* repetition penalty, which used to route everything serial) |
178
+ | structured output (`response_format` / `guided_*`) | ✅ batches — per-row grammar matchers driven by the scheduler (`MLX_BUN_GRAMMAR_BATCH=0` forces serial) |
148
179
  | `temperature` / `top_p` / `top_k` | ✅ batches (each row samples with its own seed) |
149
180
  | `stop` sequences | ✅ batches (per-row `StopMatcher` in the onToken closure) |
150
181
  | `tools` / `tool_choice` | ✅ batches (per-row tool router; decode-layer parse) |
151
182
  | `--thinking` / `enable_thinking` | ✅ batches (template-render concern, lane-independent) |
152
- | multi-turn / long prompt | ✅ batches, but **no prompt-cache reuse** (`cached_tokens=0`) |
183
+ | multi-turn / long prompt | ✅ batches **with prompt-cache reuse** (Phase 3.2): a joiner restores the longest usable cached prefix and prefills only the suffix (`cached_tokens` reported); a request that finishes without ever sharing the batch puts its caches back. **Prefix sharing (2026-07-05): serves are non-consuming clones**, so concurrent agents with a shared system prompt all reuse one prefill and nobody's entry is destroyed. A row that merges with others is not re-put (its entry ages out) — heavy concurrency reduces hit rate, never correctness |
153
184
 
154
- Full-attention (CPM) and sliding-window (Gemma) models batch — the
155
- scheduler assembles each layer's cache by attention type. Hybrid
156
- gated-DeltaNet models (Qwen3.5) **route serial**: their `SSMCache` has no
157
- dynamic-B merge/filter, so the gateway's cache-capability gate (the
158
- mirror of mlx-lm server.py's all-caches-have-`merge` check) sends every
159
- request down the serial lane with the standard drain behavior. A batched
160
- SSM path is planned (mlx-lm's `ArraysCache` is the oracle) — see
161
- [batching-v2-plan.md](../design/batching-v2-plan.md) item (h).
185
+ **Which models batch:** full-attention (CPM), sliding-window (Gemma),
186
+ hybrid gated-DeltaNet (Qwen3.5 — the SSM batched path, token-exact vs the
187
+ mlx-lm B=2 oracle, landed 2026-07-02; `MLX_BUN_BATCH_SSM=0` reverts it to
188
+ serial routing), and **plain full-attention Tier-0 universal archs**
189
+ (Llama etc. — per-row RoPE gated token-exact vs mlx-lm B=2, 2026-07-03).
190
+ Still serial by the model-level capability gate: gemma2-family and
191
+ sliding-window *universal* archs (unvalidated cells) and DiffusionGemma
192
+ (non-autoregressive).
162
193
 
163
194
  A non-batchable request **drains** the batch: while it waits, the
164
195
  scheduler stops admitting new rows, finishes the running ones, and
@@ -181,19 +212,23 @@ inside it per the table above).
181
212
  | Option | serial (`--batch 1`) | `--batch N` (N>1) |
182
213
  | --- | --- | --- |
183
214
  | `--kv-quant config`/`4`/`8` | ✅ applied to all requests | ⚠️ applied, but forces **all** requests to the serial lane (no batching) |
215
+ | `--kv-quant turbo[:k<bits>v<bits>]` | ✅ applied to all requests | ⚠️ applied, but forces **all** requests to the serial lane (no batching) |
184
216
  | `--kv-quant off` | ✅ bf16 | ✅ bf16 (same as the implicit batch default) |
185
- | *(kv-quant unset)* | mixed-precision `config` | **bf16** (Option B) — incl. serial-lane fallback requests |
217
+ | *(kv-quant unset)* | bf16 (the L1 default) | **bf16** (Option B) — incl. serial-lane fallback requests |
186
218
  | `--memory-budget` | ✅ per-request admission | ✅ per-request admission — but **not aggregate** across rows (see limitations) |
187
- | `--prompt-cache` | ✅ prefix reuse | ⚠️ bypassed for batched requests (`cached_tokens=0`); serial-lane requests still reuse |
219
+ | `--prompt-cache` | ✅ prefix reuse | ✅ prefix reuse on BOTH lanes (Phase 3.2): batch joiners `take()` at admission; lone never-merged rows `put()` back on finish (merged rows' entries age out) |
188
220
  | `--temperature`/`--top-p`/`--top-k` | ✅ | ✅ (per-row) |
189
221
  | `--thinking` | ✅ | ✅ |
190
222
  | vision request | ✅ | ✅ via serial lane (in bf16 under Option B) |
191
223
  | LoRA `adapter` | ✅ | ✅ via serial lane |
192
- | `repetition_penalty` | ✅ | ✅ via serial lane |
193
- | `min_p` / `xtc_*` / `logit_bias` / presence+frequency penalties | ✅ | ✅ via serial lane |
224
+ | `repetition_penalty` | ✅ | ✅ (batches — per-row logits processors, since 2026-07-02) |
225
+ | `min_p` / `xtc_*` / `logit_bias` / presence+frequency penalties | ✅ | ✅ (batches — same per-row processors) |
194
226
  | `seed` | ✅ | ✅ via serial lane |
195
227
  | `tools` / `stop` | ✅ | ✅ (batches) |
196
- | `--compiled-decode`/`--perf-kernel`/`--fused-*`/`--force-wire` | ✅ (serial perf tree) | **n/a — compat mode, no perf flags by design** |
228
+ | structured output (`response_format`/`guided_*`) | ✅ (mask in the decode loop) | ✅ (batches; per-row matchers) |
229
+ | `--draft-model` | ✅ spec decode (grammar composes) | ⚠️ mounts, but routes **every** request serial — spec and batching are different modes |
230
+ | `--compiled-decode` | ✅ (serial decode route) | ✅ at **B=1 only** (Phase 3.2): a lone request's adopted serial-class caches replay the same compiled step, same kill switch; B>1 steps run the plain graph |
231
+ | `--fused-sdpa`/`--force-wire` | ✅ (serial decode route) | **n/a — compat mode, no perf flags by design** |
197
232
 
198
233
  ### `--batch N` is compat mode — perf flags don't apply by design
199
234
 
@@ -206,77 +241,171 @@ model through `forwardHidden`/`logitsFromHidden` directly (not
206
241
  optional, parity-breaking ones. Flagging the batched lane would defeat
207
242
  the guarantee, so it's intentionally not wired.
208
243
 
209
- - **`--perf-kernel` / `--fused-decode` / `--compiled-decode`** — never
210
- engage in the batched lane. They diverge from the -O0 reference (or, for
211
- the quantized-KV kernels, are moot since batched is bf16). They're the
212
- L3 perf row, a separate KL-gated path — see the validation matrix in
213
- [parallel-slots.md](../design/parallel-slots.md).
244
+ - **`--compiled-decode`** — engages at **B=1 only** (Phase 3.2,
245
+ adopt-don't-copy): a lone request's caches stay serial-class, so the
246
+ scheduler replays the serial engine's compiled step — bit-exact by the
247
+ same gate, `MLX_BUN_COMPILED_DECODE=0` is the same kill switch. The
248
+ moment a second row joins, steps run the plain batched graph.
249
+ - **`--fused-sdpa`** — never engages in the batched lane (the fused
250
+ quantized-KV SDPA is an L2 serial-lane composition). See the
251
+ validation matrix in [parallel-slots.md](../design/parallel-slots.md).
214
252
  - **`--force-wire`** — doesn't wire (the scheduler bypasses `generate()`'s
215
253
  wired scope). A model that needs wiring for speed (e.g. 26B: 8.6 → 32.3
216
254
  tok/s wired) runs at mlx-lm-equivalent unwired speed under `--batch N`;
217
255
  in practice such a model has no headroom for B>1 KV anyway.
218
- - **Always-on bit-exact kernels still run.** Fused GeGLU is bit-exact with
219
- the spelled-out MLP, so it stays on in both lanes without breaking
256
+ - **Always-on bit-exact kernels still run.** The compiled activations
257
+ (mlx-lm's own `@mx.compile` geglu/swiglu) are bit-exact with the
258
+ spelled-out MLP, so they stay on in both lanes without breaking
220
259
  parity — "compat mode" means *no parity-breaking optionality*, not
221
260
  -O0.
222
261
 
223
- The serial lane is where the perf/optimization tree lives: `--perf-kernel`
224
- / `--fused-*` / `--compiled-decode` engage there, and the
225
- mixed-precision-KV (optiq) default applies there.
262
+ The serial lane is where the decode route lives: `--compiled-decode` /
263
+ `--fused-sdpa` / `--kv-quant` (and the `--l2` optiq composition) engage
264
+ there.
226
265
 
227
266
  ## Known limitations under `--batch N`
228
267
 
229
268
  These are deliberate v1 scope, not bugs — but they change behavior, so
230
269
  know them:
231
270
 
232
- 1. **Prompt cache bypassed.** Batched requests solo-prefill every row;
233
- `cached_tokens=0`. Wiring `PromptCache` into the scheduler is a
234
- follow-up.
235
- 2. **Admission is per-request, not aggregate.** `--memory-budget` checks
236
- each request against single-sequence max-safe-context, but N
237
- concurrent rows can collectively exceed the budget (the `B×S_max`
238
- KV-budget admission is TODO). With a tight budget and several
239
- large-context requests, the allocator cap is the only backstop — and
240
- a true GPU OOM here is uncatchable. Size the budget with headroom for
241
- N rows, or keep N small.
271
+ 1. **Prompt cache on the SPEC path is bypassed.** A `--draft-model`
272
+ server re-prefills every request; the target+draft cache-entry
273
+ composition is designed in mlx-lm-tool-parity-plan §7.6, not built.
274
+ (Batched rows DO reuse the prompt cache since Phase 3.2 — see the
275
+ lane table above and `/stats` `submitted_rows`.)
276
+ 2. **Aggregate admission is opt-in.** `--memory-budget` checks each
277
+ request against single-sequence max-safe-context; the AGGREGATE cap
278
+ across N concurrent rows is `--kv-budget <GB>` (landed 2026-07-03):
279
+ over-budget joiners queue until rows finish, a request over the budget
280
+ alone rejects. Without `--kv-budget`, N large-context rows can still
281
+ collectively exceed memory (uncatchable GPU OOM) — set it when running
282
+ `--batch N` near the machine's limit.
242
283
  3. **Short-context only.** Verified pre-ring-wrap (rows < the 1024
243
284
  sliding window). Long-context (context > window) batched decode is a
244
285
  separate validation.
245
- 4. **bf16 only — by contract.** mlx-lm's batched path *is* bf16, so
246
- bf16-only batching is exactly what mlx-lm-parity means (not a
286
+ 4. **bf16 by default — by contract; mixed-KV batching beyond it.**
287
+ mlx-lm's batched path *is* bf16, so bf16 batching is exactly what
288
+ mlx-lm-parity means. Phase 3.1 adds batched per-layer MIXED KV for
289
+ all-full-attention configs — a beyond-mlx-lm composition, verified
290
+ per row against the optiq oracle instead (not a
247
291
  shortcoming). Going further — batched + mixed-precision KV quant — is
248
292
  novel territory with no mlx-lm/optiq oracle, so it's a deferred,
249
293
  KL-gated extension.
250
- 5. **`extend` join not yet used.** A joining request re-merges the whole
251
- batch (O(B·S)); mlx-lm's keep-the-running-batch `extend` is a later
252
- optimization (numerically equivalent).
294
+ 5. **`extend` join (landed 2026-07-03).** A joining request appends to
295
+ the running batch's full-attention KV in one pad+concat (mlx-lm
296
+ `BatchKVCache.extend` semantics, token-exact vs its oracle);
297
+ `MLX_BUN_BATCH_EXTEND=0` reverts to the old whole-batch re-merge
298
+ (numerically equivalent, O(B·S)). Sliding-window layers still re-merge
299
+ on join (rotating-extend is a follow-up).
300
+
301
+ ## Fidelity tiers and the decode route (`--l1` / `--l2`)
302
+
303
+ The tiers are **correctness contracts**, and each flag is an alias for a
304
+ decode-route preset (`applyDecodeRoute()` in [src/cli.ts](../../src/cli.ts));
305
+ any per-fork flag (`--kv-quant`, `--compiled-decode`,
306
+ `--compiled-activations`, `--fused-sdpa`)
307
+ overrides its tier's preset.
308
+
309
+ | Tier | Contract | KV | Kernels | Verified against |
310
+ | --- | --- | --- | --- | --- |
311
+ | `--l1` | mlx-lm **bit-for-bit** | bf16 | compiled-decode + compiled activations (faithful geglu/swiglu) | mlx-lm goldens (per machine) |
312
+ | `--l2` | mlx-optiq **bit-for-bit** | mixed-precision (`kv_config.json`) | + fused-SDPA (matches optiq exactly) | optiq goldens |
313
+ | *(none)* | **= `--l1`** | bf16 | compiled-decode + compiled activations | mlx-lm goldens |
314
+
315
+ Compiled-decode is on in **every** tier (proven bit-exact with uncompiled —
316
+ free speed, not a fidelity trade).
317
+
318
+ **There is no `--l3` tier anymore** (removed 2026-07-05; passing `--l3`
319
+ errors out with a pointer). The tier had no content: every kernel it
320
+ gated lost its paired A/B against the L1 baseline and was deleted. Its
321
+ role is replaced by the **Lab** — output-changing experiments run as env
322
+ flags with a bench + expiry, and one graduates only by beating the L1
323
+ baseline in a paired A/B. See
324
+ [docs/design/unified-engine-frontier-plan.md](../design/unified-engine-frontier-plan.md).
325
+
326
+ **KV precision is a separate axis from the tier's kernels.** Each tier row shows its
327
+ *default* KV scheme, but KV is not what defines the tier. mlx-lm supports **uniform**
328
+ quantized KV (`--kv-bits`), and `--l1 --kv-quant 8` is a **bit-exact L1** config: with
329
+ fused-sdpa off (the L1 default) our quantized decode runs `quantizedSdpaUnfused`,
330
+ which is op-for-op identical to mlx-lm's `quantized_scaled_dot_product_attention`
331
+ (`mlx_lm/models/base.py`) — same `mx.quantized_matmul` ×2 + `mx.softmax(precise=True)`
332
+ + `where(…, finfo.min)`. bf16 is just the simplest L1 default. The **fused / N-tiled**
333
+ quantized path (fused-sdpa ON — the `--kv-quant config` / `--l2` composition) is the
334
+ **optiq**-aligned one. Only the **per-layer mixed-precision** `config` scheme is
335
+ optiq-only (→ L2).
336
+
337
+ **Where each feature sits:** batching = L1-class (mlx-lm B=N parity);
338
+ speculative decoding = L1-class (token-for-token vs mlx-lm's spec path);
339
+ structured output = L2-class (oMLX oracle; masking doesn't touch the
340
+ numerics of valid tokens, so it composes with any tier); grammar × spec
341
+ together, HLG sampling, expert offload, and (future) batched quantized KV
342
+ are **no-oracle (Lab-gated)** — no ancestor runtime does them, so they're
343
+ gated by validity/KL/quality instead of bit-parity.
344
+
345
+ ## Performance characteristics & recipes
346
+
347
+ Reference numbers are from this project's dev machines (loaded-machine
348
+ numbers are directional only; `benchmarks/RESULTS.md` holds the quotable
349
+ set, and `scripts/bench-feature-matrix.ts` measures the six composition
350
+ cells in one run).
351
+
352
+ - **Serial is the fastest single stream** — prompt cache, mixed-precision
353
+ KV, compiled decode. The batch lane at ONE live row is ~25% slower
354
+ (cpm5 ~149 vs 193 tok/s: no prompt cache, no kv-quant, wrapper
355
+ overhead), which is why serial stays the default.
356
+ - **`--batch N` wins under concurrency** — cpm5 `--batch 4`: ~349 tok/s
357
+ aggregate vs ~173 serial-queued, TTFT 2–3× better; Llama-3B at just
358
+ B=2: 1.7× aggregate, TTFT 765→162 ms.
359
+ - **Spec pays only on slow targets.** One accepted draft = one skipped
360
+ target forward, so value scales with target step cost: 12B ≈ 1.09× at
361
+ γ=1 (measured); fast small targets LOSE (e4b 0.78×; 3B smoke: spec
362
+ 61.5 vs serial 81.5 tok/s). Draft for 12B+, skip below.
363
+ - **Structured output is ~free** (<1% serial; the batch lane drops to
364
+ read-before-build scheduling while a grammar row is live — bounded,
365
+ ~0.1 ms/step class).
366
+ - **`--ssd-cache` has 0% decode overhead** — pure TTFT/restart win; no
367
+ reason to leave it off on a persistent server.
368
+ - **Quantized-KV prefill pays a scheme-intrinsic tax at long context** —
369
+ chunked prefill converts each chunk's KV at the chunk boundary, so
370
+ every later chunk attends against the already-quantized prefix
371
+ (quantize-on-write; the same streaming conversion optiq serve uses —
372
+ `maybeQuantizeKv` in `src/generate.ts` is the port). That cost is by
373
+ design, not an implementation gap: ~30% prefill throughput vs bf16 at
374
+ ~16k where the config quantizes every cache (the 1B starter measured
375
+ −33% in the 2026-07-06 serve pass), single-digit % on the
376
+ sliding-window gemmas (rotating caches stay window-bounded). The lever
377
+ is upstream — mlx's `quantized_matmul` kernels (the split-K work,
378
+ [ml-explore/mlx#3120](https://github.com/ml-explore/mlx/pull/3120);
379
+ status in [decode-speed-program.md](../design/decode-speed-program.md)
380
+ lever 2) — not this codebase.
381
+
382
+ **Recipes:**
383
+
384
+ - *Single-user agent/chat (the default use):*
385
+ `mlx-bun serve <model> --ssd-cache <dir>` — L1 serial, prompt cache +
386
+ SSD tier. On 12B+ add `--draft-model <small-same-tokenizer>`.
387
+ - *Several clients at once (throughput):*
388
+ `mlx-bun serve <model> --batch 4 --ssd-cache <dir>` — don't set
389
+ `--kv-quant` (it would just un-batch everything), don't mount a draft.
390
+ - *Reproducibility:* bare / `--l1` (≡ mlx-lm), `--l1 --batch N`
391
+ (≡ mlx-lm B=N), `--l2` (≡ optiq).
392
+ - *Memory-tight big model:* serial + `--kv-quant config|4|8` +
393
+ `--memory-budget <GB>` + `--ssd-cache <dir>`; MoE adds
394
+ `--expert-offload`. Inherently the serial recipe until batched
395
+ quantized KV lands.
396
+
397
+ **The two exclusions to remember:** batching ⊕ kv-quant (batch is bf16 by
398
+ contract; the resolver is a Lab-gated follow-up) and spec ⊕ prompt
399
+ cache (v1 bypass; the §7.6 composition is the fix).
253
400
 
254
401
  ## Observability — `GET /stats`
255
402
 
256
- The live config and batch state:
257
-
258
- ```jsonc
259
- {
260
- "server": { "owner": "serve" | "pi-session" | "embedded", "model": "...", "started_at": 0 },
261
- "prompt_cache": { "entries": 0, "bytes": 0, "max_bytes": 2000000000, "hits": 0, "misses": 0 },
262
- "response_store": { "entries": 0, "bytes": 0, "max_bytes": 33554432, "ttl_ms": 3600000 },
263
- "kv_quant": {
264
- "mode": "mixed (kv_config.json)" | "uniform-kv8" | "bf16",
265
- "layers": { "kv4": 8, "bf16": 40 },
266
- "attention": { "global": 10, "sliding_window": 38 }
267
- },
268
- "admission": {
269
- "max_safe_context": 0, // requests above this 400
270
- "memory_budget_bytes": null, // explicit budget, or null = machine default
271
- "usable_bytes": 0, "weights_bytes": 0
272
- },
273
- "batch": {
274
- "configured": 1, // the --batch N value
275
- "batched": false, // batching enabled (N>1) for this server
276
- "active_rows": 0 // rows currently decoding in the batch
277
- }
278
- }
279
- ```
403
+ The live config and batch state. The canonical field-by-field snippet
404
+ lives in [server-api.md](server-api.md#get-stats) (kept in lockstep with
405
+ the code — this section only explains how to read it); highlights:
406
+ `prompt_cache` (8 GB default cap), `kv_quant.mode` (incl. `turbo kXvY`),
407
+ the conditional `ssd_cache` block, and `batch`
408
+ (`active_rows`/`pending_rows`/`submitted_rows`/`kv_bytes`/`kv_budget_bytes`).
280
409
 
281
410
  `batch.batched` reflects only whether `--batch N` (N>1) is configured;
282
411
  with an explicit `--kv-quant` it can read `true` while `active_rows`