mlx-bun 0.2.0 → 0.3.0

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 (364) hide show
  1. package/README.md +71 -588
  2. package/docs/README.md +91 -0
  3. package/docs/{design → archive/investigations}/audio-input-plan.md +4 -4
  4. package/docs/{design → archive/investigations}/diffusion-gemma-port.md +3 -3
  5. package/docs/{design → archive/investigations}/faithful-l1-consolidation.md +4 -4
  6. package/docs/{design → archive/investigations}/minicpm5-decode-megakernel.md +11 -11
  7. package/docs/{design → archive/investigations}/optimization_plan.md +1 -1
  8. package/docs/{design → archive/investigations}/pre-colibri-stabilization.md +5 -5
  9. package/docs/{design → archive/investigations}/the-dreaming-handoff.md +2 -2
  10. package/docs/design/batching.md +569 -0
  11. package/docs/design/decode-speed-program.md +3178 -169
  12. package/docs/design/dreaming-nightly-pipeline.md +675 -282
  13. package/docs/design/generic-model-support.md +579 -334
  14. package/docs/design/kv-cache.md +680 -0
  15. package/docs/design/orpo-training.md +673 -1239
  16. package/docs/design/speculative-decoding.md +1627 -0
  17. package/docs/design/turboquant.md +2102 -0
  18. package/docs/design/unified-engine-frontier-plan.md +1831 -727
  19. package/docs/design/web-chat-redesign.md +666 -1083
  20. package/docs/planning/PRODUCT_ROADMAP.md +34 -1
  21. package/docs/planning/release-notes-v0.3.0.md +35 -0
  22. package/docs/planning/video-series.md +2 -2
  23. package/docs/reference/benchmarks.md +1162 -0
  24. package/docs/reference/cli.md +394 -159
  25. package/docs/reference/distribution.md +321 -182
  26. package/docs/reference/environment.md +348 -0
  27. package/docs/reference/glossary.md +39 -0
  28. package/docs/reference/library-api.md +566 -168
  29. package/docs/reference/memory.md +227 -64
  30. package/docs/reference/models.md +104 -6
  31. package/docs/reference/server-api.md +886 -425
  32. package/docs/reference/server-config.md +638 -330
  33. package/docs/reference/training.md +393 -254
  34. package/docs/reference/troubleshooting.md +166 -0
  35. package/package.json +9 -4
  36. package/src/audio/decode.ts +2 -2
  37. package/src/audio/features.ts +2 -2
  38. package/src/backends/mlx/autoregressive.ts +108 -0
  39. package/src/backends/mlx/batch-group.ts +1646 -0
  40. package/src/backends/mlx/diffusion.ts +39 -0
  41. package/src/backends/mlx/draft-target.ts +47 -0
  42. package/src/backends/mlx/evaluation.ts +105 -0
  43. package/src/backends/mlx/fill-append.ts +45 -0
  44. package/src/backends/mlx/gateway-binding.ts +71 -0
  45. package/src/backends/mlx/graph.ts +38 -0
  46. package/src/backends/mlx/memory-client.ts +189 -0
  47. package/src/backends/mlx/memory-guard.ts +40 -0
  48. package/src/backends/mlx/methods.ts +90 -0
  49. package/src/backends/mlx/model-prompt.ts +164 -0
  50. package/src/backends/mlx/model-serving.ts +79 -0
  51. package/src/backends/mlx/prefill.ts +33 -0
  52. package/src/backends/mlx/request-policy.ts +20 -0
  53. package/src/backends/mlx/rollback.ts +51 -0
  54. package/src/backends/mlx/serial-executor.ts +273 -0
  55. package/src/backends/mlx/speculative.ts +114 -0
  56. package/src/backends/mlx/state-views.ts +26 -0
  57. package/src/backends/mlx/text-engine.ts +73 -0
  58. package/src/cli.ts +58 -25
  59. package/src/client.ts +66 -0
  60. package/src/config.ts +21 -4
  61. package/src/contracts/completion.ts +16 -0
  62. package/src/contracts/execution.ts +49 -0
  63. package/src/contracts/generation.ts +103 -0
  64. package/src/contracts/host.ts +7 -0
  65. package/src/contracts/jobs.ts +66 -0
  66. package/src/contracts/pi-provider.ts +30 -0
  67. package/src/contracts/pi.ts +259 -0
  68. package/src/contracts/resources.ts +18 -0
  69. package/src/contracts/scheduling.ts +27 -0
  70. package/src/contracts/tsconfig.json +14 -0
  71. package/src/diffusion/diffusion-generate.ts +218 -153
  72. package/src/embed.ts +1 -1
  73. package/src/engine/admission.ts +59 -0
  74. package/src/engine/cancellation.ts +38 -0
  75. package/src/engine/engine.ts +56 -0
  76. package/src/engine/execution-coordinator.ts +70 -0
  77. package/src/engine/execution-plan.ts +51 -0
  78. package/src/engine/resources.ts +42 -0
  79. package/src/engine/scheduler.ts +67 -0
  80. package/src/engine/session.ts +247 -0
  81. package/src/eval/README.md +44 -1
  82. package/src/eval/kl.ts +236 -3
  83. package/src/eval/reference-logits.ts +207 -0
  84. package/src/eval/runner.ts +8 -96
  85. package/src/fill/echo-index.ts +188 -0
  86. package/src/fill/fill-session.ts +452 -0
  87. package/src/fill/proposal.ts +68 -0
  88. package/src/fill/schema-rows.ts +506 -0
  89. package/src/fill/tool-boundary.ts +103 -0
  90. package/src/fit.ts +1 -1
  91. package/src/generate.ts +656 -268
  92. package/src/grammar.ts +12 -7
  93. package/src/harness-pi.ts +14 -33
  94. package/src/hub-rest.ts +4 -4
  95. package/src/index.ts +7 -0
  96. package/src/inference/denoising.ts +17 -0
  97. package/src/inference/graph.ts +33 -0
  98. package/src/inference/prefill.ts +39 -0
  99. package/src/inference/rollback.ts +7 -0
  100. package/src/jobs/db.ts +4 -0
  101. package/src/jobs/index.ts +2 -0
  102. package/src/jobs/job-entry.ts +12 -12
  103. package/src/jobs/runner.ts +98 -13
  104. package/src/jobs/task-client.ts +19 -0
  105. package/src/jobs/types.ts +2 -58
  106. package/src/kv-store.ts +109 -49
  107. package/src/lab/README.md +18 -0
  108. package/src/{assets → lab/curve}/curve-designer.html +2 -2
  109. package/src/{curve-sampler.ts → lab/curve/curve-sampler.ts} +2 -2
  110. package/src/{expert-trace.ts → lab/expert-trace/expert-trace.ts} +5 -5
  111. package/src/{model → lab/paged-kv}/paged-kv.ts +9 -6
  112. package/src/library.ts +40 -0
  113. package/src/lora.ts +18 -0
  114. package/src/media-fetch.ts +1 -1
  115. package/src/memory/article.ts +1 -1
  116. package/src/memory/chunk.ts +1 -1
  117. package/src/memory/model.ts +30 -169
  118. package/src/memory/prompts.ts +1 -1
  119. package/src/memory/rest.ts +1 -1
  120. package/src/memory/schedule.ts +1 -1
  121. package/src/memory/stages.ts +4 -1
  122. package/src/memory/vault.ts +1 -3
  123. package/src/mlx/array.ts +9 -10
  124. package/src/mlx/compile.ts +12 -2
  125. package/src/mlx/ffi.ts +58 -11
  126. package/src/mlx/materialize.ts +23 -0
  127. package/src/mlx/metal-kernel.ts +10 -11
  128. package/src/mlx/ops.ts +33 -13
  129. package/src/mlx/quantized-rows.ts +36 -0
  130. package/src/mlx/token-bitmask.ts +52 -0
  131. package/src/mlx/turboquant-kv-decode.ts +82 -0
  132. package/src/mlx/turboquant-kv-inverse.ts +122 -0
  133. package/src/mlx/turboquant-ops.ts +33 -3
  134. package/src/mlx/turboquant-tables.ts +2 -2
  135. package/src/model/batched-mask.ts +8 -2
  136. package/src/model/batched-quant.ts +1 -1
  137. package/src/model/batched-rotating-quant.ts +2 -2
  138. package/src/model/batched-rotating.ts +1 -1
  139. package/src/model/compiled-decode.ts +1 -1
  140. package/src/model/diffusion-gemma.ts +8 -8
  141. package/src/model/factory.ts +76 -33
  142. package/src/model/fingerprint.ts +1 -1
  143. package/src/model/flash-attention.ts +3 -3
  144. package/src/model/gemma4-base.ts +26 -9
  145. package/src/model/gemma4.ts +6 -6
  146. package/src/model/generated/gemma4-12b.ts +2 -2
  147. package/src/model/generated/gemma4-26b.ts +2 -2
  148. package/src/model/generated/gemma4-e4b.ts +2 -2
  149. package/src/model/glm52-dsa.ts +13 -9
  150. package/src/model/glm52-memory.ts +1 -1
  151. package/src/model/implementation.ts +69 -0
  152. package/src/model/minicpm5.ts +6 -8
  153. package/src/model/profile.ts +12 -2
  154. package/src/model/qwen-append.ts +12 -0
  155. package/src/model/qwen-conv.ts +52 -0
  156. package/src/model/qwen3_5-checkpoint.ts +164 -0
  157. package/src/model/qwen3_5.ts +140 -56
  158. package/src/model/support.ts +1 -1
  159. package/src/model/trellis-balanced-scatter.ts +51 -0
  160. package/src/model/trellis-linear.ts +672 -0
  161. package/src/model/trellis-shared-m.ts +151 -0
  162. package/src/model/trellis-shared-scatter.ts +144 -0
  163. package/src/model/trellis-splitk-prefill.ts +158 -0
  164. package/src/model/trellis-tiled-prefill.ts +136 -0
  165. package/src/model/trellis-vector-expand.ts +62 -0
  166. package/src/model/trellis-wide-prefill.ts +94 -0
  167. package/src/model/universal/dense.ts +1 -1
  168. package/src/model/universal/modules.ts +8 -1
  169. package/src/native-pack.ts +6 -5
  170. package/src/pi-provider.ts +6 -64
  171. package/src/pi-web.ts +8 -315
  172. package/src/prompt-cache.ts +96 -53
  173. package/src/quantize/allocator.ts +1 -1
  174. package/src/quantize/config-writer.ts +10 -3
  175. package/src/quantize/rotate.ts +4 -4
  176. package/src/quantize/trellis.ts +586 -0
  177. package/src/responses.ts +28 -1
  178. package/src/runtime-config.ts +18 -4
  179. package/src/sampler.ts +4 -4
  180. package/src/serve/batch-scheduler.ts +8 -1671
  181. package/src/serve/chat-request.ts +391 -0
  182. package/src/serve/chat-stage.ts +195 -0
  183. package/src/serve/checkpoint-identity.ts +64 -0
  184. package/src/serve/completion-executor.ts +15 -7
  185. package/src/serve/discovery-routes.ts +12 -13
  186. package/src/serve/generation-gateway.ts +154 -193
  187. package/src/serve/http.ts +159 -0
  188. package/src/serve/inference-request.ts +79 -0
  189. package/src/serve/isolate.ts +297 -105
  190. package/src/serve/lab-routes.ts +3 -0
  191. package/src/serve/lane-registry.ts +2 -1
  192. package/src/serve/model-admin-routes.ts +7 -5
  193. package/src/serve/model-binding.ts +43 -0
  194. package/src/serve/model-host.ts +512 -0
  195. package/src/serve/openai-wire.ts +103 -0
  196. package/src/serve/parent-application.ts +56 -0
  197. package/src/serve/pipeline.ts +33 -0
  198. package/src/serve/preparation.ts +33 -0
  199. package/src/serve/request-plan.ts +19 -15
  200. package/src/serve/request-prep.ts +346 -0
  201. package/src/serve/response-lease.ts +24 -0
  202. package/src/serve/responses-client.ts +69 -0
  203. package/src/serve/session-completion-engine.ts +110 -0
  204. package/src/serve/session-search.ts +1 -1
  205. package/src/serve/static-routes.ts +5 -5
  206. package/src/serve/text-completion-stage.ts +104 -0
  207. package/src/serve/token-streams.ts +519 -0
  208. package/src/server.ts +317 -2649
  209. package/src/spec/assistant-source.ts +10 -44
  210. package/src/spec/deepspec-source.ts +6 -6
  211. package/src/spec/dflash-source.ts +8 -8
  212. package/src/spec/dspark/ab-stats.ts +3 -3
  213. package/src/spec/dspark/calibration.ts +2 -2
  214. package/src/spec/dspark/deepspec-module.ts +2 -2
  215. package/src/spec/dspark/generate-dflash.ts +2 -2
  216. package/src/spec/dspark/generate.ts +2 -2
  217. package/src/spec/dspark/loader.ts +1 -1
  218. package/src/spec/dspark/module-dflash.ts +4 -3
  219. package/src/spec/dspark/module.ts +3 -3
  220. package/src/spec/dspark/quantize-drafter.ts +1 -1
  221. package/src/spec/generate.ts +1 -1
  222. package/src/spec/glm52-mtp-source.ts +1 -1
  223. package/src/spec/ngram-source.ts +1 -1
  224. package/src/spec/prefix-state.ts +45 -0
  225. package/src/spec/qwen-mtp-source.ts +166 -69
  226. package/src/spec/serve-loop.ts +154 -156
  227. package/src/spec/source.ts +42 -10
  228. package/src/spec/two-model.ts +1 -1
  229. package/src/ssd-cache.ts +80 -12
  230. package/src/ssd-durability.ts +25 -13
  231. package/src/tool-call.ts +51 -14
  232. package/src/train/flash-cce.ts +2 -2
  233. package/src/train/forward.ts +6 -0
  234. package/src/train/loss.ts +1 -1
  235. package/src/train/prefix-shared.ts +5 -1
  236. package/src/train/segmented.ts +5 -3
  237. package/src/train/trainer.ts +6 -6
  238. package/src/vision/prompt.ts +1 -1
  239. package/src/vision/qwen3vl-tower.ts +52 -10
  240. package/src/web/app.html +1 -1
  241. package/src/web/app.js +5 -3
  242. package/src/web/src/adapters-panel.ts +3 -3
  243. package/src/web/src/assistant.ts +2 -36
  244. package/src/web/src/chat.ts +4 -4
  245. package/src/web/src/composer.ts +4 -4
  246. package/src/web/src/hub.ts +1 -1
  247. package/src/web/src/markdown.ts +0 -0
  248. package/src/web/src/memory-panel.ts +1 -1
  249. package/src/web/src/model-picker.ts +1 -1
  250. package/src/web/src/palette.ts +1 -1
  251. package/src/web/src/protocol.ts +3 -39
  252. package/src/web/src/sessions.ts +1 -1
  253. package/src/web/src/ui-catalog.ts +4 -18
  254. package/src/web-assets.ts +47 -0
  255. package/src/weights.ts +55 -9
  256. package/docs/archive/investigations/curve-runs/bisector-route.json +0 -98
  257. package/docs/archive/investigations/curve-runs/bisector-trace.json +0 -1393
  258. package/docs/archive/investigations/curve-runs/curve-terrain.json +0 -1179
  259. package/docs/archive/investigations/curve-runs/curve-terrain.svg +0 -42
  260. package/docs/archive/investigations/hlg-ab.html +0 -34
  261. package/docs/archive/investigations/hlg-explorer.html +0 -238
  262. package/docs/archive/investigations/hlg-figs/cliff.svg +0 -1
  263. package/docs/archive/investigations/hlg-figs/cross.svg +0 -1
  264. package/docs/archive/investigations/hlg-figs/edges.svg +0 -1
  265. package/docs/archive/investigations/hlg-figs/k20-grids.svg +0 -1
  266. package/docs/archive/investigations/hlg-figs/k20-peaks.svg +0 -1
  267. package/docs/archive/investigations/hlg-figs/lw-curve.svg +0 -1
  268. package/docs/archive/investigations/hlg-figs/terrain.svg +0 -1
  269. package/docs/archive/investigations/hlg-report.html +0 -262
  270. package/docs/archive/investigations/hlg-runs/hlg-ab.json +0 -149
  271. package/docs/archive/investigations/hlg-runs/hlg-blind-prompts.md +0 -87
  272. package/docs/archive/investigations/hlg-runs/hlg-blind.json +0 -107
  273. package/docs/archive/investigations/hlg-runs/hlg-cliff.json +0 -197
  274. package/docs/archive/investigations/hlg-runs/hlg-cross.json +0 -505
  275. package/docs/archive/investigations/hlg-runs/hlg-edge.json +0 -388
  276. package/docs/archive/investigations/hlg-runs/hlg-eval1.json +0 -25
  277. package/docs/archive/investigations/hlg-runs/hlg-frontier.json +0 -86
  278. package/docs/archive/investigations/hlg-runs/hlg-grid.json +0 -242
  279. package/docs/archive/investigations/hlg-runs/hlg-grid2.json +0 -343
  280. package/docs/archive/investigations/hlg-runs/hlg-lw-coarse.json +0 -110
  281. package/docs/archive/investigations/hlg-runs/hlg-map.json +0 -383
  282. package/docs/archive/investigations/hlg-runs/run0-original.log +0 -159
  283. package/docs/archive/investigations/hlg-runs/runA-recommended.log +0 -131
  284. package/docs/archive/investigations/hlg-runs/runAA-cliff-cpm5.log +0 -26
  285. package/docs/archive/investigations/hlg-runs/runAB-grid2-e4b.log +0 -18
  286. package/docs/archive/investigations/hlg-runs/runAC-peak-e4b-partial.log +0 -25
  287. package/docs/archive/investigations/hlg-runs/runAD-ab-samples.log +0 -8
  288. package/docs/archive/investigations/hlg-runs/runAE-edge-e4b.log +0 -51
  289. package/docs/archive/investigations/hlg-runs/runAF-logit-calibration.log +0 -10
  290. package/docs/archive/investigations/hlg-runs/runAG-eval-config2.log +0 -16
  291. package/docs/archive/investigations/hlg-runs/runB-gain-variants.log +0 -192
  292. package/docs/archive/investigations/hlg-runs/runC-pivots.log +0 -162
  293. package/docs/archive/investigations/hlg-runs/runD-oetf.log +0 -132
  294. package/docs/archive/investigations/hlg-runs/runE-eotf.log +0 -162
  295. package/docs/archive/investigations/hlg-runs/runF-shaper-working.log +0 -86
  296. package/docs/archive/investigations/hlg-runs/runG-shaper-sweep.log +0 -101
  297. package/docs/archive/investigations/hlg-runs/runH-wsweep.log +0 -176
  298. package/docs/archive/investigations/hlg-runs/runI-asweep.log +0 -176
  299. package/docs/archive/investigations/hlg-runs/runJ-knobmap.log +0 -49
  300. package/docs/archive/investigations/hlg-runs/runK-frontier.log +0 -19
  301. package/docs/archive/investigations/hlg-runs/runL-widemap.log +0 -65
  302. package/docs/archive/investigations/hlg-runs/runM-frontier-emb.log +0 -15
  303. package/docs/archive/investigations/hlg-runs/runN-verify.log +0 -116
  304. package/docs/archive/investigations/hlg-runs/runO-frontier-seed5000.log +0 -15
  305. package/docs/archive/investigations/hlg-runs/runP-grid-AxSm.log +0 -15
  306. package/docs/archive/investigations/hlg-runs/runQ-grid-tgW.log +0 -15
  307. package/docs/archive/investigations/hlg-runs/runR-blind-transcript.log +0 -79
  308. package/docs/archive/investigations/hlg-runs/runS-cross-coherence.log +0 -33
  309. package/docs/archive/investigations/hlg-runs/runT-cross-cpm5.log +0 -33
  310. package/docs/archive/investigations/hlg-runs/runU-cross-12b.log +0 -33
  311. package/docs/archive/investigations/hlg-runs/runV-lw-e4b.log +0 -22
  312. package/docs/archive/investigations/hlg-runs/runW-lw-12b.log +0 -22
  313. package/docs/archive/investigations/hlg-runs/runX-lw-cpm5.log +0 -22
  314. package/docs/archive/investigations/hlg-runs/runY-cliff-e4b.log +0 -26
  315. package/docs/archive/investigations/hlg-runs/runZ-cliff-12b.log +0 -26
  316. package/docs/archive/mlx-bun-lab-report.html +0 -275
  317. package/docs/archive/wwdc-mlx-bun.html +0 -848
  318. package/docs/design/adapters-end-to-end.md +0 -205
  319. package/docs/design/batching-perf-path.md +0 -255
  320. package/docs/design/batching-v2-plan.md +0 -529
  321. package/docs/design/bucketing-stage.md +0 -197
  322. package/docs/design/compat-cli-surface-design.md +0 -230
  323. package/docs/design/docs-reorg-plan.md +0 -165
  324. package/docs/design/dspark-serving-program.md +0 -371
  325. package/docs/design/dspark-speculative-decoding.md +0 -318
  326. package/docs/design/grammar-spec-batching-integration.md +0 -301
  327. package/docs/design/memory-inference-path.md +0 -422
  328. package/docs/design/memory-synthesis.md +0 -287
  329. package/docs/design/memory-system.md +0 -237
  330. package/docs/design/mlx-lm-serving-execution-seam.md +0 -81
  331. package/docs/design/mlx-lm-tool-parity-plan.md +0 -545
  332. package/docs/design/omlx-adoption-map.md +0 -98
  333. package/docs/design/orpo-dynamic-lambda.md +0 -220
  334. package/docs/design/orpo-future-enhancements.md +0 -340
  335. package/docs/design/paged-kv-cache.md +0 -214
  336. package/docs/design/parallel-slots.md +0 -567
  337. package/docs/design/parity-tier-dag.md +0 -161
  338. package/docs/design/repo-cleanup-plan.md +0 -231
  339. package/docs/design/runtime-isolation.md +0 -263
  340. package/docs/design/segmented-backward-training.md +0 -543
  341. package/docs/design/spec-decode-larger-targets.md +0 -229
  342. package/docs/design/ssd-kv-cold-tier.md +0 -415
  343. package/docs/design/structured-output.md +0 -484
  344. package/docs/design/tauri-desktop-app.md +0 -127
  345. package/docs/design/the-dreaming-master-plan.md +0 -445
  346. package/docs/design/turboquant-kv.md +0 -235
  347. package/docs/design/turboquant-weights.md +0 -467
  348. package/docs/design/w4a16-compute-precision-spike.md +0 -232
  349. package/docs/design/web-chat-beat-matrix.md +0 -315
  350. package/docs/design/write-pipeline-entity.md +0 -468
  351. package/docs/planning/IDEAS.md +0 -69
  352. package/docs/reference/embedding.md +0 -197
  353. package/docs/reference/features-matrix.md +0 -101
  354. package/docs/reference/orpo-quickstart.md +0 -135
  355. package/src/flags.ts +0 -11
  356. /package/docs/{design → archive/investigations}/colibri-glm52-port.md +0 -0
  357. /package/docs/{planning → archive/planning}/chunk-task-roadmap.md +0 -0
  358. /package/docs/{planning → archive/planning}/curve-sampler-research-plan.md +0 -0
  359. /package/docs/{planning → archive/planning}/memory-docs-and-dag-plan.md +0 -0
  360. /package/docs/{planning → archive/planning}/pi-integration-review.md +0 -0
  361. /package/docs/{planning → archive/planning}/release-notes-v0.2.0.md +0 -0
  362. /package/src/{memory-plan.ts → admission-plan.ts} +0 -0
  363. /package/{docs/archive/investigations → src/serve/assets}/curve-terrain.html +0 -0
  364. /package/{docs/dag → src/serve/assets}/training-inference-map.html +0 -0
