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
@@ -1,1671 +1,8 @@
1
- // Continuous-batching scheduler for `--batch N` serving (phase S2, the engine
2
- // loop). Owns ONE running batch and drives it forward one decode step at a
3
- // time, admitting waiting requests and evicting finished ones between steps —
4
- // iteration-level (continuous) scheduling, not static batching. See
5
- // docs/design/parallel-slots.md and docs/design/batching-v2-plan.md.
6
- //
7
- // The numerically-hard parts are verified primitives:
8
- // - the batched FORWARD (per-row RoPE/mask) is bit-parity with mlx-lm B=N
9
- // across all 4 models (tests/batched-decode-parity);
10
- // - the dynamic-B FULL-attention ops mergeKVRows / filterKVRows match mlx-lm
11
- // BatchKVCache (tests/batched-decode-parity), and the SLIDING-window
12
- // BatchedRotatingCache (merge/filter/decode/make_mask incl. ring-wrap)
13
- // matches mlx-lm BatchRotatingKVCache (tests/batched-rotating).
14
- // This module is the ORCHESTRATION on top: admission, the step loop, per-row
15
- // sampling + token accounting, eviction, and assembling each layer's batched
16
- // cache by type. Gate: tests/batch-scheduler.test.ts (teacher-forced, KL).
17
- //
18
- // Per-layer cache types: a model interleaves full-attention layers (plain
19
- // KVCache, wrapped per step in a BatchedDecodeMaskCache) and sliding-window
20
- // layers (a persistent BatchedRotatingCache that is itself the batched cache +
21
- // mask). Full layers share one leftPad/offset (all rows advance together); the
22
- // rotating caches self-track per-row leftPad/offset as the ring wraps. The
23
- // per-row absolute position stays consistent across both (full: offset-leftPad;
24
- // rot: offsetArr) — see docs/design/parallel-slots.md. Hybrid gated-DeltaNet
25
- // models (Qwen3.5) add "ssm" layers: SSMCache state is plain [B,...] with no
26
- // temporal axis and no padding (rows solo-prefill unpadded, decode feeds one
27
- // real token per row), so merge/filter are B-axis concat/take and the cache
28
- // passes through the step unwrapped (no mask, no per-row RoPE — full layers
29
- // carry positions). Gate: GenerationGateway.place on cache capability.
30
- //
31
- // Engine mechanics (the serial decode loop's hygiene, transplanted —
32
- // batching-v2-plan step 3):
33
- // - PIPELINED decode: each step builds the NEXT step's graph from the
34
- // still-unread sampled-token array (asyncEval), then reads the PREVIOUS
35
- // step's tokens while the new step computes — mlx-lm GenerationBatch._step.
36
- // The pipeline is flushed (read + emit) before a join merges, so admission
37
- // never has to reconcile an in-flight token array with a new row.
38
- // - clearCache every 256 steps (serial's cadence; mlx-lm batched uses 512),
39
- // not every step — per-step clears trashed the buffer pool each token.
40
- // - CHUNKED, INTERLEAVED admission: a joiner prefills prefillChunkSize
41
- // tokens per loop iteration with one batch decode step run in between, so
42
- // running rows stall at most one chunk per joiner (mlx-lm interleaves the
43
- // same way), and the prefill transient stays bounded.
44
- // - Failure containment: one row's onToken throwing evicts THAT row (its
45
- // promise rejects); siblings keep decoding (mlx-lm `remove` semantics). A
46
- // forward/sampling error still drops the whole batch (can't be attributed
47
- // to a row).
48
- //
49
- // Bun-async, NO threads: a single detached driver loop owns the GPU for batched
50
- // mode (an ExclusiveLock keeps the serial fallback off the GPU concurrently).
51
- // When `admissionHeld` reports a waiting serial-lane request, the loop stops
52
- // admitting, finishes the running rows, and releases the lock so the serial
53
- // request runs (mlx-lm's drain_batch) — resumed via kick().
54
- // Joins re-merge the whole batch; the keep-the-running-batch `extend`
55
- // optimization is a later refinement (batching-v2-plan item a).
56
-
57
- import { MlxArray } from "../mlx/array";
58
- import * as ops from "../mlx/ops";
59
- import { activeMemory, cacheMemory, clearCache, Dtype, peakMemory } from "../mlx/ffi";
60
- import { flagOn } from "../flags";
61
- import { runtimeValue } from "../runtime-config";
62
- import { CompiledDecode } from "../model/compiled-decode";
63
- import type { Gemma4Model } from "../model/gemma4";
64
- import {
65
- KVCache, QuantizedKVCache, RotatingKVCache, RotatingQuantizedKVCache,
66
- cacheSignature,
67
- isBatchableCache,
68
- isPlainKvCache,
69
- isQuantizedKvCache,
70
- isRotatingPlainCache,
71
- isRotatingQuantizedCache,
72
- isRowBatchCache,
73
- type BatchableCache,
74
- type Cache,
75
- } from "../model/gemma4-base";
76
- import { BatchedDecodeMaskCache, mergeKVRows, extendKVRows, extractKVRow, filterKVRows } from "../model/batched-mask";
77
- import {
78
- BatchedQuantDecodeMaskCache, extendQuantRows, extractQuantRow, filterQuantRows, mergeQuantRows,
79
- type QuantRow,
80
- } from "../model/batched-quant";
81
- import { BatchedRotatingQuantCache } from "../model/batched-rotating-quant";
82
- import type { KvQuantSpec } from "../config";
83
- import type { KvScheme } from "../kv-scheme";
84
- import { BatchedRotatingCache } from "../model/batched-rotating";
85
- import { cloneKvCaches } from "../kv-store";
86
- import { SSMCache } from "../model/qwen3-delta";
87
- import type { RuntimeModel } from "../model/factory";
88
- import type { GrammarController } from "../grammar";
89
- import { toLogprobs } from "../sampler";
90
- import { acquireModelWiredLimit } from "../generate";
91
- import { batchRowKvBytes } from "./kv-budget";
92
- import type { PromptResponseTrace } from "./prompt-response-trace";
93
-
94
- /** Decode-pipeline kill switch (read once at load, like the serial loop's
95
- * MLX_BUN_COMPILED_DECODE): 1 ⇒ read each step's tokens synchronously. */
96
- const NO_PIPELINE = runtimeValue("MLX_BUN_BATCH_NO_PIPELINE") === "1";
97
-
98
- /** Per-step phase timing (MLX_BUN_BATCH_STEP_TRACE=1, debug-only): where a
99
- * decode step's wall time goes — graph BUILD+dispatch (host), the pipelined
100
- * READ of the previous step's tokens (GPU wait), row EMIT (onToken/SSE), and
101
- * the GAP between consecutive steps (drive-loop + everything else). Sums
102
- * print via `stepTraceReport()` (the b1 profile experiment calls it). */
103
- const STEP_TRACE = runtimeValue("MLX_BUN_BATCH_STEP_TRACE") === "1";
104
- const STEP_T = { t0: 0, lastEnd: 0, build: 0, read: 0, emit: 0, gap: 0, n: 0 };
105
- export function stepTraceReport(): string {
106
- const per = (x: number) => (STEP_T.n ? (x / STEP_T.n).toFixed(3) : "0");
107
- const s = `steps=${STEP_T.n} build=${per(STEP_T.build)}ms read=${per(STEP_T.read)}ms emit=${per(STEP_T.emit)}ms gap=${per(STEP_T.gap)}ms (per step)`;
108
- STEP_T.t0 = STEP_T.lastEnd = STEP_T.build = STEP_T.read = STEP_T.emit = STEP_T.gap = STEP_T.n = 0;
109
- return s;
110
- }
111
-
112
- // NOTE — oMLX-style "adaptive burst decode" (several steps per event-loop
113
- // yield, omlx/engine_core.py _step_burst) was ported here 2026-07-02 and
114
- // REFUTED by measurement: on this runtime it REGRESSED cpm5 B=4 aggregate
115
- // 345→289 tok/s, batch-lane B=1 149→121, and TTFT ~+100 ms (first-token SSE
116
- // flush waits out the burst budget). Their win exists because each Python
117
- // step hand-off ping-pongs the GIL with asyncio/uvicorn (~1 ms/token); Bun's
118
- // setImmediate hop costs microseconds, so bursting here only delays socket
119
- // flushes. Don't re-add without new evidence — the per-yield step below is
120
- // the measured optimum (docs/design/batching-perf-path.md P4 notes).
121
-
122
- /** A token sampler for one row: (logits [1,V], step) → token array [1] on
123
- * device. Greedy is `(l) => ops.argmaxAxis(l, -1)`; richer closures fold in
124
- * temperature / top-p / logits processors + history (built per request from
125
- * its sampler options, exactly like generate()'s sampleStep). Called once per
126
- * step 0..maxTokens-1 for this row (never beyond maxTokens). */
127
- export type RowSampler = (logits1V: MlxArray, step: number) => MlxArray;
128
-
129
- export interface BatchRequest {
130
- promptIds: number[];
131
- maxTokens: number;
132
- eosTokenIds: number[];
133
- sample: RowSampler;
134
- /** Called per emitted (non-EOS) token, in order. Returning `false` halts this
135
- * row (a decoded-text stop sequence fired) — matches generate()'s onToken
136
- * contract. EOS terminates the row WITHOUT an onToken call. Throwing evicts
137
- * THIS row only (its submit promise rejects; siblings continue). May be
138
- * async; keep it cheap — it runs inline in the step loop. */
139
- onToken: (token: number) => void | boolean | Promise<void | boolean>;
140
- /** Diagnostic-only request-local trace. */
141
- trace?: PromptResponseTrace;
142
- /** Closes the gateway's queue/admission span when this row leaves pending. */
143
- onAdmitted?: () => void;
144
- /** Client/request lifetime. An aborted pending row is removed before
145
- * admission; a row in prefill or decode is evicted at the next safe
146
- * scheduler boundary. */
147
- signal?: AbortSignal;
148
- /** The row's sampler is PLAIN GREEDY (temperature 0, no curve) with no
149
- * logits processors and no grammar — set by the gateway when true. Lets
150
- * the scheduler take the vectorized sampling fast path (ONE
151
- * log-softmax+argmax over [B,V] instead of B slice/sample/concat graphs)
152
- * when EVERY live row qualifies. Numerically identical per row
153
- * (row-independent ops, same per-row shapes); the per-row closure path
154
- * is the fallback and the MLX_BUN_BATCH_VEC_SAMPLE=0 kill switch. */
155
- plainGreedy?: boolean;
156
- /** Grammar controller for this row (B1: per-row matchers). When set, the
157
- * scheduler drives it: `accept()` after each emitted token (fires the async
158
- * bitmask fill), `await ready()` before the row's next sample (the sample
159
- * closure applies `applyMask`). Termination (grammar satisfied) is an
160
- * additional per-row stop source. The gateway OWNS disposal (finally around
161
- * submit()); the scheduler uses but never owns. Null on the degrade path. */
162
- grammar?: GrammarController;
163
- /** Stable cache boundary from the server's template probe (the serial
164
- * lane's options.snapshotAt): the prompt prefix the NEXT turn's render
165
- * preserves. The scheduler snapshots a trim-free prompt-cache entry at
166
- * min(this, promptIds.length-1) during the solo prefill — the oracle
167
- * invariant (mlx-lm insert_segments) that gives wrapped-ring models a
168
- * reuse path. Absent = still capped at promptIds.length-1. */
169
- snapshotAt?: number;
170
- }
171
-
172
- export interface BatchStats {
173
- promptTokens: number;
174
- generatedTokens: number;
175
- /** Prompt tokens served from the prompt cache (Phase 3.2): a joiner's solo
176
- * prefill starts from the longest usable cached prefix, exactly like the
177
- * serial lane's runGeneration take(). 0 on a cold prefill. */
178
- cachedTokens: number;
179
- finishReason: "stop" | "length";
180
- }
181
-
182
- /** The slice of PromptCache the scheduler drives (structural — the server's
183
- * PromptCache satisfies it). take() on admission (any joiner: a restored
184
- * prefix + suffix prefill is byte-safe whether the row later merges or
185
- * not). put() paths: rows that finish NEVER-MERGED (adopted lone rows,
186
- * Phase 3.2) hand their pristine serial caches over zero-copy; rows that
187
- * finish INSIDE a multi-row batch get their KV EXTRACTED per row into
188
- * fresh serial caches first (mlx-lm server.py:872 extract_cache — the
189
- * cross-request reuse concurrent agents live on). */
190
- export interface RowPromptCache {
191
- take(prompt: number[], ns?: string): {
192
- tokens: number[]; caches: Cache[]; retain?: () => void;
193
- } | null;
194
- put(tokens: number[], caches: Cache[], ns?: string, retain?: () => void): void;
195
- }
196
-
197
- interface Row {
198
- req: BatchRequest;
199
- resolve: (s: BatchStats) => void;
200
- reject: (e: unknown) => void;
201
- current: number; // last emitted token, fed at the next step (pipeline cold)
202
- generated: number; // tokens emitted so far (incl. a terminating EOS)
203
- sampled: number; // sample() calls so far (leads `generated` by 1 in-pipeline)
204
- promptTokens: number;
205
- /** Prompt tokens restored from the prompt cache at admission (stats). */
206
- cachedTokens: number;
207
- /** Generated tokens whose KV actually entered the cache (fed as a step
208
- * input) — serial generate()'s `forwarded` list. promptIds+fed is the
209
- * exact token coverage of the row's caches, the put() entry key.
210
- * PER-ROW EXACT for every row: pushes are gated on the pending
211
- * register's per-slot real/placeholder flags (#pendingReal), so a
212
- * doomed slot's placeholder is never recorded — and if one ever DID
213
- * feed a forward, `fedTainted` flips instead (see #step's read). */
214
- fed: number[];
215
- /** A placeholder slot value fed this row's KV (junk token in the cache):
216
- * the coverage key is no longer derivable — extraction refuses.
217
- * Structurally unreachable today (a doomed row always evicts at the
218
- * emit that follows its placeholder, filtering the slot before the
219
- * next forward; cold steps never contain doomed rows because a flush
220
- * emits every in-flight token) — this flag makes `fed` provably exact
221
- * instead of resting on that timing chain. */
222
- fedTainted: boolean;
223
- /** Ever merged into a multi-row batch (or poisoned by a reject): its KV
224
- * is interleaved in batched buffers — a finish put() must EXTRACT the
225
- * row (#extractRowCaches) rather than adopt the inners. */
226
- merged: boolean;
227
- }
228
-
229
- /** A joiner mid-prefill: `pos` prompt tokens are already in `solo`. Advanced
230
- * one chunk per loop iteration, interleaved with batch decode steps. */
231
- interface PrefillState {
232
- row: Row;
233
- solo: Cache[];
234
- pos: number;
235
- /** Release hook carried by an SSD-restored prompt-cache entry (must run
236
- * only after `solo`/the adopted caches are disposed — see PromptCacheEntry). */
237
- retain?: () => void;
238
- /** Pending boundary-snapshot position: #prefillChunk splits its chunking
239
- * exactly here, clones + put()s the trim-free prefix entry, then nulls
240
- * it. null = no snapshot for this row (short prompt / cached past it). */
241
- snapAt: number | null;
242
- closePrefill?: () => void;
243
- }
244
-
245
- /** Held while the batch is active; the serial fallback acquires the same lock. */
246
- export interface ExclusiveLock {
247
- acquire(): Promise<() => void>;
248
- }
249
-
250
- export interface BatchSchedulerOptions {
251
- /** Max rows in the running batch (mlx-lm `--decode-concurrency`). */
252
- maxBatch: number;
253
- lock?: ExclusiveLock;
254
- /** Drain signal: while true, no NEW rows are admitted (running rows finish,
255
- * the lock is released) so a waiting serial-lane request can run — mlx-lm's
256
- * drain_batch. Pair with kick() when it flips back to false. */
257
- admissionHeld?: () => boolean;
258
- /** Joiner prefill chunk length (default 2048, the serial loop's constant). */
259
- prefillChunkSize?: number;
260
- /** Aggregate KV-byte budget across all running rows (batching-perf-path
261
- * P3 admission, via `--kv-budget`). A joiner whose PROJECTED KV
262
- * (kvBytesAt(config, prompt + maxTokens), sliding window already capped)
263
- * would push the batch's projected total over this ceiling WAITS in the
264
- * queue (FIFO, head-of-line — no starvation, no reorder) until rows
265
- * evict. A request that can NEVER fit (over budget alone, empty batch)
266
- * is rejected instead of deadlocking. Unset = unlimited (v1 behavior). */
267
- kvBudgetBytes?: number;
268
- /** Authoritative server-wide scheme. Each joiner's solo prefill converts at
269
- * the same chunk boundaries as the serial path, so a row's quantized bytes
270
- * preserve the L2 composition. Unsupported schemes fail at construction. */
271
- kvScheme?: KvScheme;
272
- /** Prompt-cache hook (Phase 3.2): admission take()s the longest usable
273
- * prefix into the joiner's solo caches (suffix-only prefill — the
274
- * multi-turn chat TTFT path); rows that finish never-merged put() their
275
- * caches back. Adapter requests never reach the batch lane, so the
276
- * namespace is always "" here. Runs under the gateway mutex domain, so
277
- * take/put never race the serial lane's use of the same cache. */
278
- promptCache?: RowPromptCache;
279
- }
280
-
281
- type LayerInner =
282
- | KVCache
283
- | QuantizedKVCache
284
- | BatchedRotatingCache
285
- | BatchedRotatingQuantCache // rot layer under a kv_config scheme (milestone 2)
286
- | RotatingKVCache // adopted lone-row state only (see #mergeJoiner adopt)
287
- | RotatingQuantizedKVCache // adopted lone-row state, quantized rot layer
288
- | SSMCache
289
- | BatchableCache;
290
- type Row1 = { keys: MlxArray; values: MlxArray };
291
-
292
- export class BatchScheduler {
293
- #running: Row[] = [];
294
- #inners: LayerInner[] | null = null; // per-layer batched KV; null when empty
295
- #fullLeftPad: number[] = []; // per-row padding for FULL layers (rot self-tracks)
296
- #pending: Row[] = [];
297
- #prefill: PrefillState | null = null; // the (single) joiner mid-prefill
298
- /** Sampled-but-unread token array [B], aligned with #running — the decode
299
- * pipeline register. Filtered/disposed alongside the batched KV. */
300
- #pendingToks: MlxArray | null = null;
301
- /** Per-slot flags for #pendingToks: true = a REAL sampled token of that
302
- * row's stream; false = a doomed/terminated row's placeholder (fed-token
303
- * accounting must skip it — see Row.fed/fedTainted). Filtered/cleared in
304
- * lockstep with the register. */
305
- #pendingReal: boolean[] | null = null;
306
- #steps = 0; // decode-step counter (clearCache cadence)
307
- #lastHop = 0; // last macrotask yield (B=1 fast-path rate limiting)
308
- #looping = false;
309
- #wake: (() => void) | null = null;
310
- readonly #maxBatch: number;
311
- readonly #lock: ExclusiveLock | undefined;
312
- readonly #admissionHeld: (() => boolean) | undefined;
313
- readonly #prefillChunkSize: number;
314
- readonly #kvBudgetBytes: number | undefined;
315
- readonly #promptCache: RowPromptCache | undefined;
316
- /** Retain hook of the currently-ADOPTED row's cache entry (at most one:
317
- * only a lone adopted row holds un-copied entry caches). Runs after the
318
- * adopted caches are disposed, or transfers back on put(). */
319
- #adoptedRetain: (() => void) | null = null;
320
- readonly #kinds: ("full" | "rot" | "ssm" | "owned-batch")[];
321
- readonly #rotMaxSize: number[]; // per-layer sliding window (rot layers only)
322
- readonly #compressedProjectors: Array<(tokens: number) => number> | null;
323
- readonly #batchCacheMaxTokens: number | null;
324
- readonly #kvScheme: KvScheme | undefined;
325
- /** layerIdx → mixed-precision spec (Phase 3.1); null = bf16 batch (v1). */
326
- readonly #kvByLayer: Map<number, KvQuantSpec> | null;
327
- /** Compiled decode runner for the B=1 serial-class case (Phase 3.2) —
328
- * same eligibility gate as generate.ts (gemma dense, kill switch
329
- * MLX_BUN_COMPILED_DECODE); adapters never reach the batch lane. Set
330
- * to null permanently on a failed step (serial disables per
331
- * generation; the scheduler is one long-lived "generation"). */
332
- #compiled: CompiledDecode | null;
333
-
334
- constructor(private readonly model: RuntimeModel, opts: BatchSchedulerOptions) {
335
- this.#maxBatch = Math.max(1, Math.floor(opts.maxBatch));
336
- this.#lock = opts.lock;
337
- this.#admissionHeld = opts.admissionHeld;
338
- this.#prefillChunkSize = Math.max(1, Math.floor(opts.prefillChunkSize ?? 2048));
339
- this.#kvBudgetBytes = opts.kvBudgetBytes;
340
- this.#promptCache = opts.promptCache;
341
- this.#kvScheme = opts.kvScheme;
342
- const proto = model.makeCache(); // fresh caches hold no buffers
343
- if (this.#kvScheme && !this.#kvScheme.batchable(
344
- model.config,
345
- (layerIdx) =>
346
- isPlainKvCache(proto[layerIdx]) || isRotatingPlainCache(proto[layerIdx]),
347
- )) {
348
- for (const cache of proto) cache.dispose();
349
- throw new Error(`unsupported KV scheme for batch scheduler: ${this.#kvScheme.kind}`);
350
- }
351
- this.#kinds = proto.map((c) =>
352
- isBatchableCache(c)
353
- ? "owned-batch"
354
- : isRotatingPlainCache(c)
355
- ? "rot"
356
- : cacheSignature(c) === "ssm"
357
- ? "ssm"
358
- : "full",
359
- );
360
- this.#compressedProjectors = proto.every(isBatchableCache)
361
- ? proto.map((cache) => (tokens: number) => cache.projectedBytes(tokens))
362
- : null;
363
- this.#batchCacheMaxTokens = proto.every(isBatchableCache)
364
- ? Math.min(...proto.map((cache) => cache.maxTokens ?? Number.MAX_SAFE_INTEGER))
365
- : null;
366
- const kvConfig = this.#kvScheme?.options.kvConfig;
367
- this.#kvByLayer = kvConfig?.length
368
- ? new Map(kvConfig.map((entry) => [entry.layerIdx, entry]))
369
- : null;
370
- this.#rotMaxSize = proto.map((c) => (isRotatingPlainCache(c) ? c.maxSize : 0));
371
- for (const c of proto) c.dispose();
372
- this.#compiled =
373
- flagOn("MLX_BUN_COMPILED_DECODE", true) &&
374
- model.config.modelType.startsWith("gemma4") &&
375
- !model.config.text.enableMoeBlock
376
- ? CompiledDecode.for(model as Gemma4Model)
377
- : null;
378
- }
379
-
380
- get activeRows(): number {
381
- return this.#running.length;
382
- }
383
-
384
- get pendingRows(): number {
385
- return this.#pending.length + (this.#prefill ? 1 : 0);
386
- }
387
-
388
- /** Projected KV bytes of one row at its worst case (full prompt + full
389
- * completion; the sliding-window term is window-capped by kvBytesAt). */
390
- #rowKvBytes(row: Row): number {
391
- return this.#compressedProjectors
392
- ? this.#compressedProjectors.reduce(
393
- (sum, project) => sum + project(row.promptTokens + row.req.maxTokens),
394
- 0,
395
- )
396
- : batchRowKvBytes(
397
- this.model.config,
398
- row.promptTokens,
399
- row.req.maxTokens,
400
- this.#kvScheme,
401
- );
402
- }
403
-
404
- async #forwardHidden(ids: MlxArray, cache: Cache[]): Promise<MlxArray> {
405
- const asyncModel = this.model as RuntimeModel & {
406
- forwardHiddenAsync?: (ids: MlxArray, cache: Cache[]) => Promise<MlxArray>;
407
- };
408
- return typeof asyncModel.forwardHiddenAsync === "function"
409
- ? await asyncModel.forwardHiddenAsync(ids, cache)
410
- : this.model.forwardHidden(ids, cache);
411
- }
412
-
413
- /** Projected aggregate KV of everything admitted (running + mid-prefill). */
414
- get projectedKvBytes(): number {
415
- let total = this.#running.reduce((a, r) => a + this.#rowKvBytes(r), 0);
416
- if (this.#prefill) total += this.#rowKvBytes(this.#prefill.row);
417
- return total;
418
- }
419
-
420
- get kvBudgetBytes(): number | undefined {
421
- return this.#kvBudgetBytes;
422
- }
423
-
424
- /** KV-budget admission for the queue head. True = admit now. A candidate
425
- * that cannot fit even alone is rejected here (never deadlocks the
426
- * queue); one that fits alone but not alongside the current batch waits. */
427
- #kvAdmits(candidate: Row): boolean {
428
- const requestedTokens = candidate.promptTokens + candidate.req.maxTokens;
429
- if (
430
- this.#batchCacheMaxTokens !== null &&
431
- requestedTokens > this.#batchCacheMaxTokens
432
- ) {
433
- this.#pending.shift();
434
- candidate.reject(new RangeError(
435
- `context limit: prompt ${candidate.promptTokens} + max_tokens ` +
436
- `${candidate.req.maxTokens} exceeds ${this.#batchCacheMaxTokens}`,
437
- ));
438
- return false;
439
- }
440
- if (this.#kvBudgetBytes === undefined) return true;
441
- const need = this.#rowKvBytes(candidate);
442
- if (need > this.#kvBudgetBytes && this.#running.length === 0 && !this.#prefill) {
443
- this.#pending.shift();
444
- candidate.reject(
445
- new Error(
446
- `kv budget: request needs ~${(need / 1e9).toFixed(2)} GB KV ` +
447
- `(prompt ${candidate.promptTokens} + max_tokens ${candidate.req.maxTokens}), ` +
448
- `over --kv-budget ${(this.#kvBudgetBytes / 1e9).toFixed(2)} GB — ` +
449
- `lower max_tokens or raise the budget`,
450
- ),
451
- );
452
- return false;
453
- }
454
- return this.projectedKvBytes + need <= this.#kvBudgetBytes;
455
- }
456
-
457
- /** Submit a request; resolves when its row finishes (EOS, stop, or length). */
458
- submit(req: BatchRequest): Promise<BatchStats> {
459
- if (req.signal?.aborted) return Promise.reject(req.signal.reason);
460
- return new Promise<BatchStats>((resolve, reject) => {
461
- let abortListener: (() => void) | null = null;
462
- const cleanup = () => {
463
- if (abortListener) req.signal?.removeEventListener("abort", abortListener);
464
- abortListener = null;
465
- };
466
- this.#pending.push({
467
- req,
468
- resolve: (stats) => { cleanup(); resolve(stats); },
469
- reject: (error) => { cleanup(); reject(error); },
470
- current: 0, generated: 0, sampled: 0, promptTokens: req.promptIds.length,
471
- cachedTokens: 0, fed: [], fedTainted: false, merged: false,
472
- });
473
- if (req.signal) {
474
- abortListener = () => this.kick();
475
- req.signal.addEventListener("abort", abortListener, { once: true });
476
- }
477
- this.#ensureLoop();
478
- });
479
- }
480
-
481
- /** Wake the driver loop (e.g. after admissionHeld flips back to false). */
482
- kick(): void {
483
- this.#ensureLoop();
484
- }
485
-
486
- #ensureLoop(): void {
487
- if (this.#wake) { this.#wake(); return; }
488
- if (this.#looping) return;
489
- this.#looping = true;
490
- void this.#drive();
491
- }
492
-
493
- async #drive(): Promise<void> {
494
- let release: (() => void) | null = null;
495
- let releaseWired: (() => void) | null = null;
496
- try {
497
- while (true) {
498
- for (let i = this.#pending.length - 1; i >= 0; i--) {
499
- const row = this.#pending[i]!;
500
- if (!row.req.signal?.aborted) continue;
501
- this.#pending.splice(i, 1);
502
- row.reject(row.req.signal.reason);
503
- }
504
- const held = this.#admissionHeld?.() === true;
505
- // Work = running rows, an in-flight prefill (finish it even under
506
- // drain — the row is already half-admitted), or admissible pending.
507
- const hasWork =
508
- this.#running.length > 0 ||
509
- this.#prefill !== null ||
510
- (!held && this.#pending.length > 0);
511
- if (!hasWork) {
512
- if (release) { release(); release = null; } // let the serial lane in
513
- if (releaseWired) { releaseWired(); releaseWired = null; }
514
- await new Promise<void>((r) => { this.#wake = r; });
515
- this.#wake = null;
516
- continue;
517
- }
518
- releaseWired ??= acquireModelWiredLimit(this.model);
519
- if (!release && this.#lock) release = await this.#lock.acquire();
520
-
521
- // Start at most one joiner prefill (drain-aware); it advances one
522
- // chunk per iteration, interleaved with the decode step below.
523
- if (!this.#prefill && !held &&
524
- this.#pending.length > 0 && this.#running.length < this.#maxBatch &&
525
- this.#kvAdmits(this.#pending[0]!)) {
526
- const row = this.#pending.shift()!;
527
- row.req.onAdmitted?.();
528
- // Prompt-cache reuse (Phase 3.2): start the solo prefill from the
529
- // longest usable cached prefix — the serial lane's take(), same
530
- // semantics (entry ownership transfers; put back or dispose).
531
- const closeCache = row.req.trace?.begin("cache.lookup_restore", {
532
- mechanism: "continuous",
533
- });
534
- const hit = this.#promptCache?.take(row.req.promptIds) ?? null;
535
- closeCache?.();
536
- if (hit) row.cachedTokens = hit.tokens.length;
537
- // Boundary snapshot (the serial lane's A1, ported): a trim-free
538
- // strict-prefix entry at min(stableLen, len-1) for every
539
- // substantial cold prefill — same gates as runGeneration
540
- // (>= 256 tokens, extends past the cached prefix). Without this
541
- // the batch lane's ONLY entry was the untrimmable [prompt+gen]
542
- // finish-time put — a total ctx-repeat miss once rings wrap
543
- // (12B batched 84 s vs serial 0.4 s, 2026-07-06).
544
- const len = row.req.promptIds.length;
545
- const boundary = Math.min(row.req.snapshotAt ?? len, len - 1);
546
- const snapAt =
547
- this.#promptCache && boundary >= 256 && boundary > (hit?.tokens.length ?? 0)
548
- ? boundary
549
- : null;
550
- const closePrefill = row.req.trace?.begin("prefill.total", {
551
- mechanism: "continuous",
552
- promptTokens: row.promptTokens,
553
- cachedTokens: row.cachedTokens,
554
- });
555
- const closeBatchSetup = row.req.trace?.begin("prefill.batch_setup", {
556
- mechanism: "continuous",
557
- });
558
- this.#prefill = hit
559
- ? {
560
- row, solo: hit.caches, pos: hit.tokens.length,
561
- retain: hit.retain, snapAt, closePrefill,
562
- }
563
- : { row, solo: this.model.makeCache(), pos: 0, snapAt, closePrefill };
564
- closeBatchSetup?.();
565
- }
566
-
567
- if (this.#prefill) {
568
- const p = this.#prefill;
569
- try {
570
- if (await this.#prefillChunk(p)) {
571
- p.closePrefill?.();
572
- this.#prefill = null;
573
- }
574
- } catch (e) {
575
- // Prefill/admission failure is per-row: reject the joiner, keep
576
- // the running batch.
577
- this.#prefill = null;
578
- p.closePrefill?.();
579
- for (const c of p.solo) c.dispose();
580
- p.retain?.();
581
- p.row.reject(e);
582
- }
583
- }
584
-
585
- // Burst admission: when a joiner just COMPLETED and more batchable
586
- // requests are queued, admit them before the next decode step (the
587
- // admit-all-then-step grouping the mlx-lm goldens gate). Long prompts
588
- // still interleave: a mid-prefill joiner leaves #prefill non-null, so
589
- // the loop falls through to run a decode step between chunks.
590
- if (this.#prefill === null && !held &&
591
- this.#pending.length > 0 && this.#running.length < this.#maxBatch &&
592
- (this.#kvBudgetBytes === undefined ||
593
- this.projectedKvBytes + this.#rowKvBytes(this.#pending[0]!) <= this.#kvBudgetBytes))
594
- continue;
595
-
596
- if (this.#running.length > 0) {
597
- try {
598
- await this.#step();
599
- } catch (e) {
600
- // A forward/sampling error can't be attributed to one row — drop
601
- // the batch. (A row's onToken error is contained in #emitRows.)
602
- // dropOnly: mid-step caches may be inconsistent — never put().
603
- for (const row of this.#running) row.reject(e);
604
- this.#applyFilter([], true);
605
- }
606
- }
607
- // The per-step macrotask hop exists so joiners/admin interleave at
608
- // token granularity. With ONE row and an empty queue there is
609
- // nobody to interleave — rate-limit the hop to the serial loop's
610
- // 25 ms cadence (identical responsiveness budget; generate()'s
611
- // hoppingOnToken does exactly this). This was the B=1 drive-loop
612
- // tax: one setImmediate round-trip per token ≈ the 0.4–0.8% gap
613
- // vs pure serial (paired-measured 2026-07-05).
614
- if (
615
- this.#running.length === 1 && this.#pending.length === 0 &&
616
- !this.#prefill && this.#admissionHeld?.() !== true &&
617
- performance.now() - this.#lastHop < 25
618
- )
619
- continue;
620
- this.#lastHop = performance.now();
621
- await new Promise<void>((r) => setImmediate(r));
622
- }
623
- } finally {
624
- if (release) release();
625
- releaseWired?.();
626
- this.#looping = false;
627
- }
628
- }
629
-
630
- /** Per-layer mixed-precision conversion of a joiner's SOLO caches — the
631
- * scheduler-side mirror of the serial maybeQuantizeKv (generate.ts): same
632
- * per-layer map, same skip rules (empty cache, already quantized), same
633
- * streaming discipline (evalAll the converted layer's state so the bf16
634
- * source frees before the next layer converts). Called at every prefill
635
- * chunk boundary AND once before merge, exactly where the serial loop
636
- * calls maybeQuantizeKv — that placement is what makes a row's quantized
637
- * bytes bit-exact vs serial `--kv-quant config`. Gateway placement and the
638
- * constructor both guarantee every named cache can convert. */
639
- #quantizeSolo(solo: Cache[], trace?: PromptResponseTrace): void {
640
- if (!this.#kvByLayer) return;
641
- const close = trace?.begin("prefill.kv_maintenance", {
642
- mechanism: "continuous",
643
- });
644
- try {
645
- for (let i = 0; i < solo.length; i++) {
646
- const e = this.#kvByLayer.get(i);
647
- const c = solo[i]!;
648
- if (!e || c.offset === 0) continue;
649
- // Milestone 2: rotating layers convert too — RotatingKVCache.
650
- // toQuantized → RotatingQuantizedKVCache, exactly maybeQuantizeKv's
651
- // dispatch (generate.ts). Converted caches don't match either class
652
- // (the four cache classes are siblings), so re-conversion never fires.
653
- if (!(isPlainKvCache(c) || isRotatingPlainCache(c))) continue;
654
- const q = c.toQuantized(e.groupSize, e.bits);
655
- solo[i] = q;
656
- ops.evalAll(q.state());
657
- clearCache();
658
- }
659
- } finally {
660
- close?.();
661
- }
662
- }
663
-
664
- /** Advance a joiner's solo prefill by one chunk. Non-final chunks forward +
665
- * eval the cache and return false (the caller interleaves a decode step).
666
- * The final chunk samples token 0, emits it, and — if the row survives —
667
- * merges it into the running batch; returns true (admission complete). */
668
- async #prefillChunk(p: PrefillState): Promise<boolean> {
669
- p.row.req.signal?.throwIfAborted();
670
- const prompt = p.row.req.promptIds;
671
- const chunkStart = p.pos;
672
- const closeChunk = p.row.req.trace?.begin("prefill.chunk", {
673
- mechanism: "continuous",
674
- startToken: chunkStart,
675
- });
676
- try {
677
- // Boundary-snapshot chunking (mirrors generate.ts's snapshotAt split):
678
- // chunk edges land EXACTLY on snapAt so the clone is taken while the
679
- // caches hold precisely that prefix — slicing tokens against caches at
680
- // any other offset would store corrupt entries. The extra split only
681
- // changes chunk shapes for opted-in rows: the same numerics class as a
682
- // prompt-cache-hit continuation prefill (GEMV/GEMM reduction order).
683
- if (p.snapAt !== null && p.pos < p.snapAt) {
684
- const end = Math.min(p.pos + this.#prefillChunkSize, p.snapAt);
685
- const chunk = prompt.slice(p.pos, end);
686
- const ids = ops.fromInt32(chunk, [1, chunk.length]);
687
- const h = await this.#forwardHidden(ids, p.solo);
688
- ids.dispose();
689
- h.dispose();
690
- ops.evalAll(p.solo.flatMap((c) => c.state()));
691
- this.#quantizeSolo(p.solo, p.row.req.trace); // serial converts at every chunk boundary
692
- clearCache();
693
- p.pos = end;
694
- if (p.pos === p.snapAt) {
695
- try {
696
- this.#promptCache!.put(prompt.slice(0, p.snapAt), cloneKvCaches(p.solo));
697
- } catch (e) {
698
- console.warn(`batch-lane boundary snapshot skipped: ${(e as Error).message}`);
699
- }
700
- p.snapAt = null;
701
- }
702
- return false;
703
- }
704
- // Oracle prefill convention (see generate.ts prefill): drain only to
705
- // len-1 in chunks, then step-0 logits from an L=1 forward of the LAST
706
- // prompt token — mirrors mlx-lm's batched engine (insert_segments'
707
- // forced final 1-token segment + GenerationBatch._step,
708
- // generate.py:1645/1327) and keeps this lane bit-exact with the serial
709
- // lane (unified == --batch 1 == oracle). The L=1 step runs on the solo
710
- // caches BEFORE merge, so per-row serial equivalence is by construction.
711
- // MLX_BUN_PREFILL_TAIL_SPLIT=0 restores the old convention.
712
- const tailSplit = flagOn("MLX_BUN_PREFILL_TAIL_SPLIT", true);
713
- if ((tailSplit ? prompt.length - p.pos - 1 : prompt.length - p.pos) >
714
- this.#prefillChunkSize) {
715
- const chunk = prompt.slice(p.pos, p.pos + this.#prefillChunkSize);
716
- const ids = ops.fromInt32(chunk, [1, chunk.length]);
717
- const h = await this.#forwardHidden(ids, p.solo);
718
- ids.dispose();
719
- h.dispose(); // logits never computed for non-final chunks
720
- ops.evalAll(p.solo.flatMap((c) => c.state()));
721
- this.#quantizeSolo(p.solo, p.row.req.trace); // serial converts at every chunk boundary
722
- clearCache(); // serial prefill's per-chunk clear (generate.ts)
723
- p.pos += this.#prefillChunkSize;
724
- return false;
725
- }
726
-
727
- // Tail split: forward the remaining head [p.pos, len-1) as a plain chunk
728
- // (serial chunk-boundary semantics: eval + quantize + clear), leaving
729
- // exactly one token for the L=1 step-0 forward below. Head + step share
730
- // this tick — same chunk-tick count as before, plus one L=1 dispatch
731
- // (~a single decode step) in the admission tick's no-yield window.
732
- if (tailSplit && p.pos < prompt.length - 1) {
733
- const head = prompt.slice(p.pos, prompt.length - 1);
734
- const ids = ops.fromInt32(head, [1, head.length]);
735
- const h = await this.#forwardHidden(ids, p.solo);
736
- ids.dispose();
737
- h.dispose();
738
- ops.evalAll(p.solo.flatMap((c) => c.state()));
739
- this.#quantizeSolo(p.solo, p.row.req.trace);
740
- clearCache();
741
- p.pos = prompt.length - 1;
742
- }
743
-
744
- // Final chunk: slice the LAST hidden position BEFORE the lm_head — running
745
- // logitsFromHidden on the whole [1,Lp,H] hidden materializes a [1,Lp,V]
746
- // transient (~4.3 GB bf16 at Gemma V=262k, 8k prompt). Same reorder as the
747
- // serial path (generate.ts prefill). Under tailSplit this chunk is exactly
748
- // the last prompt token (L=1).
749
- const chunk = prompt.slice(p.pos);
750
- const ids = ops.fromInt32(chunk, [1, chunk.length]);
751
- const h = await this.#forwardHidden(ids, p.solo);
752
- ids.dispose();
753
- closeChunk?.();
754
- p.closePrefill?.();
755
- p.closePrefill = undefined;
756
- const closeTokenZero = p.row.req.trace?.begin("token_zero.total", {
757
- mechanism: "continuous",
758
- });
759
- // MLX is lazy: synchronize(stream) only waits for already-submitted work;
760
- // it does not submit `h`'s graph. Attribution mode must evaluate the final
761
- // L=1 forward explicitly or its work is silently charged to the head/read.
762
- const forceAttribution = !!p.row.req.trace &&
763
- runtimeValue("MLX_BUN_P2R_SYNC") === "1";
764
- if (forceAttribution) {
765
- const closeForward = p.row.req.trace!.begin("token_zero.forward", {
766
- mechanism: "continuous",
767
- activeBytes: activeMemory(),
768
- cacheBytes: cacheMemory(),
769
- peakBytes: peakMemory(),
770
- });
771
- ops.evalAll([h, ...p.solo.flatMap((c) => c.state())]);
772
- closeForward();
773
- }
774
- const [, Lc, H] = h.shape as [number, number, number];
775
- const hLast = h.slice([0, Lc - 1, 0], [1, Lc, H]);
776
- h.dispose();
777
- const lg = this.model.logitsFromHidden(hLast); // [1,1,V]
778
- hLast.dispose();
779
- if (forceAttribution) {
780
- const closeHead = p.row.req.trace!.begin("token_zero.head", {
781
- mechanism: "continuous",
782
- activeBytes: activeMemory(),
783
- cacheBytes: cacheMemory(),
784
- peakBytes: peakMemory(),
785
- });
786
- ops.evalAll([lg]);
787
- closeHead();
788
- }
789
- const V = lg.shape[2]!;
790
- const last2 = ops.reshape(lg, [1, V]);
791
- lg.dispose();
792
- const closeSample = forceAttribution
793
- ? p.row.req.trace!.begin("token_zero.sample", { mechanism: "continuous" })
794
- : undefined;
795
- const tok = this.#readToken(p.row.req.sample(last2, 0));
796
- closeSample?.();
797
- closeTokenZero?.();
798
- last2.dispose();
799
- p.row.sampled = 1;
800
- p.row.generated = 1;
801
- clearCache(); // drop the prefill transients (serial's token-0 clear)
802
-
803
- // Grammar (B1): the mask0 was applied inside the sample closure (the
804
- // controller is primed at compile). After reading token 0, advance the
805
- // matcher — fires the async fill for the NEXT step, overlapping the merge
806
- // below. If the grammar is already satisfied at token 0 (a 1-token grammar,
807
- // e.g. guided_choice landing on a single-token option), emit + finish("stop")
808
- // WITHOUT merging into the batch — the row never joins #running.
809
- if (p.row.req.grammar) {
810
- p.row.req.grammar.accept(tok);
811
- if (p.row.req.grammar.isTerminated) {
812
- const stop = await this.#emit(p.row, tok);
813
- // Token 0 was sampled but never fed — the caches cover exactly the
814
- // prompt, a clean prompt-only entry (put-or-dispose).
815
- this.#putOrDispose(p.solo, p.row.req.promptIds, p.retain);
816
- this.#finish(p.row, stop === "continue" ? "stop" : stop);
817
- return true;
818
- }
819
- }
820
-
821
- const stop = await this.#emit(p.row, tok);
822
- if (stop !== "continue") {
823
- this.#putOrDispose(p.solo, p.row.req.promptIds, p.retain);
824
- this.#finish(p.row, stop);
825
- return true;
826
- }
827
- // Default tail-split path: the caches were already converted at the head
828
- // boundary above (oracle composition: prefill ids[:-1] → convert → L=1
829
- // step-0), so this call is an idempotent no-op (converted caches match
830
- // neither serial class). It is LOAD-BEARING only under
831
- // MLX_BUN_PREFILL_TAIL_SPLIT=0 — the old serial order: token 0 sampled
832
- // from the unconverted final-chunk logits, THEN the caches convert
833
- // (before decode step 1 == before the merge).
834
- this.#quantizeSolo(p.solo, p.row.req.trace);
835
- await this.#mergeJoiner(p);
836
- return true;
837
- } finally {
838
- closeChunk?.();
839
- }
840
- }
841
-
842
- /** Merge a fully-prefilled joiner with the running batch, layer by layer
843
- * (re-merge; `extend` is the later refinement). Flushes the decode pipeline
844
- * first so the row set is settled and the next step starts cold. */
845
- async #mergeJoiner(p: PrefillState): Promise<void> {
846
- await this.#flushPipeline();
847
-
848
- // ADOPT, don't copy (unified-engine plan Phase 3.2): a row joining an
849
- // EMPTY batch keeps its solo caches as the batch inners — a pointer
850
- // handoff, zero bytes moved (the old path ran the full merge machinery
851
- // to produce a byte-identical [1,...] copy). The copy now happens only
852
- // when a SECOND row joins and a genuinely new layout must exist. The
853
- // prize beyond the saved copy: the lone row's caches stay SERIAL-CLASS
854
- // (KVCache / RotatingKVCache / QuantizedKVCache), so the B=1 step is
855
- // literally the serial graph, and compiled decode + prompt-cache
856
- // take/put become possible for it. The rot branch below knows how to
857
- // treat an adopted RotatingKVCache as the merge's first row.
858
- if (!this.#inners) {
859
- this.#inners = p.solo as LayerInner[];
860
- this.#fullLeftPad = [0];
861
- this.#adoptedRetain = p.retain ?? null;
862
- this.#running.push(p.row);
863
- return;
864
- }
865
-
866
- const prev = this.#inners;
867
- const prevPad = this.#fullLeftPad;
868
- const B = this.#running.length;
869
- const newInners: LayerInner[] = [];
870
- let newFullPad = this.#fullLeftPad;
871
- for (let layer = 0; layer < this.#kinds.length; layer++) {
872
- if (this.#kinds[layer] === "owned-batch") {
873
- const solo = p.solo[layer]!;
874
- if (!isBatchableCache(solo))
875
- throw new Error(`batch-capable layer ${layer} lost its cache capability`);
876
- const merged = solo.makeEmptyBatch();
877
- const previous = prev?.[layer];
878
- merged.mergeRows(previous ? [previous, solo] : [solo]);
879
- newFullPad = [...merged.leftPad];
880
- newInners.push(merged);
881
- continue;
882
- }
883
- if (this.#kinds[layer] === "ssm") {
884
- // No temporal axis, no left-pad: B-axis concat of the state slots.
885
- // mergeRows steals the solo arrays when the batch starts cold, so the
886
- // unconditional p.solo dispose below stays safe either way.
887
- newInners.push(SSMCache.mergeRows(
888
- (prev?.[layer] as SSMCache | undefined) ?? null,
889
- p.solo[layer] as SSMCache,
890
- ));
891
- continue;
892
- }
893
- if (isQuantizedKvCache(p.solo[layer])) {
894
- // Phase 3.1 — quantized full layer: same merge/extend shapes as the
895
- // bf16 branch below, over (packed, scales, biases) triples. The solo
896
- // row was converted by #quantizeSolo with the serial ops, so its
897
- // bytes already bit-match serial `--kv-quant config`; this branch
898
- // only re-arranges rows along the batch axis.
899
- const qSolo = p.solo[layer] as QuantizedKVCache;
900
- const [qk, qv] = qSolo.temporalView();
901
- const qRow = { keys: qk, values: qv };
902
- const dispose3 = (t: { packed: MlxArray; scales: MlxArray; biases: MlxArray }) => {
903
- t.packed.dispose(); t.scales.dispose(); t.biases.dispose();
904
- };
905
- const prevQ = prev?.[layer] as QuantizedKVCache | undefined;
906
- if (prevQ && runtimeValue("MLX_BUN_BATCH_EXTEND") !== "0") {
907
- const [k0, v0] = prevQ.temporalView();
908
- const ext = extendQuantRows(k0, v0, prevPad, qRow);
909
- dispose3(k0); dispose3(v0);
910
- newFullPad = ext.leftPad;
911
- const c = new QuantizedKVCache(qSolo.groupSize, qSolo.bits);
912
- c.restoreState(ext.keys, ext.values, ext.width);
913
- newInners.push(c);
914
- } else {
915
- const rows: { keys: typeof qk; values: typeof qv }[] = [];
916
- if (prevQ) {
917
- const [k0, v0] = prevQ.temporalView(); // [B,H,off,*]
918
- const S = k0.packed.shape[2]!;
919
- for (let b = 0; b < B; b++) {
920
- const pad = prevPad[b]!;
921
- const cutRow = (t: typeof k0): typeof k0 => ({
922
- packed: t.packed.slice([b, 0, pad, 0], [b + 1, t.packed.shape[1]!, S, t.packed.shape[3]!]),
923
- scales: t.scales.slice([b, 0, pad, 0], [b + 1, t.scales.shape[1]!, S, t.scales.shape[3]!]),
924
- biases: t.biases.slice([b, 0, pad, 0], [b + 1, t.biases.shape[1]!, S, t.biases.shape[3]!]),
925
- });
926
- rows.push({ keys: cutRow(k0), values: cutRow(v0) });
927
- }
928
- dispose3(k0); dispose3(v0);
929
- }
930
- rows.push(qRow);
931
- const merged = mergeQuantRows(rows);
932
- newFullPad = merged.leftPad;
933
- const c = new QuantizedKVCache(qSolo.groupSize, qSolo.bits);
934
- c.restoreState(merged.keys, merged.values, merged.width);
935
- newInners.push(c);
936
- for (const r of rows) { dispose3(r.keys); dispose3(r.values); }
937
- }
938
- // qRow views are disposed via the rows loop above or here for extend
939
- if (prevQ && runtimeValue("MLX_BUN_BATCH_EXTEND") !== "0") { dispose3(qRow.keys); dispose3(qRow.values); }
940
- else if (!prevQ) { /* disposed in the rows loop */ }
941
- continue;
942
- }
943
- // NOTE: the quantized-rotating branch below never uses this layer's
944
- // bf16 temporalView — calling it before the branch leaked the pair
945
- // (six arrays per second-row join on quantized rotating layers,
946
- // introduced in 859572d; 2026-07-07 review fix). The view is taken
947
- // AFTER the branch, on the paths that actually consume it.
948
- const soloC = p.solo[layer] as KVCache | RotatingKVCache;
949
- if (this.#kinds[layer] === "rot" && isRotatingQuantizedCache(p.solo[layer])) {
950
- // Milestone 2 — QUANTIZED rotating layer: the solo row converted at
951
- // the serial boundaries (#quantizeSolo), so its ring bytes are the
952
- // serial oracle's; this branch re-arranges temporal triples across
953
- // the batch axis (the bf16 rot merge over triples).
954
- const qSolo = p.solo[layer] as RotatingQuantizedKVCache;
955
- const [qk, qv] = qSolo.temporalView();
956
- const rows: QuantRow[] = [];
957
- const offsets: number[] = [];
958
- const dispose3 = (t: QuantRow) => {
959
- t.keys.packed.dispose(); t.keys.scales.dispose(); t.keys.biases.dispose();
960
- t.values.packed.dispose(); t.values.scales.dispose(); t.values.biases.dispose();
961
- };
962
- const prevC = prev?.[layer];
963
- if (prevC && isRowBatchCache(prevC) && isRotatingQuantizedCache(prevC)) {
964
- const batched = prevC as BatchedRotatingQuantCache;
965
- const [k0, v0] = batched.temporalView(); // [B,H,valid,*] triples, temporal
966
- const valid = k0.packed.shape[2]!;
967
- for (let b = 0; b < B; b++) {
968
- const pad = Math.max(0, batched.leftPad[b]!);
969
- const cutRow = (t: typeof k0): typeof k0 => ({
970
- packed: t.packed.slice([b, 0, pad, 0], [b + 1, t.packed.shape[1]!, valid, t.packed.shape[3]!]),
971
- scales: t.scales.slice([b, 0, pad, 0], [b + 1, t.scales.shape[1]!, valid, t.scales.shape[3]!]),
972
- biases: t.biases.slice([b, 0, pad, 0], [b + 1, t.biases.shape[1]!, valid, t.biases.shape[3]!]),
973
- });
974
- rows.push({ keys: cutRow(k0), values: cutRow(v0) });
975
- offsets.push(batched.offsetArr[b]!);
976
- }
977
- for (const t of [k0, v0]) { t.packed.dispose(); t.scales.dispose(); t.biases.dispose(); }
978
- } else if (isRotatingQuantizedCache(prevC)) {
979
- // Adopted lone row (Phase 3.2 adopt): its chronological triples
980
- // are the merge's first row, pad 0 by definition.
981
- const [k0, v0] = prevC.temporalView();
982
- rows.push({ keys: k0, values: v0 });
983
- offsets.push(prevC.offset);
984
- }
985
- rows.push({ keys: qk, values: qv });
986
- offsets.push(qSolo.offset);
987
- newInners.push(BatchedRotatingQuantCache.merge(
988
- rows, offsets, this.#rotMaxSize[layer]!, qSolo.groupSize, qSolo.bits,
989
- ));
990
- for (const r of rows) dispose3(r);
991
- continue;
992
- }
993
- const [sk, sv] = soloC.temporalView();
994
- const newRow: Row1 = { keys: sk, values: sv };
995
- if (this.#kinds[layer] === "rot") {
996
- const rows: Row1[] = [];
997
- const offsets: number[] = [];
998
- const prevC = prev?.[layer];
999
- if (isRotatingPlainCache(prevC) && !isRowBatchCache(prevC)) {
1000
- // Adopted lone row (Phase 3.2): a plain serial rotating cache —
1001
- // its chronological view is the merge's first row, same as a
1002
- // fresh solo (pad 0 by definition).
1003
- const [k0, v0] = prevC.temporalView();
1004
- rows.push({ keys: k0, values: v0 });
1005
- offsets.push(prevC.offset);
1006
- }
1007
- // Capability-only: BatchedRotatingCache has NO signature() override
1008
- // (cacheSignature → "unknown"), so any signature-based conjunct here
1009
- // silently drops the running batch's cache from the merge — joiners
1010
- // then build a B<max batched ring while forwards arrive at full B
1011
- // (grow-path concatenate crash, whole-batch drop; 2026-08-22 agg×4
1012
- // regression from 443f333). The quant family is routed by its own
1013
- // branch above (its subclass inherits a real signature).
1014
- const prevRot = prevC && isRowBatchCache(prevC)
1015
- ? prevC as unknown as BatchedRotatingCache
1016
- : undefined;
1017
- if (prevRot) {
1018
- const [k0, v0] = prevRot.temporalView(); // [B,H,valid,D]
1019
- const [, H, valid, D] = k0.shape as [number, number, number, number];
1020
- const vD = v0.shape[3]!;
1021
- for (let b = 0; b < B; b++) {
1022
- const pad = Math.max(0, prevRot.leftPad[b]!);
1023
- rows.push({
1024
- keys: k0.slice([b, 0, pad, 0], [b + 1, H, valid, D]),
1025
- values: v0.slice([b, 0, pad, 0], [b + 1, H, valid, vD]),
1026
- });
1027
- offsets.push(prevRot.offsetArr[b]!);
1028
- }
1029
- k0.dispose(); v0.dispose();
1030
- }
1031
- rows.push(newRow);
1032
- offsets.push(soloC.offset);
1033
- newInners.push(BatchedRotatingCache.merge(rows, offsets, this.#rotMaxSize[layer]!));
1034
- for (const r of rows) { r.keys.dispose(); r.values.dispose(); }
1035
- } else {
1036
- const prevFull = prev?.[layer] as KVCache | undefined;
1037
- if (prevFull && runtimeValue("MLX_BUN_BATCH_EXTEND") !== "0") {
1038
- // extend-join (mlx-lm BatchKVCache.extend semantics, P0): append
1039
- // the new right-justified row to the running buffer in ONE pad +
1040
- // ONE concat — no per-row extraction. Existing pads grow, never
1041
- // shrink (the re-merge below re-normalizes them instead; both are
1042
- // masked, both token-exact vs their mlx-lm protocol twin).
1043
- // MLX_BUN_BATCH_EXTEND=0 = the O(B·S) re-merge, kill switch/A-B.
1044
- const [k0, v0] = prevFull.temporalView(); // [B,H,off,D]
1045
- const ext = extendKVRows(k0, v0, prevPad, newRow);
1046
- k0.dispose(); v0.dispose();
1047
- newFullPad = ext.leftPad;
1048
- const c = new KVCache();
1049
- c.restoreState(ext.keys, ext.values, ext.width);
1050
- newInners.push(c);
1051
- newRow.keys.dispose(); newRow.values.dispose();
1052
- } else {
1053
- const rows: Row1[] = [];
1054
- if (prevFull) {
1055
- const [k0, v0] = prevFull.temporalView(); // [B,H,off,D]
1056
- const [, H, off, D] = k0.shape as [number, number, number, number];
1057
- const vD = v0.shape[3]!;
1058
- for (let b = 0; b < B; b++) {
1059
- const pad = prevPad[b]!;
1060
- rows.push({
1061
- keys: k0.slice([b, 0, pad, 0], [b + 1, H, off, D]),
1062
- values: v0.slice([b, 0, pad, 0], [b + 1, H, off, vD]),
1063
- });
1064
- }
1065
- k0.dispose(); v0.dispose();
1066
- }
1067
- rows.push(newRow);
1068
- const merged = mergeKVRows(rows);
1069
- newFullPad = merged.leftPad;
1070
- const c = new KVCache();
1071
- c.restoreState(merged.keys, merged.values, merged.width);
1072
- newInners.push(c);
1073
- for (const r of rows) { r.keys.dispose(); r.values.dispose(); }
1074
- }
1075
- }
1076
- }
1077
- if (prev) for (const c of prev) c.dispose();
1078
- // An adopted row's entry-backed arrays are gone after the prev dispose;
1079
- // run its retain now. The joiner's likewise after its solo dispose.
1080
- this.#adoptedRetain?.();
1081
- this.#adoptedRetain = null;
1082
- for (const c of p.solo) c.dispose();
1083
- p.retain?.();
1084
- // Every row in a REAL merge has its KV interleaved in batched buffers —
1085
- // no longer prompt-cache put() candidates.
1086
- for (const r of this.#running) r.merged = true;
1087
- p.row.merged = true;
1088
- this.#inners = newInners;
1089
- this.#fullLeftPad = newFullPad;
1090
- this.#running.push(p.row);
1091
- }
1092
-
1093
- /** One PIPELINED batched decode step (mlx-lm GenerationBatch._step):
1094
- * 1. forward all rows from the UNREAD pending token array (or, pipeline
1095
- * cold, from the rows' last emitted tokens), sample each live row's next
1096
- * token on its [1,V] slice, asyncEval the new [B] token array;
1097
- * 2. THEN sync-read the previous step's tokens (overlapping the readback
1098
- * with this step's compute), emit them, and evict finished rows.
1099
- * Rows that finish get one extra harmless KV write from the already-built
1100
- * step; filter drops the row (mlx-lm behaves identically). Length-finished
1101
- * rows are known in advance and are NOT sampled (placeholder slot). */
1102
- async #step(): Promise<void> {
1103
- if (STEP_TRACE) {
1104
- const now = performance.now();
1105
- if (STEP_T.lastEnd) STEP_T.gap += now - STEP_T.lastEnd;
1106
- STEP_T.t0 = now;
1107
- }
1108
- const rows = this.#running;
1109
- const B = rows.length;
1110
- const inners = this.#inners!;
1111
-
1112
- // A row is live if it still needs tokens sampled; a row whose pending
1113
- // unread token is its last (sampled == maxTokens) only awaits emission.
1114
- const anyLive = rows.some((r) => r.sampled < r.req.maxTokens);
1115
- // Grammar (B1): if any row has a LIVE grammar controller (not terminated,
1116
- // still sampling), take the read-before-build shape — the matcher's
1117
- // acceptToken needs the token id as a JS number, so the previous step's
1118
- // [B] token array is read back NOW (before building the next graph),
1119
- // accept()ed per row (firing async fills that overlap the forward), then
1120
- // ready() is awaited before this step's sample. This is the serial loop's
1121
- // grammar resolution transplanted to the batch. Batches with NO live
1122
- // grammar row keep the pipelined path byte-identical (zero cost when
1123
- // unused). The trade: while a grammar row is live the batch runs
1124
- // effectively NO_PIPELINE (readback no longer overlaps GPU compute) —
1125
- // bounded by the readback (~0.1 ms) + fills (0.004–0.19 ms/row, overlapped
1126
- // with the graph build). Serial grammar pays the identical trade today.
1127
- const hasLiveGrammar = anyLive && rows.some(
1128
- (r) => r.req.grammar && !r.req.grammar.isTerminated,
1129
- );
1130
- if (hasLiveGrammar) return this.#stepGrammar();
1131
-
1132
- let nextToks: MlxArray | null = null;
1133
- let nextReal: boolean[] | null = null;
1134
- if (anyLive) {
1135
- // Fed-token accounting (prompt-cache put): a COLD step feeds each
1136
- // row's `current` (values known now, always a real emitted token); a
1137
- // pipelined step feeds the pending array, whose values are pushed at
1138
- // the read below gated on the per-slot real flags.
1139
- if (!this.#pendingToks) for (const r of rows) r.fed.push(r.current);
1140
- // Per-layer forward cache: rot layers use the persistent
1141
- // BatchedRotatingCache directly; ssm layers are already [B,...] state
1142
- // with no padding (no mask, no per-row RoPE — pass through); full
1143
- // layers get a fresh BatchedDecodeMaskCache wrapper — UNLESS no row
1144
- // has left padding. UNPADDED FAST PATH (unified-engine plan Phase 2,
1145
- // the B=1 case above all): with every leftPad 0 the wrapper's two
1146
- // jobs vanish — the padding mask (KVCache.makeMask(1) is the empty
1147
- // mask, exactly the serial loop's) and the per-row rope positions
1148
- // (every row sits at the shared scalar offset). The bare cache then
1149
- // dispatches the SAME per-step graph serial builds; the wrapper
1150
- // otherwise costs a host mask build + ~8 device nodes PER FULL LAYER
1151
- // PER TOKEN (the Phase-0 constant ~4–6 ms/step host tax at B=1).
1152
- const unpadded = this.#fullLeftPad.every((p) => p === 0);
1153
- // Compiled decode at B=1 (Phase 3.2): after adopt-don't-copy, a lone
1154
- // row's caches are SERIAL-CLASS, so the serial engine's compiled step
1155
- // replays the same C++ graph here — closing the batch lane's last
1156
- // B=1 host-tax gap (e4b's ~7%). Guards: gemma dense (constructor),
1157
- // serial-class caches (supports — a merged batch's BatchedRotating
1158
- // layers fail it), unpadded, and a uint32 pipeline register (the
1159
- // trace signature; per-row int32 samplers take the graph path).
1160
- // Grammar batches use #stepGrammar and stay on the graph path.
1161
- let lg: MlxArray | null = null;
1162
- let evalWith: MlxArray[] = [];
1163
- if (
1164
- this.#compiled && B === 1 && unpadded &&
1165
- (!this.#pendingToks || this.#pendingToks.dtype === Dtype.uint32) &&
1166
- // A filtered-to-one BATCHED rot-quant cache subclasses the serial
1167
- // class (so supports() passes) but carries batched ring state —
1168
- // exclude it; only truly-adopted serial caches replay compiled.
1169
- !inners.some((c) => isRowBatchCache(c) && isRotatingQuantizedCache(c)) &&
1170
- CompiledDecode.supports(inners as Cache[])
1171
- ) {
1172
- let cur = this.#pendingToks;
1173
- let owned = false;
1174
- if (!cur) {
1175
- const i = ops.fromInt32([rows[0]!.current], [1]);
1176
- cur = i.astype(Dtype.uint32);
1177
- i.dispose();
1178
- owned = true;
1179
- }
1180
- try {
1181
- const r = this.#compiled.step(cur, inners as Cache[]);
1182
- lg = r.logits; // [1,1,V]
1183
- evalWith = r.evalWith;
1184
- } catch (e) {
1185
- // A failed step is transactional (see generate.ts) — safe to
1186
- // re-forward the same token on the graph path below.
1187
- this.#compiled = null;
1188
- console.warn(`batch lane: compiled decode disabled: ${e}`);
1189
- } finally {
1190
- if (owned) cur.dispose();
1191
- }
1192
- }
1193
- let fwd: Cache[] | null = null;
1194
- try {
1195
- if (!lg) {
1196
- fwd = inners.map((c) =>
1197
- isRowBatchCache(c) || isBatchableCache(c) || unpadded
1198
- ? c
1199
- : isQuantizedKvCache(c)
1200
- ? new BatchedQuantDecodeMaskCache(c, B, this.#fullLeftPad)
1201
- : new BatchedDecodeMaskCache(c, B, this.#fullLeftPad, null),
1202
- );
1203
- const ids = this.#pendingToks
1204
- ? ops.reshape(this.#pendingToks, [B, 1]) // feed the unread tokens
1205
- : ops.fromInt32(rows.map((r) => r.current), [B, 1]); // pipeline cold
1206
- const h = await this.#forwardHidden(ids, fwd);
1207
- ids.dispose();
1208
- lg = this.model.logitsFromHidden(h); // [B,1,V]
1209
- h.dispose();
1210
- }
1211
- const V = lg.shape[2]!;
1212
- // Vectorized homogeneous sampling (batching-perf-path P0): when every
1213
- // LIVE row is plain greedy, one log-softmax+argmax over [B,V] replaces
1214
- // B slice/sample/concat graphs. Per-row identical math (row-independent
1215
- // ops, same per-row shapes — argmax over log-softmax mirrors the
1216
- // closure's toLogprobs→argmax exactly, tie behavior included). Doomed
1217
- // rows get a real argmax instead of the placeholder 0 — equally
1218
- // harmless (the slot is filtered before it is ever emitted; its only
1219
- // use is one KV write on the row's own, about-to-evict row).
1220
- const vecOk =
1221
- runtimeValue("MLX_BUN_BATCH_VEC_SAMPLE") !== "0" &&
1222
- rows.every((r) => r.sampled >= r.req.maxTokens || r.req.plainGreedy);
1223
- // Doomed slots hold placeholders (vec path: a real argmax value,
1224
- // equally not part of the row's stream) — flag them so the fed
1225
- // accounting at the read stays per-row exact.
1226
- nextReal = rows.map((r) => r.sampled < r.req.maxTokens);
1227
- if (vecOk) {
1228
- const flat = ops.reshape(lg, [B, V]);
1229
- lg.dispose();
1230
- const lp = toLogprobs(flat);
1231
- flat.dispose();
1232
- nextToks = ops.argmaxAxis(lp, -1); // [B]
1233
- lp.dispose();
1234
- for (const row of rows) if (row.sampled < row.req.maxTokens) row.sampled++;
1235
- } else {
1236
- const sampled: MlxArray[] = [];
1237
- for (let b = 0; b < B; b++) {
1238
- const row = rows[b]!;
1239
- if (row.sampled >= row.req.maxTokens) {
1240
- // Length-doomed row: evicted right after the emission below ever
1241
- // uses this slot as input — placeholder keeps the [B] alignment.
1242
- sampled.push(ops.fromInt32([0], [1]));
1243
- continue;
1244
- }
1245
- const rl = lg.slice([b, 0, 0], [b + 1, 1, V]);
1246
- const rl2 = ops.reshape(rl, [1, V]);
1247
- rl.dispose();
1248
- sampled.push(row.req.sample(rl2, row.sampled));
1249
- row.sampled++;
1250
- rl2.dispose();
1251
- }
1252
- lg.dispose();
1253
- nextToks = ops.concatAxis(sampled, 0); // [B]
1254
- for (const t of sampled) t.dispose();
1255
- }
1256
- // dispatch; read NEXT iteration. evalWith: the compiled step's
1257
- // cache-update nodes must ride the same async_eval (generate.ts).
1258
- ops.asyncEvalAll([nextToks, ...evalWith]);
1259
- } finally {
1260
- // Free the step's RoPE arrays; do NOT dispose (full wrappers would free
1261
- // their persistent inner; rot caches persist across steps).
1262
- if (fwd) for (const c of fwd) (c as { releaseRopeArr?: () => void }).releaseRopeArr?.();
1263
- }
1264
- }
1265
- this.#steps++;
1266
- if (this.#steps % 256 === 0) clearCache(); // serial's cadence, not per-step
1267
- if (STEP_TRACE) STEP_T.build += performance.now() - STEP_T.t0;
1268
-
1269
- // Read + emit the PREVIOUS step's tokens while the new step computes.
1270
- const prev = this.#pendingToks;
1271
- const prevReal = this.#pendingReal;
1272
- this.#pendingToks = nextToks;
1273
- this.#pendingReal = nextReal;
1274
- // Kill switch / A-B lever (house style, cf. MLX_BUN_COMPILED_DECODE=0):
1275
- // MLX_BUN_BATCH_NO_PIPELINE=1 reads THIS step's tokens synchronously —
1276
- // set from process start `prev` is always null, so the flush below IS the
1277
- // whole phase 2. Same math either way (pipelining is scheduling).
1278
- if (NO_PIPELINE) {
1279
- await this.#flushPipeline();
1280
- return;
1281
- }
1282
- if (prev) {
1283
- const tRead = STEP_TRACE ? performance.now() : 0;
1284
- const toks = prev.toIntTokens();
1285
- prev.dispose();
1286
- // These values were the step's forward input (fed) iff a forward ran.
1287
- // Placeholder slots are excluded from `fed` — and taint their row if
1288
- // they ever fed (see Row.fedTainted; structurally unreachable today).
1289
- if (anyLive)
1290
- for (let b = 0; b < B; b++) {
1291
- if (!prevReal || prevReal[b]) rows[b]!.fed.push(toks[b]!);
1292
- else rows[b]!.fedTainted = true;
1293
- }
1294
- if (STEP_TRACE) STEP_T.read += performance.now() - tRead;
1295
- const tEmit = STEP_TRACE ? performance.now() : 0;
1296
- await this.#emitRows(toks); // also filters #pendingToks on eviction
1297
- if (STEP_TRACE) { STEP_T.emit += performance.now() - tEmit; STEP_T.n++; }
1298
- }
1299
- if (STEP_TRACE) STEP_T.lastEnd = performance.now();
1300
- }
1301
-
1302
- /** Read out the pipeline register (if any): emit its tokens and evict
1303
- * finished rows, leaving the pipeline cold. Called before a join merges. */
1304
- async #flushPipeline(): Promise<void> {
1305
- const prev = this.#pendingToks;
1306
- if (!prev) return;
1307
- this.#pendingToks = null;
1308
- this.#pendingReal = null; // flushed values never fed — nothing to account
1309
- const toks = prev.toIntTokens();
1310
- prev.dispose();
1311
- // Grammar rows: the flushed tokens are EMITTED below, so their matchers
1312
- // must advance here — #stepGrammar's accept only covers tokens it reads
1313
- // from a live pending array, and after this flush it cold-starts (no
1314
- // accept). Skipping this left the matcher one token behind its stream on
1315
- // every mid-decode join → one-step-stale masks → invalid output (found
1316
- // by the feature-matrix conformance gate, 2026-07-03; the fill fired
1317
- // here is awaited by the next #stepGrammar's ready()).
1318
- for (let b = 0; b < this.#running.length && b < toks.length; b++) {
1319
- const g = this.#running[b]!.req.grammar;
1320
- if (g && !g.isTerminated) g.accept(toks[b]!);
1321
- }
1322
- await this.#emitRows(toks);
1323
- }
1324
-
1325
- /** The read-before-build decode step for batches with ≥1 live grammar row
1326
- * (B1). Mirrors the serial loop's grammar resolution: read the previous
1327
- * step's [B] tokens NOW (acceptToken needs JS numbers), accept() per row
1328
- * (fires async fills), build the forward graph (fills overlap), await
1329
- * ready(), sample per live row (the closure applies applyMask), then emit
1330
- * the values read in step 1 — no second readback. Terminated grammar rows
1331
- * keep their [B] slot through the forward (placeholder, not sampled) and
1332
- * finish("stop") in #emitRows. On a cold start (first step after prefill)
1333
- * there is no pending array to read; the prefill already accepted token 0
1334
- * and fired the fill, so we just await ready() + sample. */
1335
- async #stepGrammar(): Promise<void> {
1336
- const rows = this.#running;
1337
- const B = rows.length;
1338
- const inners = this.#inners!;
1339
-
1340
- // (1) Read the pending [B] token array (host copy for accept). The device
1341
- // array is kept for the forward input below; disposed after sampling.
1342
- // Cold start: pendingToks null → prevVals empty (prefill handled tok0).
1343
- const prev = this.#pendingToks;
1344
- const prevVals: number[] =
1345
- prev ? prev.toIntTokens() : [];
1346
- if (runtimeValue("MLX_BUN_GRAMMAR_DEBUG") === "1")
1347
- console.log(`[sg] B=${B} prevVals=${JSON.stringify(prevVals)} current=${JSON.stringify(rows.map((r) => r.current))} sampled=${JSON.stringify(rows.map((r) => r.sampled))}`);
1348
-
1349
- // (2) accept() per live grammar row — fires that row's async bitmask fill.
1350
- for (let b = 0; b < B && prevVals.length; b++) {
1351
- const g = rows[b]!.req.grammar;
1352
- if (g && !g.isTerminated) g.accept(prevVals[b]!);
1353
- }
1354
-
1355
- let nextToks: MlxArray | null = null;
1356
- let nextReal: boolean[] | null = null;
1357
- const anyLive = rows.some((r) => r.sampled < r.req.maxTokens);
1358
- if (anyLive) {
1359
- // Fed-token accounting (prompt-cache put) — mirror of #step's read:
1360
- // real slots push, a placeholder that fed taints its row.
1361
- const prevReal = this.#pendingReal;
1362
- if (prev)
1363
- for (let b = 0; b < B; b++) {
1364
- if (!prevReal || prevReal[b]) rows[b]!.fed.push(prevVals[b]!);
1365
- else rows[b]!.fedTainted = true;
1366
- }
1367
- else for (const r of rows) r.fed.push(r.current);
1368
- // Unpadded fast path — same rule as #step (bare caches == serial graph).
1369
- const unpadded = this.#fullLeftPad.every((p) => p === 0);
1370
- const fwd: Cache[] = inners.map((c) =>
1371
- isRowBatchCache(c) || isBatchableCache(c) || unpadded
1372
- ? c
1373
- : isQuantizedKvCache(c)
1374
- ? new BatchedQuantDecodeMaskCache(c, B, this.#fullLeftPad)
1375
- : new BatchedDecodeMaskCache(c, B, this.#fullLeftPad, null),
1376
- );
1377
- try {
1378
- // (3) Build the forward graph (host-side; the fills overlap it).
1379
- const ids = prev
1380
- ? ops.reshape(prev, [B, 1]) // feed the unread tokens (device array)
1381
- : ops.fromInt32(rows.map((r) => r.current), [B, 1]); // pipeline cold
1382
- const h = await this.#forwardHidden(ids, fwd);
1383
- ids.dispose();
1384
- const lg = this.model.logitsFromHidden(h); // [B,1,V]
1385
- h.dispose();
1386
- const V = lg.shape[2]!;
1387
-
1388
- // (4) await ready() on every live grammar row — the fills fired in (2)
1389
- // overlapped the graph build above. Usually already resolved.
1390
- const liveGrammar = rows.filter(
1391
- (r) => r.req.grammar && !r.req.grammar.isTerminated,
1392
- );
1393
- if (liveGrammar.length)
1394
- await Promise.all(liveGrammar.map((r) => r.req.grammar!.ready()));
1395
-
1396
- // (5) Sample per live row (the closure applies applyMask after the
1397
- // logits processors). Terminated grammar rows + length-doomed rows
1398
- // take a placeholder (one harmless KV write, then evicted).
1399
- const sampled: MlxArray[] = [];
1400
- nextReal = rows.map(
1401
- (r) => r.sampled < r.req.maxTokens && !r.req.grammar?.isTerminated,
1402
- );
1403
- for (let b = 0; b < B; b++) {
1404
- const row = rows[b]!;
1405
- if (
1406
- row.sampled >= row.req.maxTokens ||
1407
- row.req.grammar?.isTerminated
1408
- ) {
1409
- sampled.push(ops.fromInt32([0], [1]));
1410
- continue;
1411
- }
1412
- const rl = lg.slice([b, 0, 0], [b + 1, 1, V]);
1413
- const rl2 = ops.reshape(rl, [1, V]);
1414
- rl.dispose();
1415
- sampled.push(row.req.sample(rl2, row.sampled));
1416
- row.sampled++;
1417
- rl2.dispose();
1418
- }
1419
- lg.dispose();
1420
- nextToks = ops.concatAxis(sampled, 0); // [B]
1421
- for (const t of sampled) t.dispose();
1422
- ops.asyncEvalAll([nextToks]);
1423
- } finally {
1424
- for (const c of fwd)
1425
- (c as { releaseRopeArr?: () => void }).releaseRopeArr?.();
1426
- }
1427
- }
1428
- this.#steps++;
1429
- if (this.#steps % 256 === 0) clearCache();
1430
-
1431
- // The device array `prev` has now fed the forward + been read for accept;
1432
- // dispose it and install the new pending array. On cold start prev is null.
1433
- prev?.dispose();
1434
- this.#pendingToks = nextToks;
1435
- this.#pendingReal = nextReal;
1436
-
1437
- // (6) Emit the values read in (1) — no second readback. Terminated grammar
1438
- // rows finish("stop") via #emit's isTerminated check; the filter evicts.
1439
- // Cold start: prevVals empty (prefill emitted tok0) → nothing to emit.
1440
- if (prevVals.length) await this.#emitRows(prevVals);
1441
- }
1442
-
1443
- /** Emit one read-back token per running row; evict finished rows. A row's
1444
- * onToken throwing rejects THAT row and evicts it — siblings continue. */
1445
- async #emitRows(toks: number[]): Promise<void> {
1446
- const rows = this.#running;
1447
- const B = rows.length;
1448
- const keep: number[] = [];
1449
- const done: { b: number; row: Row }[] = []; // clean finishes (stop/length)
1450
- for (let b = 0; b < B; b++) {
1451
- const row = rows[b]!;
1452
- row.generated++;
1453
- let disp: "continue" | "stop" | "length";
1454
- try {
1455
- disp = await this.#emit(row, toks[b]!);
1456
- } catch (e) {
1457
- row.reject(e); // containment: this row only (mlx-lm `remove`)
1458
- row.merged = true; // poison: a rejected row is never put() back
1459
- row.fedTainted = true;
1460
- continue; // not kept → evicted by the filter below
1461
- }
1462
- if (disp === "continue") keep.push(b);
1463
- else {
1464
- done.push({ b, row });
1465
- this.#finish(row, disp);
1466
- }
1467
- }
1468
- if (keep.length < B) {
1469
- // Per-row prompt-cache extraction (mlx-lm server.py:864-880 →
1470
- // BatchGenerator.extract_cache): a MERGED row's KV lives interleaved
1471
- // in the batched inners, so it is pulled out into fresh serial caches
1472
- // BEFORE filter() mutates them. Never-merged lone rows skip this —
1473
- // #applyFilter's keep=[] path put()s their adopted caches zero-copy.
1474
- // Rejected rows aren't in `done`; the whole-batch error path calls
1475
- // #applyFilter(dropOnly) directly and never reaches here.
1476
- for (const { b, row } of done) this.#extractAndPut(b, row);
1477
- this.#applyFilter(keep);
1478
- }
1479
- }
1480
-
1481
- /** Account one sampled token for a row. Mirrors generate(): EOS terminates
1482
- * WITHOUT an onToken call; otherwise onToken(token) runs and `false` halts;
1483
- * reaching maxTokens ends with "length". Advances row.current on continue. */
1484
- async #emit(row: Row, token: number): Promise<"continue" | "stop" | "length"> {
1485
- row.req.signal?.throwIfAborted();
1486
- if (row.req.eosTokenIds.includes(token)) return "stop";
1487
- const cont = await row.req.onToken(token);
1488
- if (cont === false) return "stop";
1489
- // Grammar termination: the matcher is satisfied (e.g. closing `}` accepted).
1490
- // The final token has been delivered via onToken above; halt with "stop" so
1491
- // the row finishes + evicts rather than sampling into an all--inf mask.
1492
- if (row.req.grammar?.isTerminated) return "stop";
1493
- if (row.generated >= row.req.maxTokens) return "length";
1494
- row.current = token;
1495
- return "continue";
1496
- }
1497
-
1498
- #finish(row: Row, reason: "stop" | "length"): void {
1499
- row.resolve({
1500
- promptTokens: row.promptTokens,
1501
- generatedTokens: row.generated,
1502
- cachedTokens: row.cachedTokens,
1503
- finishReason: reason,
1504
- });
1505
- }
1506
-
1507
- /** Finish-time disposition of serial-class caches covering exactly
1508
- * `tokens`: put() into the prompt cache when the hook is present and the
1509
- * offset lines up (defensive — a mismatch means the accounting is wrong
1510
- * and the entry would corrupt future hits), else dispose. `retain` rides
1511
- * along per the PromptCacheEntry contract (runs after dispose). */
1512
- #putOrDispose(caches: Cache[], tokens: number[], retain?: () => void): void {
1513
- if (this.#promptCache) {
1514
- const withOff = caches.find(
1515
- (c) => typeof (c as { offset?: unknown }).offset === "number",
1516
- ) as { offset: number } | undefined;
1517
- if (withOff && withOff.offset === tokens.length) {
1518
- this.#promptCache.put(tokens, caches, "", retain);
1519
- return;
1520
- }
1521
- }
1522
- for (const c of caches) c.dispose();
1523
- retain?.();
1524
- }
1525
-
1526
- /** Extract a finishing MERGED row's KV into fresh serial caches and put()
1527
- * them keyed by [promptIds + fed] — the batch-lane mirror of mlx-lm
1528
- * server.py:872 (extract_cache → prompt_cache.insert_cache). Gates:
1529
- * promptTokens >= 256 (the boundary-snapshot substantiality gate,
1530
- * server.ts) and an exact coverage key (!fedTainted). Refusal
1531
- * (#extractRowCaches null) just disposes-by-omission — the row's KV dies
1532
- * with the filter, exactly the pre-extraction behavior. */
1533
- #extractAndPut(b: number, row: Row): void {
1534
- if (!this.#promptCache || !row.merged || row.fedTainted) return;
1535
- if (row.promptTokens < 256) return;
1536
- const tokens = [...row.req.promptIds, ...row.fed];
1537
- const caches = this.#extractRowCaches(b, tokens.length);
1538
- if (!caches) return;
1539
- // Materialize the owned copies without stalling the in-flight step (the
1540
- // slices depend on this step's KV writes): async — the batched source
1541
- // buffers free once the copies land, instead of being pinned by a lazy
1542
- // graph inside an idle cache entry.
1543
- ops.asyncEvalAll(caches.flatMap((c) => c.state()));
1544
- this.#putOrDispose(caches, tokens);
1545
- }
1546
-
1547
- /** Row `b` of every layer as OWNED serial-class caches, or null when a
1548
- * layer kind can't be extracted (then the caller drops the row's KV as
1549
- * before). Bit-exactness: merge/extend/filter/decode are byte-preserving
1550
- * per row (tests/batched-decode-parity, tests/batched-rotating,
1551
- * tests/batched-rotating-quant) and each extract is a pure slice+copy of
1552
- * those bytes (tests/batched-extract), so an extracted row's bytes ==
1553
- * the solo run's. */
1554
- #extractRowCaches(b: number, expectTokens: number): Cache[] | null {
1555
- const out: Cache[] = [];
1556
- for (const inner of this.#inners!) {
1557
- let c: Cache | null;
1558
- if (isBatchableCache(inner)) c = inner.extractRow(b);
1559
- else if (isRowBatchCache(inner) && cacheSignature(inner) === "ssm")
1560
- // Coverage gate: recurrent state is UNTRIMMABLE, so an entry is only
1561
- // valid when the row's own advance count equals the [promptIds+fed]
1562
- // key EXACTLY (a mismatched entry would silently corrupt every future
1563
- // exact-hit). Defensive — a miss here means the fed accounting broke.
1564
- c = (inner as SSMCache).conv && (inner as SSMCache).rowOffset(b) === expectTokens
1565
- ? inner.extractRow(b)
1566
- : null;
1567
- else if (isRowBatchCache(inner)) c = inner.extractRow(b);
1568
- else if (
1569
- isRotatingQuantizedCache(inner) || // adopted serial state never
1570
- isRotatingPlainCache(inner) // coexists with a merged row (defensive)
1571
- )
1572
- c = null;
1573
- else if (isQuantizedKvCache(inner))
1574
- c = inner.keys ? extractQuantRow(inner, this.#fullLeftPad[b]!, b) : null;
1575
- else if (isPlainKvCache(inner))
1576
- c = inner.keys ? extractKVRow(inner, this.#fullLeftPad[b]!, b) : null;
1577
- else c = null;
1578
- if (!c) {
1579
- for (const d of out) d.dispose();
1580
- return null;
1581
- }
1582
- out.push(c);
1583
- }
1584
- return out;
1585
- }
1586
-
1587
- /** Evict rows not in `keep` (sorted ascending) from the batched KV and the
1588
- * pipeline register. */
1589
- #applyFilter(keep: number[], dropOnly = false): void {
1590
- const inners = this.#inners!;
1591
- if (keep.length === 0) {
1592
- // Prompt-cache put (Phase 3.2): a lone NEVER-MERGED row's inners are
1593
- // its adopted serial-class caches, covering exactly prompt+fed — hand
1594
- // them back to the cache instead of disposing. dropOnly (the batch-
1595
- // drop error path) and poisoned rows dispose as before; MERGED rows'
1596
- // KV was already extracted per row in #emitRows (owned copies), so
1597
- // disposing the batched inners here is safe either way.
1598
- const solo =
1599
- !dropOnly && this.#running.length === 1 && !this.#running[0]!.merged
1600
- ? this.#running[0]!
1601
- : null;
1602
- if (solo) {
1603
- this.#putOrDispose(
1604
- inners as Cache[],
1605
- [...solo.req.promptIds, ...solo.fed],
1606
- this.#adoptedRetain ?? undefined,
1607
- );
1608
- } else {
1609
- for (const c of inners) c.dispose();
1610
- this.#adoptedRetain?.();
1611
- }
1612
- this.#adoptedRetain = null;
1613
- this.#inners = null;
1614
- this.#fullLeftPad = [];
1615
- this.#running = [];
1616
- this.#pendingToks?.dispose();
1617
- this.#pendingToks = null;
1618
- this.#pendingReal = null;
1619
- return;
1620
- }
1621
- const out: LayerInner[] = [];
1622
- for (const inner of inners) {
1623
- if (isBatchableCache(inner)) {
1624
- inner.filterRows(keep);
1625
- out.push(inner);
1626
- } else if (isRowBatchCache(inner)) {
1627
- inner.filterRows(keep);
1628
- out.push(inner);
1629
- } else if (isRotatingQuantizedCache(inner) || isRotatingPlainCache(inner)) {
1630
- // Adopted lone-row state exists only at B=1, where the only filter
1631
- // is the keep=[] dispose-all handled above — unreachable.
1632
- throw new Error("applyFilter: adopted serial rotating cache cannot be row-filtered");
1633
- } else if (isQuantizedKvCache(inner)) {
1634
- const [k0, v0] = inner.temporalView();
1635
- const f = filterQuantRows(k0, v0, keep);
1636
- for (const t of [k0, v0]) { t.packed.dispose(); t.scales.dispose(); t.biases.dispose(); }
1637
- const c = new QuantizedKVCache(inner.groupSize, inner.bits);
1638
- c.restoreState(f.keys, f.values, inner.offset);
1639
- out.push(c);
1640
- inner.dispose();
1641
- } else if (isPlainKvCache(inner)) {
1642
- const [k0, v0] = inner.temporalView();
1643
- const f = filterKVRows(k0, v0, keep);
1644
- k0.dispose(); v0.dispose();
1645
- const c = new KVCache();
1646
- c.restoreState(f.keys, f.values, inner.offset);
1647
- out.push(c);
1648
- inner.dispose();
1649
- } else {
1650
- throw new Error(`applyFilter: unsupported cache signature ${cacheSignature(inner)}`);
1651
- }
1652
- }
1653
- this.#inners = out;
1654
- this.#fullLeftPad = keep.map((i) => this.#fullLeftPad[i]!);
1655
- this.#running = keep.map((i) => this.#running[i]!);
1656
- if (this.#pendingToks) {
1657
- const idx = ops.fromInt32(keep, [keep.length]);
1658
- const next = ops.takeAxis(this.#pendingToks, idx, 0);
1659
- idx.dispose();
1660
- this.#pendingToks.dispose();
1661
- this.#pendingToks = next;
1662
- }
1663
- if (this.#pendingReal) this.#pendingReal = keep.map((i) => this.#pendingReal![i]!);
1664
- }
1665
-
1666
- #readToken(t: MlxArray): number {
1667
- const v = t.toIntTokens()[0]!;
1668
- t.dispose();
1669
- return v;
1670
- }
1671
- }
1
+ // Compatibility import. Scheduling policy lives in engine/scheduler; MLX
2
+ // batch state and numerical execution belong to the backend group.
3
+ export {
4
+ MlxBatchExecutionGroup as BatchScheduler,
5
+ stepTraceReport,
6
+ type MlxBatchExecutionGroupOptions as BatchSchedulerOptions,
7
+ type BatchRequest, type BatchStats, type RowSampler, type RowPromptCache, type ExclusiveLock,
8
+ } from "../backends/mlx/batch-group";