mlx-bun 0.0.8 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +112 -20
  2. package/docs/design/batching-perf-path.md +232 -0
  3. package/docs/design/batching-v2-plan.md +515 -0
  4. package/docs/design/bucketing-stage.md +196 -0
  5. package/docs/design/compat-cli-surface-design.md +11 -11
  6. package/docs/design/docs-reorg-plan.md +1 -1
  7. package/docs/design/dreaming-nightly-pipeline.md +291 -0
  8. package/docs/design/dspark-speculative-decoding.md +106 -0
  9. package/docs/design/generic-model-support.md +381 -0
  10. package/docs/design/memory-inference-path.md +422 -0
  11. package/docs/design/memory-synthesis.md +287 -0
  12. package/docs/design/minicpm5-decode-megakernel.md +484 -0
  13. package/docs/design/mlx-lm-tool-parity-plan.md +535 -0
  14. package/docs/design/omlx-adoption-map.md +90 -0
  15. package/docs/design/orpo-dynamic-lambda.md +41 -0
  16. package/docs/design/orpo-training.md +35 -5
  17. package/docs/design/parallel-slots.md +12 -1
  18. package/docs/design/parity-tier-dag.md +13 -5
  19. package/docs/design/segmented-backward-training.md +15 -0
  20. package/docs/design/spec-decode-larger-targets.md +229 -0
  21. package/docs/design/ssd-kv-cold-tier.md +158 -0
  22. package/docs/design/tauri-desktop-app.md +2 -2
  23. package/docs/design/the-dreaming-handoff.md +141 -0
  24. package/docs/design/the-dreaming-master-plan.md +446 -0
  25. package/docs/design/write-pipeline-entity.md +468 -0
  26. package/docs/investigations/curve-bisector-routing.md +1 -1
  27. package/docs/investigations/decode-roofline-lookagain.md +348 -0
  28. package/docs/investigations/dspark-handoff.md +58 -0
  29. package/docs/investigations/kernel-perf-review-2026-07.md +171 -0
  30. package/docs/planning/PRODUCT_ROADMAP.md +2 -2
  31. package/docs/planning/curve-sampler-research-plan.md +331 -0
  32. package/docs/planning/memory-docs-and-dag-plan.md +185 -0
  33. package/docs/planning/pi-integration-review.md +178 -0
  34. package/docs/planning/release-notes-v0.0.10.md +57 -0
  35. package/docs/planning/release-notes-v0.0.9.md +122 -0
  36. package/docs/planning/web-ui-pass-plan.md +213 -0
  37. package/docs/planning/website-readme-pass-plan.md +224 -0
  38. package/docs/reference/cli.md +255 -0
  39. package/docs/reference/embedding.md +7 -2
  40. package/docs/reference/library-api.md +22 -3
  41. package/docs/reference/memory.md +5 -5
  42. package/docs/reference/models.md +137 -0
  43. package/docs/reference/orpo-quickstart.md +6 -0
  44. package/docs/reference/server-api.md +187 -9
  45. package/docs/reference/server-config.md +43 -9
  46. package/docs/reference/training.md +31 -12
  47. package/package.json +1 -1
  48. package/src/anthropic.ts +22 -0
  49. package/src/assets/curve-designer.html +1 -1
  50. package/src/chat-template.ts +25 -7
  51. package/src/cli.ts +1077 -90
  52. package/src/config.ts +4 -1
  53. package/src/download.ts +99 -8
  54. package/src/embed.ts +77 -0
  55. package/src/eval/perplexity.ts +164 -0
  56. package/src/eval/runner.ts +15 -6
  57. package/src/eval/tasks/bfcl.ts +1 -1
  58. package/src/generate.ts +149 -18
  59. package/src/harness-pi.ts +43 -17
  60. package/src/index.ts +2 -0
  61. package/src/kv-store.ts +240 -98
  62. package/src/lora.ts +2 -2
  63. package/src/memory/article.ts +558 -0
  64. package/src/memory/chunk-validation.ts +150 -0
  65. package/src/memory/chunk.ts +305 -12
  66. package/src/memory/cluster.ts +344 -17
  67. package/src/memory/coverage.ts +134 -0
  68. package/src/memory/crosslink.ts +0 -0
  69. package/src/memory/db.ts +184 -45
  70. package/src/memory/entity.ts +203 -0
  71. package/src/memory/gate.ts +187 -0
  72. package/src/memory/history.ts +152 -0
  73. package/src/memory/ingest.ts +324 -11
  74. package/src/memory/model.ts +321 -0
  75. package/src/memory/normalize.ts +671 -0
  76. package/src/memory/parse.ts +22 -0
  77. package/src/memory/pipeline.ts +242 -30
  78. package/src/memory/prompts.ts +42 -0
  79. package/src/memory/query.ts +334 -0
  80. package/src/memory/reconcile.ts +734 -0
  81. package/src/memory/redirect.ts +108 -0
  82. package/src/memory/reindex.ts +325 -0
  83. package/src/memory/resolve.ts +307 -0
  84. package/src/memory/route.ts +272 -0
  85. package/src/memory/schedule.ts +6 -4
  86. package/src/memory/stages.ts +774 -0
  87. package/src/memory/synthesize.ts +1629 -20
  88. package/src/memory/tools.ts +446 -96
  89. package/src/memory/vault.ts +462 -35
  90. package/src/memory/wikify.ts +1057 -16
  91. package/src/mlx/array.ts +21 -0
  92. package/src/mlx/ffi.ts +3 -0
  93. package/src/mlx/ops.ts +72 -0
  94. package/src/model/compiled-decode.ts +43 -8
  95. package/src/model/factory.ts +29 -8
  96. package/src/model/flash-attention.ts +4 -2
  97. package/src/model/fused-decode-kernel.ts +14 -7
  98. package/src/model/fused-mlp-kernel.ts +169 -0
  99. package/src/model/fused-swiglu-kernel.ts +240 -0
  100. package/src/model/gemma4-base.ts +96 -7
  101. package/src/model/gemma4.ts +33 -2
  102. package/src/model/minicpm5.ts +106 -8
  103. package/src/model/qwen3-delta.ts +48 -1
  104. package/src/model/qwen3.ts +276 -0
  105. package/src/model/qwen3_5.ts +11 -2
  106. package/src/model/steel-linear-kernel.ts +96 -0
  107. package/src/model/support.ts +38 -8
  108. package/src/model/universal/archs.ts +332 -0
  109. package/src/model/universal/dense.ts +576 -0
  110. package/src/model/universal/modules.ts +163 -0
  111. package/src/model/universal/rope.ts +366 -0
  112. package/src/pi-provider.ts +1 -1
  113. package/src/pi-terminal.ts +29 -8
  114. package/src/pi-web.ts +19 -2
  115. package/src/prompt-cache.ts +55 -9
  116. package/src/quantize/config-writer.ts +1 -1
  117. package/src/registry.ts +219 -8
  118. package/src/responses.ts +23 -0
  119. package/src/sampler.ts +220 -9
  120. package/src/serve/batch-scheduler.ts +307 -83
  121. package/src/serve/generation-gateway.ts +156 -29
  122. package/src/server.ts +746 -73
  123. package/src/spec/drafter.ts +81 -49
  124. package/src/spec/dspark/data-dflash.ts +156 -0
  125. package/src/spec/dspark/data.ts +201 -0
  126. package/src/spec/dspark/generate-dflash.ts +193 -0
  127. package/src/spec/dspark/generate.ts +290 -0
  128. package/src/spec/dspark/loss.ts +213 -0
  129. package/src/spec/dspark/module-dflash.ts +361 -0
  130. package/src/spec/dspark/module.ts +540 -0
  131. package/src/spec/dspark/sample.ts +112 -0
  132. package/src/spec/generate.ts +40 -19
  133. package/src/ssd-cache.ts +213 -0
  134. package/src/status-page.html +1 -1
  135. package/src/tokenizer.ts +7 -0
  136. package/src/train/flash-cce.ts +81 -23
  137. package/src/train/fuse.ts +243 -0
  138. package/src/train/index.ts +3 -0
  139. package/src/train/job.ts +8 -0
  140. package/src/train/loss.ts +341 -40
  141. package/src/train/prefix-shared.ts +62 -11
  142. package/src/train/segmented.ts +215 -55
  143. package/src/train/trainer.ts +112 -38
  144. package/src/web/skills/memory/SKILL.md +40 -17