@@ -0,0 +1,569 @@
1
+ ---
2
+ status: active
3
+ axis: ON
4
+ canonical-for: batching
5
+ plan-anchor: "Phase 18 — Concurrent / batched serving (slots) + parallel load benchmark `[~]` (2026-06-13)"
6
+ last-verified: 2026-08-23
7
+ ---
8
+
9
+ # Batching — continuous scheduling for `--batch N`
10
+
11
+ The canonical design for concurrent serving: the scheduling seam
12
+ (`src/serve/generation-gateway.ts`), the continuous-batching engine
13
+ (`src/serve/batch-scheduler.ts`), the dynamic-B cache primitives
14
+ (`src/model/batched-*.ts`), admission, and the parity discipline that
15
+ gates all of it. Consolidates docs/design/batching.md, docs/design/batching.md,
16
+ docs/design/batching.md, the batching items of
17
+ docs/design/batching.md, and the scheduler tie-in from
18
+ docs/design/kv-cache.md. Status and dated progress live in PLAN.md (see the
19
+ plan anchor above); this file describes the mechanism as it exists and
20
+ what is still open.
21
+
22
+ User-facing mirror: docs/reference/server-config.md ("Execution modes",
23
+ compatibility matrix, known limitations) and docs/reference/cli.md.
24
+
25
+ ## 1. Why batch, and why continuous
26
+
27
+ Decode on Apple Silicon is bandwidth-bound: every token streams the whole
28
+ weight set through the GPU to produce one token. Batch B sequences and
29
+ the weights stream once for B tokens — aggregate tok/s scales roughly
30
+ linearly until the compute roofline.
31
+
32
+ The motivating workload is one person's agent harness: a coding agent
33
+ fans out sub-agents against one local server. Serially, the Nth agent's
34
+ first token waits for N−1 full generations. Batched, 4–8 agents decode
35
+ together — each stream somewhat slower, nobody queued. "Single-user" in
36
+ this project means single user, many agents. Consequences that shape the
37
+ design: arrivals are uncoordinated (so scheduling is concurrency-driven),
38
+ sub-agents share long system prompts (so prefix reuse on the batch lane
39
+ matters), and agent fleets want long contexts (so quantized KV must
40
+ compose with batching).
41
+
42
+ The scheduler is **continuous** (iteration-level), never static: it
43
+ works per decode step, admitting a newly arrived request after a short
44
+ prefill and retiring finished rows between steps. A late request joins at
45
+ the next step instead of waiting for the in-flight batch to finish. By
46
+ Little's law requests overlap even at modest rates because generations
47
+ last seconds. When traffic never overlaps there is only ever one live row
48
+ and the engine runs its B=1 fast path (§4.4).
49
+
50
+ ## 2. Decisions and rationale
51
+
52
+ - **Concurrency IS the batch size.** `--batch N` is a concurrency cap
53
+ (mlx-lm's `--decode-concurrency` twin, accepted as an alias), not a
54
+ mode switch. Placement declares a *mechanism* per request; the
55
+ scheduler picks B=1 or B=N from its live row count. Default cap is 8
56
+ (`server.ts`: `serverOptions.batch ?? 8`), flipped from 1 on
57
+ 2026-07-05 once a lone scheduler request measured within 0.992–0.996
58
+ of the strict serial decode (paired A/B, byte-identical output). The
59
+ earlier "mode switch, auto-batching rejected for determinism" decision
60
+ was reversed with an answer to its objection: mlx_lm.server itself
61
+ batches by arrival, so drop-in parity *requires* it, and anyone who
62
+ needs arrival-independent numerics pins `--batch 1`.
63
+ - **`--batch 1` is the determinism pin.** It runs the strict serialized
64
+ single-queue path; a request's bits never depend on what else was in
65
+ flight. Use it for golden regeneration.
66
+ - **bf16 batching IS the mlx-lm-parity contract.** mlx-lm's batched
67
+ path is bf16 (`BatchRotatingKVCache.to_quantized` raises), so the
68
+ oracle for a batched row is **mlx-lm's own batch mode at the same B**,
69
+ bit-exact — never our own B=1 (batching changes the attention
70
+ kernel's reduction order, so B=N vs B=1 measures the wrong thing).
71
+ KV-quant unset ⇒ bf16 on both lanes.
72
+ - **Compositions inherit the scheme's oracle.** Batched per-layer
73
+ quantized KV (`--kv-quant config`) has no mlx-lm oracle; each row is
74
+ gated against the *serial* composition of the same scheme (unpadded
75
+ rows bit-exact / KL 0; padded rows within a calibrated bf16
76
+ reduction-order envelope). Lab-style KL/eval gates are for no-oracle
77
+ schemes only, never a substitute for an oracle that exists.
78
+ - **Compat mode: no parity-breaking levers on the batched step.** The
79
+ scheduler drives `forwardHidden`/`logitsFromHidden` directly. Only
80
+ bit-exact machinery engages: compiled decode at B=1 on adopted
81
+ serial-class caches (same kill switch as serial), the compiled
82
+ activations, the pipelined readback. `--fused-sdpa` and `--force-wire`
83
+ never reach the batched lane.
84
+ - **Never downgrade a composition to make it batchable.** Placement is a
85
+ support check only: it does not strip MTP/drafting, KV schemes,
86
+ TurboQuant, grammar, adapters, or sampling. A composition the scheduler
87
+ does not implement runs unchanged on the serial mechanism.
88
+ - **Drain, never starve.** A serial-lane waiter pauses admission; running
89
+ rows finish; the lock hands over FIFO; admission resumes (mlx-lm
90
+ `drain_batch`). One `AsyncMutex` is the single exclusion domain for
91
+ the GPU and shared model state (`loraState`): serial generation, the
92
+ scheduler's whole active period, curve `/generate` + `/signal`, and
93
+ adapter mount/unmount all go through it.
94
+ - **Byte-budget admission is ours, not mlx-lm's.** mlx-lm caps counts
95
+ only; we project bytes (`--kv-budget`) because GPU OOM is uncatchable.
96
+ Budget accounting is total bytes, mirroring the byte-capped
97
+ `PromptCache` precedent. Queue-don't-OOM; never evict a running row.
98
+ - **Solo prefill, then join.** Joiners prefill alone (chunked,
99
+ interleaved with decode steps) and merge/extend into the running
100
+ batch. This is why SSM state needs no `ssm_mask` under left-padding:
101
+ state never sees a pad token, and the token-exact match vs mlx-lm's
102
+ masked-pad prefill proves the equivalence.
103
+ - **oMLX-style burst decode is refuted here.** Their `_step_burst` runs
104
+ K steps per event-loop hand-off to dodge Python's per-token GIL
105
+ ping-pong (~1 ms/token). Ported faithfully and measured 2026-07-02
106
+ (M1 Max 32 GB): cpm5 B=4 aggregate 345→289 tok/s, batch-lane B=1
107
+ 149→121, TTFT +~100 ms (SSE flushes wait out the budget). Bun's
108
+ `setImmediate` hop costs microseconds; there is nothing to dodge.
109
+ Reverted; the per-yield step is the measured optimum. Don't re-add
110
+ without new evidence.
111
+ - **Spec decode and batching are different mechanisms.** A mounted
112
+ `--draft-model` routes every request serial (upstream
113
+ `is_batchable = draft is None`); per-row speculation inside the batch
114
+ is a research question, not a feature gap.
115
+
116
+ ## 3. Scheduling seam — `GenerationGateway`
117
+
118
+ `place(shape)` freezes one `GenerationPlacement { shape, mechanism }`
119
+ with `mechanism ∈ {"serial", "continuous"}`; `run()` rejects a placement
120
+ made for another shape. The predicate, exactly as implemented
121
+ (`#supportsContinuous`):
122
+
123
+ ```
124
+ continuous iff
125
+ model is not DiffusionGemmaModel (non-autoregressive)
126
+ AND batch > 1
127
+ AND #modelCachesBatchable() (cache-capability gate, below)
128
+ AND !hasVision (offset-0 prefill + bidirectional mask)
129
+ AND !hasAdapters (loraState is one global field)
130
+ AND !wantsLogprobs (batch sampler does not capture logprob arrays)
131
+ AND !userSeed (reproducibility ⇒ solo, matches mlx-lm)
132
+ AND !(kvQuant AND !#kvBatchable()) (scheme must be a batchable per-layer config)
133
+ AND !turboQuant (solo-only v1; belt on top of the capability braces)
134
+ AND !hasDraft (spec is a B=1 mode)
135
+ AND !(hasGrammar AND MLX_BUN_GRAMMAR_BATCH=0)
136
+ ```
137
+
138
+ `hasRepetitionPenalty` and `hasLogitsExtras` (min_p, XTC, logit_bias,
139
+ presence/frequency) are carried on `RequestShape` for stats only — they
140
+ do **not** gate. The load-bearing reason: Qwen3.5 ships a default
141
+ repetition penalty in `generation_config.json`, which once routed every
142
+ request serial. Per-row `StepSampler`s fold the processors over a per-row
143
+ device-side token history, so all of these batch.
144
+
145
+ **Cache-capability gate** (`#modelCachesBatchable`, mirrors mlx-lm
146
+ server.py's all-caches-have-`merge` check), memoized from a fresh
147
+ `makeCache()`:
148
+ - `UniversalDenseModel`: batchable iff `!maskArray` and no
149
+ `sliding_attention` layer type — plain full-attention Tier-0 archs
150
+ (Llama) only; gemma2-family (pad-blind causal mask in `forwardLayers`)
151
+ and sliding-window universal archs are unvalidated cells → serial.
152
+ - Otherwise every cache must be `KVCache`, `RotatingKVCache`, a
153
+ `BatchableCache` implementer (GLM-5.2's `MLACache`, which owns its own
154
+ merge/extract/filter and byte projection), or `SSMCache` (Qwen3.5's
155
+ gated-DeltaNet state; `MLX_BUN_BATCH_SSM=0` removes it → serial).
156
+
157
+ **KV-scheme gate** (`#kvBatchable`): the server's resolved `KvScheme`
158
+ batches iff `kind === "affine-config"` and `scheme.batchable()` holds
159
+ with every configured layer a plain `KVCache` or plain `RotatingKVCache`.
160
+ Uniform `--kv-quant 4|8` (quantizedKvStart threshold semantics) and
161
+ `turbo` route serial with a startup warning. The scheme is threaded to
162
+ the scheduler only when this passes; the scheduler re-checks at
163
+ construction and throws on an unsupported scheme (never bf16 service
164
+ with quantized accounting).
165
+
166
+ Startup refusals (server.ts): `--paged-kv` with `--batch N>1` throws
167
+ (paged caches can't merge); GLM `--mtp on` (its default) mounts the MTP
168
+ drafter and therefore routes serial+spec — `--mtp off` exposes ordinary
169
+ GLM batching.
170
+
171
+ **Lanes and the lock.** Serial requests run under `runExclusive`, which
172
+ counts itself as a serial waiter; the scheduler's `admissionHeld()` reads
173
+ that count. The serial branch also wraps `onToken` in a ≥25 ms
174
+ rate-limited macrotask hop: serial decode is an unbroken microtask chain
175
+ and without the hop `/stats`, `/health`, and accepts stall for the whole
176
+ generation (measured 2.5 s on a 512-token cpm5 run, 2026-07-02; after:
177
+ 10–44 ms). The batch lane needs none — its drive loop yields per step.
178
+
179
+ `/stats.batch` exposes `{configured, mode, batched, active_rows,
180
+ pending_rows, submitted_rows, kv_bytes, kv_budget_bytes}`; `mode` is
181
+ `off | serial | batch` (cap ≤1 / model not batchable / batchable).
182
+ `MLX_BUN_LANE_DEBUG=1` logs each placement with its shape.
183
+
184
+ ## 4. The engine — `BatchScheduler`
185
+
186
+ One detached Bun-async driver loop (no threads) owns one running batch.
187
+ Per iteration: purge aborted pending rows → decide `hasWork` (running
188
+ rows, an in-flight prefill — finished even under drain — or admissible
189
+ pending) → sleep on `#wake` when idle, releasing the lock and the wired
190
+ limit → otherwise acquire both, start/advance at most one joiner prefill,
191
+ burst-admit further queued rows that fit before the next step, run one
192
+ decode step, yield.
193
+
194
+ ### 4.1 Admission
195
+
196
+ FIFO from `#pending`, head-of-line, never reordered. A joiner starts iff
197
+ no prefill is in flight, admission isn't held, `running < maxBatch`, and
198
+ `#kvAdmits` passes:
199
+ - context limit: `prompt + max_tokens > batchCacheMaxTokens` (GLM's
200
+ compressed caches declare one) rejects with `RangeError`;
201
+ - no `--kv-budget` ⇒ admit;
202
+ - a row whose projected bytes exceed the budget *alone* (empty batch, no
203
+ prefill) rejects with an actionable error — never deadlocks the queue;
204
+ - otherwise admit iff `projectedKvBytes + need ≤ budget`, else wait.
205
+
206
+ Projection is worst-case: `batchRowKvBytes(config, prompt, max_tokens,
207
+ scheme)` = the scheme's `bytesAt` (window-capped for rotating layers,
208
+ quantized bytes under a config scheme), or per-cache `projectedBytes()`
209
+ when every cache is a `BatchableCache`. `/stats.batch.kv_bytes` is the
210
+ running + mid-prefill sum.
211
+
212
+ ### 4.2 Joiner prefill (chunked, interleaved, cache-aware)
213
+
214
+ - **Prompt-cache take at admission** (`RowPromptCache.take`): the solo
215
+ prefill starts from the longest usable cached prefix (namespace `""`
216
+ — adapter requests never reach this lane); `cachedTokens` is real.
217
+ Serves are non-consuming zero-copy clones, so N agents sharing a
218
+ system prompt reuse one prefill. The SSD tier lives inside `take()`,
219
+ so restores reach batch joiners too.
220
+ - **Boundary snapshot**: for a cold-ish prefill ≥256 tokens, chunking
221
+ splits exactly at `min(snapshotAt ?? len, len−1)` and a trim-free
222
+ strict-prefix entry is cloned + put there — the same invariant as the
223
+ serial lane's `snapshotAt`. Without it the lane's only entry was the
224
+ untrimmable `[prompt+gen]` finish-time put, a total miss once rings
225
+ wrap (12B batched ctx-repeat 84 s vs serial 0.4 s, 2026-07-06).
226
+ - **One chunk per loop iteration** (`prefillChunkSize`, default 2048 =
227
+ the serial constant) with `evalAll(state)` + `#quantizeSolo` +
228
+ `clearCache()` at every chunk boundary, one decode step between
229
+ chunks. Running rows stall at most one chunk per joiner; the prefill
230
+ transient stays bounded.
231
+ - **Oracle tail convention** (`MLX_BUN_PREFILL_TAIL_SPLIT`, default on):
232
+ drain to `len−1`, then an L=1 forward of the last prompt token yields
233
+ step-0 logits — mlx-lm's `insert_segments` + `GenerationBatch._step`
234
+ shape, and what keeps `--batch 1 == unified == oracle` bit-exact.
235
+ - **Slice before the head**: the last hidden position is sliced before
236
+ `logitsFromHidden` (a whole-prompt `[1,Lp,V]` would be ~4.3 GB bf16 at
237
+ Gemma's V=262k and an 8k prompt).
238
+ - **Token 0 is sampled on the solo caches before merge**, so per-row
239
+ serial equivalence holds by construction. A row that stops at token 0
240
+ (EOS, stop sequence, 1-token grammar) never joins `#running`; its
241
+ prompt-only caches go back to the prompt cache.
242
+ - **Mixed-precision conversion** (`#quantizeSolo`): the scheduler-side
243
+ mirror of serial `maybeQuantizeKv` — same per-layer map, same skip
244
+ rules, same chunk-boundary placement (`KVCache.toQuantized` /
245
+ `RotatingKVCache.toQuantized`). That placement is what makes a row's
246
+ quantized bytes bit-exact vs serial `--kv-quant config`.
247
+
248
+ ### 4.3 Join: adopt, extend, or merge — by layer kind
249
+
250
+ `#kinds[layer] ∈ {full, rot, ssm, owned-batch}` from the prototype
251
+ caches. Before any merge the decode pipeline is flushed (read + emit) so
252
+ the row set is settled and the next step starts cold.
253
+
254
+ **Adopt-don't-copy.** A row joining an *empty* batch keeps its solo
255
+ caches as the inners (pointer handoff, zero bytes moved). The lone row's
256
+ caches stay serial-class (`KVCache` / `RotatingKVCache` /
257
+ `QuantizedKVCache` / `RotatingQuantizedKVCache`), which is what makes the
258
+ B=1 step literally the serial graph and lets compiled decode and
259
+ prompt-cache put/take apply to it.
260
+
261
+ When a second row joins, per layer:
262
+
263
+ | kind | running batch cache | join op | file |
264
+ | --- | --- | --- | --- |
265
+ | full (bf16) | `KVCache` `[B,H,S,D]` + shared `#fullLeftPad` | `extendKVRows` — one pad + one B-axis concat, running rows untouched, pads grow (mlx-lm `BatchKVCache.extend`); `MLX_BUN_BATCH_EXTEND=0` = `mergeKVRows` re-merge (per-row slices, pads re-normalized; O(B·S)) | `src/model/batched-mask.ts` |
266
+ | full (quantized) | `QuantizedKVCache` over (packed, scales, biases) | `extendQuantRows` / `mergeQuantRows` — the same surgery on all three triples | `src/model/batched-quant.ts` |
267
+ | rot (bf16) | `BatchedRotatingCache` (persistent; self-tracks per-row leftPad/offsetArr through ring wrap) | always re-merge: extract each running row's temporal view + the new row → `BatchedRotatingCache.merge(rows, offsets, maxSize)`; an adopted lone `RotatingKVCache` is the first row (pad 0) | `src/model/batched-rotating.ts` |
268
+ | rot (quantized) | `BatchedRotatingQuantCache` | same recipe over triples, `merge(rows, offsets, maxSize, groupSize, bits)` | `src/model/batched-rotating-quant.ts` |
269
+ | ssm | `SSMCache` `[B,…]` state, no temporal axis, no padding | `SSMCache.mergeRows(prev, solo)` — B-axis concat | `src/model/qwen3-delta.ts` |
270
+ | owned-batch | `BatchableCache` (GLM `MLACache`) | `solo.makeEmptyBatch().mergeRows([prev, solo])`; the cache owns leftPad/rowOffsets | `src/model/glm52-cache.ts` |
271
+
272
+ Routing among these branches is **by capability only** (`isRowBatchCache`,
273
+ `isBatchableCache`, `isQuantizedKvCache`, …). `BatchedRotatingCache` has
274
+ no `signature()` override, so any signature-based conjunct silently drops
275
+ the running batch from the merge — the 2026-08-22 agg×4 outage from
276
+ 443f333 (joiners built a B<max ring; the next full-B step crashed in the
277
+ grow-path concatenate; whole-batch drop). `tests/batch-rotating-join.test.ts`
278
+ pins this.
279
+
280
+ After a real merge every row is marked `merged`: its KV is interleaved in
281
+ batched buffers and must be *extracted*, not adopted, at finish.
282
+
283
+ ### 4.4 The decode step
284
+
285
+ Pipelined, mirroring mlx-lm `GenerationBatch._step`: (1) forward all rows
286
+ from the still-unread `[B]` token register (or, cold, from each row's
287
+ `current`), sample, `asyncEvalAll` the new register; (2) then read the
288
+ *previous* register with `toIntTokens()` (an eval + raw int read — never
289
+ `toFloat32()`, whose cast kernel queued behind the whole next step and
290
+ stalled every token), emit, evict. Rows that finish get one extra
291
+ harmless KV write from the already-built step; `filter` drops the row
292
+ (mlx-lm behaves identically). `MLX_BUN_BATCH_NO_PIPELINE=1` reads
293
+ synchronously (same math, slower).
294
+
295
+ **Unpadded fast path.** When every `#fullLeftPad` is 0 the full layers'
296
+ per-step `BatchedDecodeMaskCache` / `BatchedQuantDecodeMaskCache`
297
+ wrapper is skipped: `KVCache.makeMask(1)` is already the empty mask and
298
+ every row sits at the shared scalar offset, so the bare cache dispatches
299
+ the same per-step graph serial builds. The wrapper otherwise costs a
300
+ host mask build + ~8 device nodes per full layer per token. Rot,
301
+ owned-batch, and ssm caches always pass through unwrapped.
302
+
303
+ **Compiled decode at B=1.** With exactly one row, unpadded, a uint32
304
+ register, serial-class inners (`CompiledDecode.supports`; a
305
+ filtered-to-one batched rot-quant cache is excluded), and a Gemma4
306
+ dense model, the scheduler replays the serial engine's compiled step
307
+ (`MLX_BUN_COMPILED_DECODE` is the same kill switch). A failed step is
308
+ transactional: the scheduler permanently falls back to the graph path.
309
+
310
+ **Vectorized homogeneous sampling.** When every live row is plain greedy
311
+ (`plainGreedy` from the gateway: temperature 0, no processors, no
312
+ grammar), one `toLogprobs` + `argmaxAxis` over `[B,V]` replaces B
313
+ slice/sample/concat graphs — per-row identical math, tie behavior
314
+ included. `MLX_BUN_BATCH_VEC_SAMPLE=0` forces the per-row closure path.
315
+ Length-doomed rows get a placeholder slot (never emitted; one KV write
316
+ on their own about-to-evict row).
317
+
318
+ **Grammar rows** (`#stepGrammar`): read the previous register *before*
319
+ building the graph (the matcher's `accept` needs JS numbers), `accept()`
320
+ per live grammar row (fires async bitmask fills that overlap the graph
321
+ build), `await ready()`, sample per row with the mask applied in the
322
+ closure, emit the values already read. While any grammar row is live the
323
+ batch effectively runs unpipelined — bounded by the ~0.1 ms readback +
324
+ 0.004–0.19 ms/row fills. Flushes must `accept()` the flushed tokens too,
325
+ or matchers run one token behind on every mid-decode join (found by the
326
+ conformance gate, 2026-07-03).
327
+
328
+ **Housekeeping.** `clearCache()` every 256 steps (serial's cadence;
329
+ mlx-lm batched uses 512), not per step. The per-step `setImmediate` hop
330
+ is rate-limited to 25 ms when exactly one row is running with an empty
331
+ queue and no drain — the 0.4–0.8% B=1 drive-loop tax, paired-measured
332
+ 2026-07-05. `MLX_BUN_BATCH_STEP_TRACE=1` prints per-step build/read/
333
+ emit/gap timings.
334
+
335
+ ### 4.5 Emit, evict, extract
336
+
337
+ `#emit` mirrors `generate()`: EOS terminates **without** an `onToken`
338
+ call; `onToken` returning `false` → `stop`; grammar terminated → `stop`
339
+ (the final token was delivered; never sample into an all-`-inf` mask);
340
+ `generated ≥ max_tokens` → `length`. Per-row `StopMatcher`, tool router,
341
+ and SSE fan-out live in each request's `onToken` closure.
342
+
343
+ **Failure containment.** A row's `onToken` throwing rejects and evicts
344
+ *that* row (mlx-lm `remove`); siblings continue. A forward/sampling error
345
+ cannot be attributed to a row: every row rejects and the batch KV is
346
+ dropped (`dropOnly`, never put). A prefill error rejects only the joiner.
347
+
348
+ **Eviction** (`#applyFilter(keep)`): `filterRows` on batched/owned
349
+ caches; `filterKVRows` / `filterQuantRows` (B-axis `takeAxis`) on full
350
+ layers; the pending register and `#pendingReal` flags are filtered in
351
+ lockstep. Full-layer filter keeps stale left padding (no `min_left_pad`
352
+ shift-left) — width waste, not correctness (open, §8).
353
+
354
+ **Finish-time prompt-cache put.** A lone never-merged row hands its
355
+ adopted serial-class caches back zero-copy, keyed by `promptIds + fed`
356
+ (`fed` = generated tokens whose KV actually entered the cache, per-row
357
+ exact via the register's real/placeholder flags; a placeholder that ever
358
+ fed taints the row and extraction refuses). A merged row with ≥256
359
+ prompt tokens is **extracted** per layer into fresh owned serial caches
360
+ before the filter mutates the batch (`#extractRowCaches`: `extractRow`,
361
+ `extractKVRow`, `extractQuantRow`; SSM only when the row's advance count
362
+ equals the key exactly — recurrent state is untrimmable), evaluated
363
+ asynchronously so the batched source buffers free once the copies land,
364
+ then put. Extract is a pure slice+copy of byte-preserved rows
365
+ (`tests/batched-extract.test.ts`), so an extracted row's bytes equal the
366
+ solo run's. All take/put happens inside the gateway's exclusion domain —
367
+ the scheduler is the sole owner of cache mutations on its lane.
368
+
369
+ ## 5. The one hard numerics problem: per-sequence position
370
+
371
+ Rows are different lengths at different positions, but a cache has one
372
+ `offset`, RoPE takes one offset, and the mask is built from it. mlx-lm's
373
+ solution, and ours: **left-padding + per-row mask + per-row RoPE**.
374
+
375
+ - Right-align every row so "the current token" is one column → one
376
+ advancing offset still works. Full layers share `#fullLeftPad`; rot
377
+ caches self-track per-row `leftPad`/`offsetArr` across the ring wrap.
378
+ - Mask padding with a real `[B,1,1,S]` boolean mask (`j >= leftPad[b]`,
379
+ `buildBatchedDecodeMask`) even at the N=1 decode step, and use the same
380
+ fused bool-mask `scaled_dot_product_attention` mlx-lm uses. An additive
381
+ mask would deviate.
382
+ - RoPE positions are a per-row array (`ropeOffsetArr`,
383
+ `ops.ropeDynamic`; `UniversalRope.applyDynamic` for Tier-0 archs).
384
+ Two traps every attention implementation must respect: capture the
385
+ offset array **once** per forward (reading it before and after
386
+ `updateAndFetch` gives K and Q different positions — the 2026-06-14
387
+ Gemma fix; the generator `scripts/gen-model.ts` carries it for
388
+ generated handlers), and a batched cache's `ropeOffsetArr` must be
389
+ **stable within a step** and refreshed only in `releaseRopeArr` (the
390
+ rope-array step-stability contract from milestone 2). MiniCPM5
391
+ (2026-06-14) and universal dense (2026-07-03, latent since v0.0.9)
392
+ each shipped a scalar-offset bug that only the batched oracle caught:
393
+ every model family wires the per-row path separately.
394
+ - Batched prefill numerics differ from B=1 prefill (GEMV vs GEMM
395
+ reduction order), and a padded row's attention sum accumulates in a
396
+ different order than its solo run. The unpadded row is bit-exact vs
397
+ solo; the padded row carries bounded bf16 noise (≤0.23 logit diff over
398
+ 8 steps, CPM, 2026-06-14). Calibrate the envelope per model; don't
399
+ chase it — mlx-lm B=2 diverges from its own B=1 the same way.
400
+
401
+ ## 6. KV memory model
402
+
403
+ Three rungs, in increasing fidelity of "who needs it most":
404
+
405
+ 1. **Static per-slot partition** — rejected, never built (wastes memory,
406
+ arbitrary per-slot context cap).
407
+ 2. **Dynamic shared budget, contiguous padded caches** — what ships.
408
+ Each layer holds one `[B,H,S,D]` buffer (growing in `KVCache.STEP`
409
+ = 256 granules) whose width tracks the *longest* live row; short rows
410
+ pay padding to that width. Admission is by projected total bytes
411
+ (§4.1). Quantized KV multiplies the budget (~4× at 4-bit), and
412
+ batching × quantized KV compounds.
413
+ 3. **Paged KV** — the density upgrade. `PagedKVCache`/`BlockPool`
414
+ (`src/lab/paged-kv/paged-kv.ts`, `--paged-kv`) shipped as a serial-only,
415
+ bf16, Gemma4 full-attention block manager, bit-exact vs the plain
416
+ path. The batching payoff is **not built**: paged caches into the
417
+ scheduler's `LayerInner` union, block-count admission replacing byte
418
+ projection, `#mergeJoiner`/`#applyFilter`/`#extractAndPut` allocating
419
+ and freeing blocks instead of pad+concat, then block-level CoW prefix
420
+ sharing and a fused paged-attention kernel. That is where the
421
+ padded-batch-waste win lives (a 50-token row cohabiting with a
422
+ 4000-token row pays ~4000 tokens of KV today).
423
+
424
+ The `--prompt-cache` budget is the *prefix* cache; live decode KV is a
425
+ separate pool. mlx-lm trims its prompt cache to `total − live batch
426
+ bytes`; sharing one ceiling between the LRU and the live batch is still
427
+ open (§8).
428
+
429
+ ## 7. Verification
430
+
431
+ Oracle discipline: every batched mechanism lands with a token-for-token
432
+ gate against whoever ships the same protocol, generated from the oracle
433
+ venv (`scripts/oracle/gen-batched-golden.py` static B=N;
434
+ `gen-batched-dynamic-golden.py` merge/extract/filter join+leave;
435
+ `gen-batched-extend-golden.py` — extend's pad layout differs from
436
+ re-merge, so it has its own golden; `gen-rotating-golden.py`
437
+ `BatchRotatingKVCache` model-free incl. ring wrap). Goldens are
438
+ machine-layered (`goldens/*.json`, per-GPU overrides under
439
+ `goldens/<machine>/`). The B=N-vs-our-B=1 KL harness is an internal
440
+ consistency check only, never the gate.
441
+
442
+ | Behavior | Test | Tier |
443
+ | --- | --- | --- |
444
+ | rotating merge/decode/mask/filter incl. ring wrap vs mlx-lm; generic row-storage state | `tests/batched-rotating.test.ts`, `batched-rotating-state.test.ts` | fast, oracle |
445
+ | static B=2 + dynamic join/leave + extend-join vs mlx-lm (CPM, Gemma 12B/e4b/26B, Llama 3B Tier-0) | `tests/batched-decode-parity.test.ts` (`MLX_BUN_TEST_BATCH_DECODE=1`) | gated, oracle |
446
+ | Qwen3.5 SSM batched vs mlx-lm B=2 | `tests/qwen35-batched-parity.test.ts` | gated, oracle |
447
+ | scheduler orchestration (admit/step/evict/join) | `tests/batch-scheduler.test.ts` | gated (teacher-forced KL + 12B golden) |
448
+ | per-row containment, drain, full-attention join routing | `tests/batch-containment.test.ts` | gated |
449
+ | rotating-layer join mid-decode (2026-08-22 regression) | `tests/batch-rotating-join.test.ts` | gated |
450
+ | KV-budget queueing + projection math | `tests/batch-kv-budget.test.ts`, `kv-budget-projection.test.ts` | gated / fast |
451
+ | vectorized sampling bit-equal A/B | `tests/batch-vec-sample.test.ts` | gated |
452
+ | grammar under batching (mixed masks, 4 schemas at B=4, churn, joiner, truncation) | `tests/batch-grammar.test.ts` | gated |
453
+ | quantized dynamic-B ops; batched mixed-KV per row vs serial composition | `tests/batched-quant.test.ts`, `batched-rotating-quant.test.ts`, `batched-kv-quant-parity.test.ts` | fast / gated |
454
+ | per-row extract == solo bytes | `tests/batched-extract.test.ts` | fast |
455
+ | live server: engage, `/stats`, SSE fan-out, serial coexistence | `tests/batch-serving.test.ts` | gated smoke |
456
+
457
+ Benchmark harness: `scripts/bench-matrix.ts features` (six-cell
458
+ composition matrix over live SSE: TTFT p50/p95 at the first SSE token,
459
+ wall-clock aggregate tok/s, schema conformance as a hard gate);
460
+ `scripts/bench-serving-load.ts` is the client-only stack-vs-stack tool.
461
+ Quotable numbers come from a clean machine only; SSE bursts inflate
462
+ naive tok/s — use wall-clock.
463
+
464
+ ## 8. Open items
465
+
466
+ - **`--prompt-concurrency`**: not accepted by the CLI (mlx-lm's cap on
467
+ joiners per iteration; the drop-in alias slot). Today at most one
468
+ joiner prefills at a time.
469
+ - **Rotating-layer `extend`**: Gemma joins extend their full layers but
470
+ re-merge their sliding layers (`BatchedRotatingCache.merge` only).
471
+ - **Full-layer filter `min_left_pad` shift** after eviction (mlx-lm
472
+ cache.py does it); width waste only.
473
+ - **Logprobs on the batch lane**: `wantsLogprobs` routes serial; the
474
+ per-row sampler already computes `toLogprobs` — extend `RowSampler` to
475
+ return extras and pass them through `onToken`.
476
+ - **Batched timing stats**: the gateway returns `prefillMs/decodeMs/
477
+ *Tps = 0` for continuous rows; `usage` timing is missing there.
478
+ `maxTokens ?? 512` fallback in the gateway differs from the server
479
+ default (dead in practice, a trap for library callers).
480
+ - **LoRA-group batching**: adapter requests route serial. Design
481
+ decided (adapter-set as the batch compatibility key; join if equal,
482
+ drain if not; `loraState.active` set once per epoch); end state is
483
+ per-slot adapter state in the batched step (vLLM/Punica is the oracle).
484
+ - **Prompt-cache byte interplay**: mirror mlx-lm's
485
+ `trim_to(total − activeBatchBytes)` so the LRU and the live batch
486
+ share one ceiling. Derived default for `--kv-budget` (usable − weights
487
+ − prompt-cache cap − headroom) instead of unlimited-when-unset.
488
+ - **Long-context batched golden**: bf16 batched decode past the sliding
489
+ window is oracle-gated model-free (`batched-rotating`), not yet as an
490
+ end-to-end forward golden on a real Gemma.
491
+ - **Unvalidated model cells** stay serial by the capability gate:
492
+ gemma2-family (`maskArray`) and sliding-window universal archs.
493
+ - **Paged KV batched integration** (§6 rung 3): block allocation in the
494
+ scheduler, block-count admission, block CoW, fused paged kernel.
495
+ - **GLM per-row MTP**: `--mtp on` routes serial+spec; batched MTP is
496
+ post-release.
497
+ - **Grammar jump-forward** (`MLX_BUN_GRAMMAR_JUMP`) is serial-only;
498
+ `#stepGrammar` does not jump. Native XGrammar-2 via TVM-FFI has a
499
+ B≫8 trigger.
500
+ - **Device-side step chaining (depth-k pipelining)**: serial-decode
501
+ work, orthogonal to the scheduler; the matrix's serial baselines show
502
+ its headroom.
503
+ - **Batch-invariant kernels** (identical numerics regardless of B): Lab
504
+ research item; would restore determinism under load for free.
505
+ - **Per-row speculation inside the batch**: non-goal (variable accept
506
+ lengths break uniform B); research question, not scheduled.
507
+ - **Stale source comments**: `batch-scheduler.ts` header still says
508
+ joins re-merge (extend landed 2026-07-04); `generation-gateway.ts`
509
+ header and server-config.md say merged rows are never re-put
510
+ (`#extractAndPut` now extracts merged rows ≥256 prompt tokens).
511
+
512
+ ## 9. Measured numbers (labels required)
513
+
514
+ - oMLX head-to-head, **M1 Max 32 GB, loaded machine, 2026-07-02**, 4
515
+ concurrent × 128 tok, median of 3, wall-clock aggregate tok/s, oMLX
516
+ 0.4.5.dev1 on the same OptiQ snapshots: MiniCPM5-1B 339.0 vs mlx-bun
517
+ `--batch 4` 345.4 (mean TTFT 397 vs 119 ms); gemma-4-e4b 89.8 vs 87.1;
518
+ Qwen3.5-4B 100.9 vs 99.5 (TTFT 848 vs 368 ms). Before P5/logits
519
+ processors landed the same day, oMLX led 1.46–1.89× and our TTFT
520
+ queue-inflated to 1.1–3.8 s.
521
+ - Batch-lane B=1 gap before Phase 2, **cpm5, M1 Max, 2026-07-05**: ~149
522
+ tok/s vs 267 serial (1.8×). mlx-lm's own `BatchGenerator` at B=1:
523
+ 256.5 vs 264.6 for `stream_generate` (3.3% tax) — the unified design
524
+ was proven achievable before the gap was closed.
525
+ - After Phase 3.2, **2026-07-05 paired A/B**: unified B=1 decode ratios
526
+ 0.992–0.996 vs strict serial, byte-identical output (the default-flip
527
+ evidence).
528
+ - Batched mixed-KV, **cpm5, 2026-07-05**: `--batch 2 --kv-quant config`
529
+ 400 tok @ 240 tok/s aggregate; B=1 bit-exact vs the optiq golden;
530
+ padded-row KL within the 5e-2 envelope (bf16 same-harness ~9e-3).
531
+ Gemma 12B rotating-quant B=2 join: unpadded row KL 0 every step, padded
532
+ ≤4e-3.
533
+
534
+ ## 10. History
535
+
536
+ - 2026-06-13 — Phase 18 opened; batched padded prefill found already
537
+ proven in the training path (`buildBatchedPadMask`, `BatchedMaskCache`).
538
+ - 2026-06-14 — `--batch N` flag (designed as `--slots`; `--decode-concurrency`
539
+ alias); `BatchedDecodeMaskCache` + decode-parity harness; dynamic-B
540
+ `mergeKVRows`/`filterKVRows` vs mlx-lm dynamic golden;
541
+ `BatchedRotatingCache`; `BatchScheduler` + `GenerationGateway` wired;
542
+ L1 row green — CPM, Gemma 12B, e4b, 26B bit-exact vs mlx-lm B=2.
543
+ - 2026-07-01 — v2 hotfix bundle: slice-before-head, cache-capability
544
+ gate, one lock domain (`runExclusive`); per-row containment +
545
+ drain-on-serial-waiter; engine hygiene (pipelined decode, clearCache/256,
546
+ chunked interleaved admit).
547
+ - 2026-07-02 — SSM batched path (Qwen3.5, token-exact vs mlx-lm B=2);
548
+ per-row logits processors; `MLX_BUN_LANE_DEBUG`; serial-lane macrotask
549
+ hop; oMLX burst port refuted; `--model` override honored by serve/bench.
550
+ - 2026-07-03 — grammar B1 per-row matchers + churn gates; `--kv-budget`
551
+ admission + `/stats.batch` fields; Tier-0 universal per-row RoPE fix
552
+ (Llama token-exact vs mlx-lm B=2); `bench-matrix.ts features`.
553
+ - 2026-07-04 — extend-join for full-attention layers (own oracle);
554
+ vectorized homogeneous greedy sampling (bit-equal A/B).
555
+ - 2026-07-05 — Phase 2: B=1 host tax closed (`toIntTokens`, unpadded
556
+ fast path); Phase 3.1: batched per-layer quantized KV (full layers);
557
+ Phase 3.2: adopt-don't-copy, compiled decode at B=1, prompt-cache
558
+ take/put on the batch lane; milestone 2: batched rotating-quant (every
559
+ shipped `kv_config` batches); `--batch` default 1→8; concurrency-is-the-
560
+ batch-size decision.
561
+ - 2026-07-06 — boundary-snapshot put on the batch lane (12B ctx repeat
562
+ 84 s → serial-class).
563
+ - 2026-07-07 — rot-quant join `temporalView` leak fixed.
564
+ - 2026-08-21 — Serving consolidation: immutable `GenerationPlacement`
565
+ (`serial` | `continuous`); resolved `KvScheme` authoritative through
566
+ placement, conversion, and projection; GLM `MLACache` as a
567
+ `BatchableCache`.
568
+ - 2026-08-22 — rotating-join regression from 443f333 fixed (capability-only
569
+ merge routing); `tests/batch-rotating-join.test.ts`.