package/README.md CHANGED
@@ -1,10 +1,39 @@
1
1
  # mlx-bun
2
2
 
3
+ **One command, chatting in under a minute** — a fully local AI on your
4
+ Apple Silicon Mac's GPU, no Python, no API key:
5
+
6
+ ```sh
7
+ curl -fsSL https://mlx-bun.dev/install.sh | sh
8
+ mlx-bun
9
+ ```
10
+
11
+ Docs: **[mlx-bun.dev](https://mlx-bun.dev)**
12
+
3
13
  Native MLX inference for Bun, and the beginning of a one-command local AI
4
14
  product for Apple Silicon Macs. `mlx-bun` detects the machine, starts a useful
5
15
  local assistant, exposes OpenAI/Anthropic-compatible APIs, and gives
6
16
  TypeScript apps direct access to MLX without Python or a sidecar server.
7
17
 
18
+ Six goals, in one binary:
19
+
20
+ 1. **A drop-in replacement for `mlx_lm.server`** — same port, endpoints,
21
+ request fields, and flags; stop the Python server, start `mlx-bun serve`,
22
+ the same curl works ([site guide](https://mlx-bun.dev/guides/drop-in-mlx-lm/)).
23
+ 2. **A TypeScript library** — embed MLX generation in Bun/Tauri/Electron apps,
24
+ or ship it as a signed single-binary sidecar
25
+ ([library](./docs/reference/library-api.md), [embedding](./docs/reference/embedding.md)).
26
+ 3. **Bit-exact, fast, and memory-honest** — logit parity with the Python
27
+ reference as the correctness oracle, measured speed wins, and a
28
+ deterministic `fit` memory contract ([benchmarks](#benchmarks),
29
+ [correctness](#correctness)).
30
+ 4. **One command, under a minute** — the appliance path above; the default
31
+ experience needs zero configuration ([getting started](#getting-started)).
32
+ 5. **A personal memory** — a local, git-tracked wiki your assistant reads and
33
+ (via local synthesis) writes ([memory](#personal-memory)).
34
+ 6. **An AI lab** — training, speculative decoding, and sampling research kept
35
+ next to the runtime that serves them ([the lab](#the-lab)).
36
+
8
37
  The product bet is that local AI on Mac is a finite optimization problem, not
9
38
  an infinite configuration maze: Apple ships a known set of chip/RAM/bandwidth
10
39
  SKUs, the supported model list is curated, and the task mode is explicit. That
@@ -36,7 +65,7 @@ mlx-bun
36
65
 
37
66
  Self-contained (binary + MLX runtime) and notarized, so it runs without a
38
67
  Gatekeeper prompt. Installs to `~/.mlx-bun` by default — override with
39
- `MLX_BUN_INSTALL_DIR`, or pin a release with `MLX_BUN_VERSION=v0.0.4`.
68
+ `MLX_BUN_INSTALL_DIR`, or pin a release with `MLX_BUN_VERSION=v0.0.8`.
40
69
 
41
70
  ### Homebrew
42
71
 
@@ -93,7 +122,7 @@ model named, it does everything for you:
93
122
  3. starts downloading `gemma-4-e4b` (the stronger 4B model) in the
94
123
  background — it becomes the default on your next `mlx-bun serve`;
95
124
  4. opens the chat UI in your browser at
96
- [`http://localhost:8090/#/chat`](http://localhost:8090/#/chat)
125
+ [`http://localhost:8080/#/chat`](http://localhost:8080/#/chat)
97
126
  (pass `--no-open` to skip).
98
127
 
99
128
  Want a specific model instead? Name it — `mlx-bun serve e4b` (substring
@@ -105,7 +134,7 @@ your machine.
105
134
  **Prefer the API?** It's OpenAI-compatible — any OpenAI client works:
106
135
 
107
136
  ```sh
108
- curl http://localhost:8090/v1/chat/completions \
137
+ curl http://localhost:8080/v1/chat/completions \
109
138
  -H 'Content-Type: application/json' \
110
139
  -d '{
111
140
  "messages": [{"role": "user", "content": "Hello!"}],
@@ -117,9 +146,13 @@ That's it. The server serves the one model it was started with (the
117
146
  request's `model` field is ignored and the loaded model id is echoed
118
147
  back in responses).
119
148
 
149
+ Longer walkthrough on the site:
150
+ [Installation](https://mlx-bun.dev/getting-started/installation/) and
151
+ [Quickstart](https://mlx-bun.dev/getting-started/quickstart/).
152
+
120
153
  ## What mlx-bun is
121
154
 
122
- `mlx-bun` has four faces, all sharing one native runtime:
155
+ `mlx-bun` has several faces, all sharing one native runtime:
123
156
 
124
157
  - **The local AI product** — run `mlx-bun` or `bunx mlx-bun` and get a
125
158
  working local chat UI plus a local server. The default path should feel like
@@ -128,25 +161,57 @@ back in responses).
128
161
  - **The app developer library** — embed local MLX inference inside Bun,
129
162
  Tauri, Electron, and other TypeScript applications without running Python in
130
163
  the background.
131
- - **The AI lab** — keep parity, evals, kernels, LoRA/ORPO training, adapter
132
- routing, and performance experiments close to the runtime that will actually
133
- serve them.
134
164
  - **The background agent runtime** — leave a local OpenAI-compatible endpoint
135
165
  available for scripts, recurring jobs, app integrations, and per-request
136
166
  adapter routing.
137
- - **The local memory layer** — `mlx-bun memory init` creates a git-tracked
138
- Markdown wiki at `~/.mlx-bun/wiki`; `Reference/` is seeded with read-only
139
- symlinks to mlx-bun's own docs, and the built-in pi agents read both those
140
- docs and your articles quietly (`memory_search`, `memory_section`, etc.) and can open it in Obsidian for you
141
- (`mlx-bun memory open [article]`). Chat-time memory is read-only; synthesis is
142
- the separate writer path. The planned synthesis pipeline uses the same local
143
- models with stage-specific LoRA adapters (chunk, bucket, synthesize, edit)
144
- trained by the project creator and distributed from Hugging Face.
167
+ - **The local memory layer** and **the AI lab** — each gets its own section
168
+ below.
145
169
 
146
170
  The default experience is intentionally opinionated. Power users can override
147
171
  models, budgets, KV modes, adapters, and flags; the average user should not
148
172
  need to know those knobs exist.
149
173
 
174
+ ## Personal memory
175
+
176
+ `mlx-bun memory init` creates a git-tracked Markdown wiki at
177
+ `~/.mlx-bun/wiki`; `Reference/` is seeded with read-only symlinks to
178
+ mlx-bun's own docs, and the built-in pi agents read both those docs and your
179
+ articles quietly (`memory_search`, `memory_section`, etc.) and can open it in
180
+ Obsidian for you (`mlx-bun memory open [article]`). Chat-time memory is
181
+ read-only; synthesis is the separate writer path: `mlx-bun memory synthesize`
182
+ runs the built-in local pipeline (segment → extract → route → create/patch →
183
+ reconcile → link → wikify) that turns your conversations into cross-linked
184
+ subject articles, using the same local models with a fine-tuned chunking LoRA
185
+ adapter.
186
+
187
+ It's your data in your format: plain Markdown, git history, editable in any
188
+ tool, never leaves the machine. Reference:
189
+ [docs/reference/memory.md](./docs/reference/memory.md); synthesis design:
190
+ [docs/design/memory-synthesis.md](./docs/design/memory-synthesis.md); on the
191
+ site: [Personal memory](https://mlx-bun.dev/guides/memory/).
192
+
193
+ ## The lab
194
+
195
+ Parity, evals, kernels, training, and sampling research live next to the
196
+ runtime that serves them:
197
+
198
+ - **LoRA fine-tuning on your Mac** (SFT / DPO / ORPO) — `mlx-bun train
199
+ <model> --data <dir>`, with a flash-CCE Metal head, segmented backward, and
200
+ prefix-sharing so long-context preference training fits laptop memory;
201
+ watch runs live with `mlx-bun train-watch`
202
+ ([training](./docs/reference/training.md),
203
+ [quickstart](./docs/reference/orpo-quickstart.md)).
204
+ - **The curve designer** — HLG tone-curve sampling with an interactive
205
+ designer in the server UI at `/curves`
206
+ ([design](./docs/design/hlg-sampling.md)).
207
+ - **Speculative decoding research (DSpark)** — a KV-injection drafter
208
+ ([design](./docs/design/dspark-speculative-decoding.md)).
209
+ - The full research trail — including dead ends — is tracked in
210
+ [docs/design/](./docs/design/) and
211
+ [docs/investigations/](./docs/investigations/).
212
+
213
+ On the site: [The lab](https://mlx-bun.dev/about/lab/).
214
+
150
215
  ## Supported models
151
216
 
152
217
  Scope is deliberate: a few model families held to **bit-exact** logit
@@ -164,7 +229,9 @@ Currently MiniCPM5, the Gemma-4 OptiQ quants, and Qwen3.5-4B:
164
229
  Not sure what fits your machine? `bun src/cli.ts fit <model> --ctx 8192`
165
230
  gives a deterministic answer (see below). The larger
166
231
  `Qwen3.6-27B-OptiQ-4bit` is still in bring-up — parity and serving polish
167
- remain (see [PLAN.md](./PLAN.md)).
232
+ remain (see [PLAN.md](./PLAN.md)). Downloading, cache layout, and reclaiming
233
+ disk: [docs/reference/models.md](./docs/reference/models.md); on the site:
234
+ [Choosing a model](https://mlx-bun.dev/getting-started/models/).
168
235
 
169
236
  ## Why
170
237
 
@@ -204,8 +271,12 @@ bun src/cli.ts ls # list models (size, params, quant, capabili
204
271
  bun src/cli.ts ls --vision --max-size 10GB
205
272
  bun src/cli.ts fit gemma --ctx 32768 # memory contract: fits? max context? predicted tok/s
206
273
  bun src/cli.ts fit gemma --ctx 8192 --skus # ...same, across the Apple Silicon lineup
207
- bun src/cli.ts serve gemma --port 8090 # OpenAI-compatible server
274
+ bun src/cli.ts serve gemma --port 8080 # OpenAI-compatible server
208
275
  bun src/cli.ts serve gemma --memory-budget 18 # ...with admission control (GB)
276
+ bun src/cli.ts generate gemma "a haiku about metal shaders" # raw one-shot generation
277
+ bun src/cli.ts train e4b --data ./pref-data --method orpo # LoRA fine-tune (SFT/DPO/ORPO)
278
+ bun src/cli.ts memory init # git-tracked Markdown wiki at ~/.mlx-bun/wiki
279
+ bun src/cli.ts memory synthesize # conversations -> cross-linked wiki articles, all local
209
280
  bun src/cli.ts pi # built-in agentic coding CLI (pi's TUI, in-process)
210
281
  bun src/cli.ts evals # recorded benchmark runs
211
282
  bun src/cli.ts harness pi # connect your own pi install to the local server
@@ -223,11 +294,14 @@ MoE active-expert bytes for decode), calibrated prefill transient, and
223
294
  the machine's wired-memory ceiling. Predictions are recorded next to
224
295
  measured peaks in the eval DB.
225
296
 
297
+ Every verb, with flags: [docs/reference/cli.md](./docs/reference/cli.md);
298
+ on the site: [CLI reference](https://mlx-bun.dev/reference/cli/).
299
+
226
300
  ## HTTP API
227
301
 
228
302
  `serve` exposes an OpenAI-compatible API. Anything that speaks the
229
303
  OpenAI protocol can point at it — the OpenAI SDK
230
- (`baseURL: "http://localhost:8090/v1"`, any non-empty `apiKey`), or
304
+ (`baseURL: "http://localhost:8080/v1"`, any non-empty `apiKey`), or
231
305
  agent CLIs like pi/OpenClaw via their provider config.
232
306
 
233
307
  - **`POST /v1/chat/completions`** — streaming (SSE) and non-streaming;
@@ -278,21 +352,30 @@ agent CLIs like pi/OpenClaw via their provider config.
278
352
  up front is the only defense. Ceiling visible at `GET /stats`.
279
353
  - **Anthropic Messages API** (`POST /v1/messages`, on by default) —
280
354
  point any Anthropic-SDK tool at the server
281
- (`ANTHROPIC_BASE_URL=http://localhost:8090`); Claude Code runs
355
+ (`ANTHROPIC_BASE_URL=http://localhost:8080`); Claude Code runs
282
356
  against it as a fully local backend. Streaming event grammar, native
283
357
  tool_use/tool_result mapping, image blocks via the vision path.
284
358
  - **OpenAI Responses API** (`POST /v1/responses`) — the protocol
285
359
  Codex/Cursor/Continue speak; includes `previous_response_id`
286
360
  resumption backed by a TTL + byte-capped store (visible in /stats).
361
+ - **OpenAI Embeddings API** (`POST /v1/embeddings`) — when the served
362
+ model is an embedding model (`mlx-bun serve Qwen3-Embedding-4B-4bit-DWQ`):
363
+ last-token pooled, L2-normalized vectors, bit-exact vs mlx-lm. Also a
364
+ one-shot CLI: `mlx-bun embed <model> --text "…"`.
287
365
  - **`GET /v1/models`**, **`GET /stats`** (cache hit rates, bytes,
288
366
  active KV scheme, response store), **`GET/POST/DELETE /v1/adapters`**,
289
367
  plus **`GET /library`**, **`GET /fit`**, **`GET /downloads`**, and
290
368
  **`GET /v1`** (a self-describing index of every endpoint).
291
369
 
370
+ Guide with examples on the site:
371
+ [The HTTP API](https://mlx-bun.dev/guides/http-api/); coming from
372
+ `mlx_lm.server`:
373
+ [Drop-in for mlx-lm](https://mlx-bun.dev/guides/drop-in-mlx-lm/).
374
+
292
375
  ## Library
293
376
 
294
377
  The server is one consumer of a library-first API. Published to npm as
295
- `mlx-bun` (current: 0.0.4) — import from the package (`from "mlx-bun"`)
378
+ `mlx-bun` (current: 0.0.8) — import from the package (`from "mlx-bun"`)
296
379
  or `./src/index` in a clone; `bunx mlx-bun` runs the CLI without
297
380
  installing. Full reference:
298
381
  [docs/reference/library-api.md](./docs/reference/library-api.md). For shipping inside a
@@ -326,6 +409,9 @@ KV caches can be persisted to disk (page-aligned files that reload as
326
409
  zero-copy GPU-safe mmaps — see `src/kv-store.ts`) so a standard agent
327
410
  preamble prefills once, ever.
328
411
 
412
+ On the site: [Using the library](https://mlx-bun.dev/guides/library/) and
413
+ [Embedding in a Mac app](https://mlx-bun.dev/guides/embedding/).
414
+
329
415
  ## Benchmarks
330
416
 
331
417
  Head-to-head against the Python stacks (mlx-lm 0.31.3, mlx-optiq 0.2.1),
@@ -368,6 +454,9 @@ both served it from the same machine state). One further optiq cell
368
454
  (12B/kv=config) is blocked on an upstream `quantized_matmul` bug. Both
369
455
  are documented in the results file.
370
456
 
457
+ On the site (with the honest negatives spelled out):
458
+ [Benchmarks](https://mlx-bun.dev/about/benchmarks/).
459
+
371
460
  ## Correctness
372
461
 
373
462
  Logit parity with mlx-lm (same weights, Python reference) is the
@@ -387,6 +476,9 @@ bun test # fast tier runs everywhere; model-loaded tests run only
387
476
  # when the reference snapshot is in your HF cache
388
477
  ```
389
478
 
479
+ On the site: [Correctness](https://mlx-bun.dev/about/correctness/) and
480
+ [How it compares](https://mlx-bun.dev/about/comparison/).
481
+
390
482
  ## Troubleshooting
391
483
 
392
484
  - **`dlopen` / `libmlxc.dylib` not found** — the MLX runtime
@@ -0,0 +1,232 @@
1
+ # Batching perf path — promote continuous batching out of compat mode
2
+
3
+ Status: P5 + two gate fixes LANDED (2026-07-02, same day); rest PLANNED.
4
+
5
+ **Headline result (M1 Max 32 GB, loaded machine, 4 concurrent × 128 tok,
6
+ median of 3, wall-clock aggregate; oMLX 0.4.5.dev1, same OptiQ snapshots):**
7
+
8
+ | Model | oMLX | mlx-bun `--batch 4` | mean TTFT (theirs/ours) |
9
+ | --- | --- | --- | --- |
10
+ | MiniCPM5-1B | 339.0 | **345.4** | 397 / **119 ms** |
11
+ | gemma-4-e4b | **89.8** | 87.1 (−3%) | 815 / **397 ms** |
12
+ | Qwen3.5-4B | **100.9** | 99.5 (−1%) | 848 / **368 ms** |
13
+
14
+ What landed (all bit-gated, `tsc` 0, suites green):
15
+ 1. **P5 — SSM batched path** (`SSMCache.mergeRows`/`filter` in
16
+ `src/model/qwen3-delta.ts`; "ssm" kind in `batch-scheduler.ts` merge/wrap/
17
+ filter; per-row RoPE via `ropeOffsetArr` in `qwen3_5.ts`; gate relaxed in
18
+ `generation-gateway.ts`, kill switch `MLX_BUN_BATCH_SSM=0`). Oracle:
19
+ `tests/qwen35-batched-parity.test.ts` — solo-prefill+merge batched greedy
20
+ is **token-for-token identical** to mlx-lm B=2 left-padded batch
21
+ (`goldens/apple-m1-max/batched-golden-qwen35.json`). No ssm_mask needed:
22
+ rows solo-prefill unpadded, decode feeds one real token per row.
23
+ 2. **Logits processors batch** (`generation-gateway.ts`): the per-row sampler
24
+ folds `makeLogitsProcessors` over a per-row device-side token history
25
+ (generate()'s pushHistory), so repetition/presence/frequency penalties +
26
+ logit_bias + min_p/XTC no longer route serial. Load-bearing discovery:
27
+ **Qwen3.5 ships a default repetition penalty in generation_config.json**,
28
+ which routed EVERY request serial under `--batch N` — the SSM port alone
29
+ changed nothing over HTTP until this landed.
30
+ 3. Diagnostic breadcrumb: `MLX_BUN_LANE_DEBUG=1` logs per-request lane
31
+ decisions + shape (server.ts) — how (2) was found.
32
+
33
+ Bench-method traps found while measuring — **both FIXED 2026-07-02**:
34
+ - `serve`/`bench` accepted `--model` but IGNORED it (positional-only fuzzy
35
+ query; auto-pick silently loaded the wrong model). Now: `--model
36
+ <path-or-query>` is an explicit override — a directory with config.json
37
+ loads via `scanSnapshot` (no registry dependency), anything else resolves
38
+ like the positional. Precedence: `--model` > positional > `--query` >
39
+ auto-pick.
40
+ - Event-loop starvation during generation was the SERIAL lane, not batch
41
+ (batch's drive loop yields per step — `/stats` answered in ~8 ms mid-
42
+ generation once measured properly): serial decode is an unbroken microtask
43
+ chain, so `/stats`//health/new accepts stalled for the whole generation
44
+ (measured 2.5 s on a 512-token run). Fixed with a ≥25 ms rate-limited
45
+ macrotask hop wrapped around onToken in `GenerationGateway.run`'s serial
46
+ branch — covers all four API surfaces, streaming and not. After: `/stats`
47
+ 10–44 ms mid-generation, decode unchanged (cpm5 serial 193 tok/s). Motivated by the oMLX head-to-head (M1 Max,
48
+ 2026-07-02): at 4 concurrent requests oMLX's continuous batching gets
49
+ 1.46–1.89× our aggregate throughput (Qwen3.5-4B 97.3 vs 66.5 tok/s;
50
+ MiniCPM5 328 vs 173; e4b 85 vs 52) and our mean TTFT queue-inflates to
51
+ 1.1–3.8 s. Single-stream we match or beat them (e4b 55.8 vs 53.7,
52
+ Qwen3.5 66.3 vs 63.9, TTFT 3–7× better everywhere); the gap is entirely
53
+ the serial lane going flat under parallel load. Full comparison + numbers:
54
+ the 2026-07-02 oMLX report (Claude artifact) and `~/.omlx` bench scripts.
55
+
56
+ Design stance: the batch lane grows the **same parity-tier DAG as the
57
+ serial lane** (docs/design/parity-tier-dag.md). `--l1 --batch N` stays
58
+ bit-exact mlx-lm-B=N compat mode forever (the oracle checkpoint); the
59
+ default/`--l3` batch path becomes the optimized one. `--batch` remains a
60
+ mode switch, not a load fallback.
61
+
62
+ ## 1. Current state
63
+
64
+ `src/serve/batch-scheduler.ts` (`BatchScheduler`) is a real
65
+ continuous-batching engine: iteration-level scheduling, pipelined decode
66
+ (`#step` via `ops.asyncEvalAll`, mlx-lm `GenerationBatch._step`
67
+ semantics), chunked joiner prefill (2048-token chunks) interleaved with
68
+ decode, per-row eviction (`filterKVRows` / `BatchedRotatingCache.filter`),
69
+ per-row failure containment, drain-on-serial-waiter, `clearCache` every
70
+ 256 steps. Bit-parity with mlx-lm B=N verified at B=2 across all 4 model
71
+ paths (`tests/batched-decode-parity.test.ts`, `tests/batched-rotating.test.ts`,
72
+ `tests/batch-scheduler.test.ts`).
73
+
74
+ `src/serve/generation-gateway.ts` (`GenerationGateway.willBatch`) routes;
75
+ one `AsyncMutex` is the GPU/`loraState` exclusion domain. The batch lane
76
+ is deliberately compat mode: it calls `model.forwardHidden` /
77
+ `logitsFromHidden` directly, bypassing `generate()` and every perf lever.
78
+
79
+ ### Levers the batch lane lacks, and why
80
+
81
+ | Lever | Serial mechanism | Why batch can't use it today |
82
+ | --- | --- | --- |
83
+ | Quantized KV (`kv_config`) | `maybeQuantizeKv` (`src/generate.ts:101`) | `QuantizedKVCache` (`src/model/gemma4-base.ts:419`) has no dynamic-B ops (no merge/filter/extend/temporalView over the packed/scales/biases triples); `willBatch` hard-gates `kvQuant` → serial |
84
+ | Perf-kernel fused decode SDPA | dispatched from `quantizedSdpa` (`gemma4-base.ts:1560`) | kernel M=1-specialized: `fused-decode-kernel.ts:262` rejects `B !== 1 \|\| L !== 1`, and requires empty mask (batched decode needs per-row left-pad validity) |
85
+ | Fused N-tiled SDPA (`--fused-decode`) | `quantizedSdpaTiled` | needs 2-D mask; batched masks are 4-D `[B,1,1,S]` (`buildBatchedDecodeMask`); quantized-only anyway |
86
+ | Compiled decode | `CompiledDecode.for()` (`generate.ts:518-525`) | trace is `[1,1]`; B changes every join/evict |
87
+ | Prompt-cache prefix reuse | `PromptCache.take/put` in `runGeneration` | bypassed by design in v1; every batched row solo-prefills (`cachedTokens: 0`) |
88
+ | Vectorized sampling | n/a | `#step` samples per row (B slice+sample+concat graphs per token) |
89
+
90
+ Two more gap sources specific to the benchmark:
91
+
92
+ - **Qwen3.5 never batches at all**: `SSMCache` (`src/model/qwen3-delta.ts`)
93
+ has no merge/filter, so the capability gate routes every request serial.
94
+ The Qwen3.5 concurrency loss is pure serial queueing. mlx-lm batches
95
+ Mamba-family state via `ArraysCache` — oracle exists.
96
+ - Joins re-merge the whole batch (O(B·S) slice storm, `#mergeJoiner`)
97
+ instead of mlx-lm's `extend`.
98
+
99
+ ## 2. Gap analysis: quantized KV + fused kernels at B>1
100
+
101
+ - `QuantizedKVCache` already stores `[B,H,S,D']` triples; missing is
102
+ dynamic-B surgery. New `src/model/batched-quant.ts`:
103
+ `BatchedQuantizedKVCache` = quantized twin of `BatchedRotatingCache`
104
+ (merge/extend/filter/extract/temporalView = the `mergeKVRows`/`filterKVRows`
105
+ B-axis ops from `src/model/batched-mask.ts:171,196`, applied to all three
106
+ triples). Left-pad must align to quantization group boundaries — pad in
107
+ whole 256-token STEP granules (both caches already grow in STEP=256).
108
+ - Conversion point mirrors `maybeQuantizeKv`: row solo-prefills bf16,
109
+ `toQuantized` per layer per `kv_config` before merge.
110
+ - **Scope cut**: shipped `kv_config.json` quantize full-attention layers
111
+ only ⇒ P1 = quantized full layers + bf16 `BatchedRotatingCache` sliding
112
+ layers (the serial `config` composition). Batched rotating-quant deferred.
113
+ - Correctness fallback: `quantizedSdpaUnfused` (`gemma4-base.ts:1273`) is
114
+ already B-generic with `mask.mode === "array"`; needs the batched mask
115
+ broadcast as `[B,1,1,1,N]` for the 5-D GQA score reshape.
116
+ - Perf kernel at B>1: (1) drop the `B !== 1` gate (keep `L !== 1`);
117
+ (2) grid gains a B axis (`[TG_THREADS, H·B, G]`), `SCORE_ROW` offsets
118
+ index `((b·KV + kv)·N + n)`; (3) replace the empty-mask requirement with
119
+ a per-row `leftPad` int32 `[B]` input — each row's n-loop starts at
120
+ `max(blockStart, leftPad[b])`, so pad bytes are never read.
121
+ - RoPE per-row positions already solved (`ropeOffsetArr`, the S1b.1 fix).
122
+
123
+ ## 3. Phases
124
+
125
+ ### P0 — Baseline + engine quick wins (no new numerics) [S–M, 2–4 days]
126
+ - Write `scripts/bench-serving-load.ts` (N concurrent vs live server,
127
+ aggregate tok/s + TTFT p50/p95). Re-measure vs oMLX **with `--batch 4`
128
+ actually on** to size the true engine gap vs the serial-queueing gap.
129
+ - Land `extend` join (full-attention twin + `BatchedRotatingCache.extend`);
130
+ oracle: extend `scripts/gen-batched-dynamic-golden.py` (mlx-lm `extend`).
131
+ - Vectorize the homogeneous-sampler fast path in `#step` (all-greedy →
132
+ one `ops.argmaxAxis` on `[B,V]`); mixed rows keep the loop.
133
+ - Exit: gated suites unchanged; B=4 baseline recorded; joins not O(B·S).
134
+
135
+ ### P1 — Quantized KV at B>1 [M–L, 1–1.5 wk]
136
+ - `src/model/batched-quant.ts` + scheduler wiring (layer kind
137
+ `"full" | "rot" | "qfull"`); solo-prefill bf16 → `toQuantized` → extend.
138
+ - Batched mask 5-D broadcast; validate `quantizedSdpaUnfused`.
139
+ - `willBatch`: `kvQuant` stops being a serial gate when quant layers are
140
+ full-attention-only.
141
+ - Exit (parity ladder; no external oracle — mlx-lm quant batching NYI):
142
+ (1) B=1-row batched-quant **bit-exact vs serial `--kv-quant config`**;
143
+ (2) row-isolation within the calibrated bf16-reduction envelope;
144
+ (3) KL vs batched-bf16 ≤ serial quant-vs-bf16 KL; 6-task eval unchanged;
145
+ (4) `--l1 --batch N` suites untouched.
146
+
147
+ ### P2 — Perf kernel at B>1 + fallback matrix [M, ~1 wk]
148
+ - Kernel edits per §2; dispatch when every row supported, else unfused.
149
+ - Fallback matrix documented in server-config.md.
150
+ - Batched frozen-oracle envelope (B=2/4 `freeze-perf-oracle` variant,
151
+ ≥56/64 per row, `tests/perf-kernel-oracle.test.ts` pattern).
152
+ - Exit: dispatch counter proves engagement; envelope green; measured
153
+ uplift at B=4 vs P1 unfused on a clean machine; if <5%, ship the
154
+ fallback matrix only and close honestly.
155
+
156
+ ### P3 — Admission, prompt cache, adapters, defaults [M–L, 1–1.5 wk]
157
+ - KV-budget admission: `projectKvBytes(model, promptLen, maxTokens)`
158
+ beside `fit()`; `--kv-budget`, `--prompt-concurrency`;
159
+ `/stats.batch.{pending_rows,kv_bytes,kv_budget_bytes}`.
160
+ - Prompt-cache reuse under batching: `take` at admit, forward uncached
161
+ tail; on finish/evict `extract(row)` → solo caches → `put`. Quantized
162
+ entries are non-trimmable mid-group — match on full-prefix or STEP
163
+ boundaries. Real `prefillMs/decodeMs`; `BatchStats.cachedTokens` real.
164
+ - Single-adapter batches: adapter-spec = batch compatibility key
165
+ (join if match, drain if not; `loraState.active` set once per epoch).
166
+ - Default review: if batch lane at 1 live row is within ~2% of serial,
167
+ propose flipping the shipped default to `--batch 4` (Josh decision,
168
+ numerics change vs serial default); else README recommends `--batch 4`
169
+ for agent workloads.
170
+ - Exit: TTFT p50 <500 ms / p95 <1.5 s at 4 concurrent short prompts;
171
+ `cached_tokens>0` on repeats under `--batch 2`; oversized-request
172
+ queueing test (no OOM); B=4 aggregate within 15% of oMLX on cpm5 + e4b.
173
+
174
+ ### P4 — Burst decode [REVISED after reading + porting oMLX's source]
175
+ - **oMLX-style event-loop bursting: BUILT AND REFUTED (2026-07-02).** Read
176
+ their actual implementation (`omlx/engine_core.py _step_burst` — full
177
+ source ships in `/Applications/oMLX.app/Contents/Resources/omlx/`): it is
178
+ NOT a GPU trick — it runs K `scheduler.step()` calls per
179
+ `run_in_executor` hand-off with adaptive time budgets (100 ms single /
180
+ 30 ms concurrent, 64-step cap) because each per-token hand-off ping-pongs
181
+ the GIL with asyncio/uvicorn (~1 ms/token; their comment: 80 vs 74 tok/s).
182
+ Ported faithfully to `BatchScheduler.#drive` and measured: **regression**
183
+ — cpm5 B=4 345→289, batch-lane B=1 149→121, TTFT +~100 ms (SSE flushes
184
+ wait out the budget). Bun's `setImmediate` hop costs microseconds — no
185
+ GIL to dodge — so the port kept the cost and lost the win. Reverted;
186
+ numbers restored (cpm5 B=4 349.1). Breadcrumb comment left at the drive
187
+ loop. This also explains their ~21-token SSE bursts (measurement trap in
188
+ the comparison report).
189
+ - **Still open (different mechanism, not refuted): device-side step
190
+ chaining** — generalize `generateInner`'s pipeline to depth k (k chained
191
+ step graphs fed device-side, one host sync per k tokens). That attacks
192
+ OUR real per-token cost (FFI graph-build + sync readback), which is not
193
+ what oMLX's burst attacks. Exit unchanged: bit-exact vs depth-1, cpm5
194
+ single-stream gap vs oMLX (178 vs ~214) is the target; no regression at
195
+ k=1.
196
+
197
+ ### P5 — SSM batched path (Qwen3.5) [DONE 2026-07-02 — see header]
198
+ - Landed in one day, much cheaper than the ~1 wk estimate: `ssm_mask` under
199
+ left-padding turned out unnecessary (our join model solo-prefills unpadded,
200
+ so SSM state never sees a pad token; mlx-lm's masked-pad prefill is
201
+ equivalent — proven by the token-exact oracle match). Batch ops are plain
202
+ B-axis concat/take on the two state slots.
203
+ - Bonus fix that the exit benchmark forced out: logits processors now batch
204
+ (per-row fold; Qwen3.5's default repetition penalty had been routing every
205
+ request serial). Exit numbers in the header table.
206
+
207
+ Ordering: P1→P2 strictly ordered; P4 and P5 independent (P5 first if the
208
+ Qwen3.5 headline matters most). Total ~5–6 wk serial, ~4 wk with P4/P5
209
+ parallel.
210
+
211
+ ## 4. Risks
212
+
213
+ - **No external oracle for quant-batched** — mitigated by the P1 parity
214
+ ladder; `--l1 --batch N` stays the always-reachable bit-exact checkpoint.
215
+ - **Metal kernel at B>1**: leftPad start-index never touches pad bytes;
216
+ no atomics (deterministic); gate = batched frozen-oracle envelope +
217
+ dispatch assertions + A/B vs unfused.
218
+ - **Memory**: B× quant caches + prompt cache + weights on 24 GB — P3
219
+ admission is the backstop; mirror mlx-lm's
220
+ `trim_to(total − activeBatchBytes)` so LRU and live batch share one
221
+ ceiling. GPU OOM is uncatchable — queueing test before default changes.
222
+ - **Ring-wrap at B>1 short-context-verified only**: land a long-context
223
+ bf16 batched golden before any P3 default flip. Quantized rotating out
224
+ of scope.
225
+ - **Default flip changes numerics** vs today's serial default — explicit
226
+ Josh decision with doc trail.
227
+ - **Benchmark hygiene**: quotable numbers via `bench-serving-load.ts` on a
228
+ clean machine; TTFT at the SSE first-token event, not server-side
229
+ (oMLX's own server log over-reports by excluding ~180 ms/req cache
230
+ bookkeeping — measure them wall-clock only).
231
+ - **Regression fence**: all existing gated batch suites + the serial suite
232
+ green unchanged at every phase.