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,229 +0,0 @@
1
- # Spec decode on larger targets — does the Gemma drafter pay on 12B / 26B?
2
-
3
- Status: **12B γ=1 is a WIN after the verify fix; matches optiq bit-exactly** (2026-06-14; 26B not run)
4
- Owner: decode / spec
5
- Default: **off** (speculation is opt-in; non-spec greedy is untouched)
6
-
7
- ## Result (2026-06-14, v2) — fixed the wrong-oracle bug; γ=1 now wins
8
-
9
- The first 12B measurement (v1, below) was made through a verify path that
10
- targeted the **wrong oracle**. Fixing it changed the conclusion.
11
-
12
- ### The wrong-oracle bug
13
-
14
- Assistant-drafter speculation is an **optiq** feature — mlx-lm has only
15
- generic standalone-`draft_model` two-model spec and structurally cannot
16
- drive a KV-borrowing drafter. So the correctness oracle is optiq's
17
- `spec_generate`. **Both optiq and mlx-lm BATCH the verify lm-head** (one
18
- matmul over the γ+1 window, then argmax per position); neither is bit-
19
- exact to stock token-at-a-time decode. Our port instead verified
20
- **per-position** to be bit-exact to *stock decode* — a target no real
21
- implementation has, and one that re-reads the ~1 GB tied lm-head matrix
22
- γ+1× per iteration instead of once. So we were exact to the wrong thing
23
- *and* paying a γ-scaling tax for it. Fix: `picksBatched` in
24
- `src/spec/generate.ts` — one batched verify lm-head, matching optiq.
25
-
26
- ### Do we match optiq? YES — bit-exact (e4b)
27
-
28
- Cross-checked our `specGenerate` against optiq's `spec_generate` on the
29
- identical prompt ids (`scripts/oracle-spec.py` + `scripts/spec-dump.ts`),
30
- e4b γ=2: **identical 48-token output AND identical accept/reject trace**
31
- (drafted 60, accepted 17, target calls 31 in both). Before the fix we
32
- matched *stock*, not optiq, at knife-edges; now we match optiq.
33
-
34
- ### Is it still a loss? Not at γ=1 (12B)
35
-
36
- 12B pair, batched verify (loaded machine — ratios paired/trustworthy,
37
- absolutes depressed & not quotable):
38
-
39
- | γ | v2 speedup (batched) | v1 (per-position) | acceptance |
40
- |---|----------------------|-------------------|-----------|
41
- | 1 | **1.09× (WIN)** | 0.96× | 42% |
42
- | 2 | 0.91× (near break-even) | 0.77× | 29% |
43
- | 3 | 0.72× | 0.67× | 23% |
44
- | 4 | 0.56× | 0.52× | 17% |
45
-
46
- Acceptance is unchanged (the fix only touches verify cost, not which
47
- tokens are accepted). The per-position verify was a large self-inflicted
48
- tax: removing it flips γ=1 from loss to **win** and moves γ=2 to the edge.
49
- γ≥2 still loses because the *drafter's own* cost dominates there — the
50
- 12B drafter is heavy (hidden 1024 vs e4b 256, 16 heads, and a full 262k
51
- tied-embedding head **every draft step**) and acceptance falls with depth.
52
-
53
- **So the v1 "hypothesis refuted / net loss at every γ" was itself partly
54
- an artifact of the wrong-oracle verify.** Corrected conclusion: γ=1
55
- speculation on 12B is a real ~9% decode win (pending clean-machine
56
- confirmation before quoting); γ≥2 doesn't pay with the shipped heavy
57
- drafter.
58
-
59
- ### Bonus finding: we're ahead of optiq on the unified drafters
60
-
61
- optiq's *installed* runtime can't actually drive the 12B/26B
62
- `gemma4_unified_assistant` drafters: its head detection is config-based
63
- (`use_ordered_embeddings = num_centroids and top_k`), but those artifacts
64
- declare `num_centroids` while shipping **no** centroid tensors and **no**
65
- `lm_head` (tied `embed_tokens` instead). optiq loads the centroid path
66
- with zero-init weights → garbage drafts → **0% acceptance** (measured:
67
- drafted 46, accepted 0 on 12B). Our `drafter.ts` detects the head by
68
- **tensor presence** and uses the tied-embedding path → 29% acceptance on
69
- the same artifact.
70
-
71
- ### Remaining levers / next
72
-
73
- - **Clean-machine rerun** before promoting any number to RESULTS.md
74
- (baseline jumped 15.4→24.4 between runs under memory pressure — exactly
75
- why only paired ratios are trusted here).
76
- - **Cheaper drafter head**: the 262k tied-embed argmax every draft step is
77
- the dominant remaining γ≥2 cost; capping/approximating it (e4b-style)
78
- could extend the win past γ=1.
79
- - **26B-MoE**: still unlikely — MoE decode reads only top-8/128 experts
80
- (~2.4 GB active), so per-token target cost is *lower* than its size →
81
- harder to beat. Measure if curious.
82
- - **Optional strict mode**: the old per-position verify gave "spec output
83
- identical to spec-off" — a product nicety no oracle provides. Could be
84
- kept as a default-off `strictVerify` flag rather than deleted
85
- ([[dont-delete-optionality-on-one-measurement]]) — not implemented; ask.
86
-
87
- ## v1 (superseded) — per-position verify, "net loss at every γ"
88
-
89
- > Kept for the record. The numbers (0.96/0.77/0.67/0.52×) were real but
90
- > measured through the wrong-oracle per-position verify; v2 above is the
91
- > optiq-faithful measurement.
92
-
93
- ## TL;DR
94
-
95
- The Gemma `-assistant-bf16` drafters are Gemma's substitute for an MTP
96
- head: a 4-layer Q-only module that reuses the target's KV + hidden state
97
- to draft tokens (`src/spec/drafter.ts`), verified by the target's own
98
- greedy pick (`src/spec/generate.ts`). True MTP (a trained
99
- `mtp.safetensors` head + an `optiq/runtime/mtp` backend) **does not exist
100
- for Gemma** and never will — that's the Qwen path (Phase 14). The
101
- assistant drafter is the functional equivalent we already have.
102
-
103
- It was measured a **net loss on e4b** (Phase 6): γ=2 ran at 0.78× the
104
- 54 tok/s baseline, ~23% acceptance — because e4b decode is already too
105
- fast for a low-acceptance drafter to beat. **This doc is the experiment
106
- to find out whether the same drafter wins on a slower target**, where
107
- each skipped target-forward is worth more.
108
-
109
- ## The hypothesis
110
-
111
- The drafter's per-step cost is roughly fixed. The *value* of each
112
- accepted draft = one skipped target forward, which grows as the target
113
- gets slower per token. e4b is the worst case for speculation (fastest
114
- target). So the SAME acceptance rate can flip net-positive as the target
115
- slows:
116
-
117
- | target | non-spec decode | per-accepted-draft value | drafter | status |
118
- |--------|-----------------|--------------------------|---------|--------|
119
- | e4b | ~54 tok/s | low | 160 MB (cached) | **measured net loss** (0.91/0.78/0.63/0.51× @ γ=1–4) |
120
- | 12B | ~25 tok/s | ~2× e4b | 846 MB (cached) | **measured net loss** — see Result above (hypothesis refuted) |
121
- | 26B-A4B (MoE) | ~32 tok/s | high (MoE forward) | 839 MB (HF, **not downloaded**) | untested (follow-on; expect loss) |
122
-
123
- > The reasoning below is the *pre-measurement* hypothesis — kept for the
124
- > record. It predicted the crossover assuming a fixed-cost drafter; the
125
- > 12B drafter is not fixed-cost (it scales with the target), which is
126
- > exactly why the Result section above refutes it.
127
-
128
- Crossover intuition: a drafter is a net win when
129
- `acceptance × (target_step_cost − draft_step_cost) > verify_overhead`.
130
- On e4b the target step is cheap, so the bar isn't cleared at 23%. On 12B
131
- the target step is ~2× more expensive while the drafter cost barely
132
- moves — same acceptance, very different sign. **Measure, don't assume**
133
- (it could still lose; this is exactly the kind of thing the e4b result
134
- warned against assuming).
135
-
136
- Acceptance itself may also differ on 12B (a stronger target predicts
137
- itself differently). We don't know the direction — that's why we run it.
138
-
139
- ## What is already wired (this change)
140
-
141
- - **`scripts/spec-bench.ts`** — now multi-pair, **one pair per process**
142
- (memory-safe; 12B+drafter ≈ 8 GB, 26B ≈ 17 GB, never co-resident):
143
- - `bun scripts/spec-bench.ts e4b` (default) — reproduces the baseline.
144
- - `bun scripts/spec-bench.ts 12B` — the experiment.
145
- - `bun scripts/spec-bench.ts 26B` — follow-on.
146
- - Auto-detects whether each `(target, drafter)` snapshot pair is
147
- present; exits with a clear "download with: hf download …" message
148
- otherwise. Records baseline + γ-sweep (acceptance + tok/s) to the
149
- eval DB tagged `spec-bench[<id>]`.
150
- - **`tests/spec-decode.test.ts`** — added a `(12B)` parity block,
151
- auto-skipped until the drafter is downloaded. Gate = long-prefix
152
- agreement (greedy spec vs greedy non-spec). It guards the shared
153
- accept/reject/**rollback + cache-trim** machinery against 12B's
154
- distinct layer/cache shapes — NOT drafter correctness (see the
155
- important nuance below). The calibrated e4b block (exact `toEqual` +
156
- knife-edge) is unchanged.
157
-
158
- The port (`drafter.ts` + `generate.ts`) is already generic over the
159
- target's `backbone_hidden_size` — drafters are target-bonded artifacts
160
- (e4b 2560, 12B 3840, 26B 2816) but there's no per-pair code, just the
161
- matching weights. Expect to *validate* each cell per the project's
162
- per-model-specialization reality, not assume it's free.
163
-
164
- ## Important nuance: a broken drafter still produces correct tokens
165
-
166
- Speculation can't corrupt output. The target verifies every position and
167
- replaces every rejected draft with its own greedy pick; drafts only ever
168
- *skip* target forwards. So a mis-wired drafter (wrong KV borrow, wrong
169
- projection) shows up as **low/zero acceptance — a perf signal**, not bad
170
- tokens. This splits the validation cleanly:
171
-
172
- - **Correctness** (parity test): does the shared accept/reject/rollback
173
- + cache-trim logic still hold on 12B's cache shapes? A trim bug *would*
174
- corrupt output, shortly after the first rejection — that's what the
175
- long-prefix gate catches. (Shape-incompatible KV borrow throws, it
176
- doesn't silently corrupt.)
177
- - **Payoff** (bench): is acceptance high enough, and the target slow
178
- enough, that γ>0 beats the non-spec baseline? This is the actual
179
- question.
180
-
181
- ## Procedure (once the 12B drafter is downloaded)
182
-
183
- 1. `hf download mlx-community/gemma-4-12B-it-assistant-bf16`
184
- (846 MB; `HF_HUB_DISABLE_XET=1`). The 12B OptiQ-4bit target is
185
- already cached.
186
- 2. **Correctness first** — `bun test tests/spec-decode.test.ts`
187
- (the `(12B)` block now runs). Long-prefix gate must pass for γ=1,2,3.
188
- If it fails early → rollback/trim bug on 12B caches, fix before
189
- trusting any number.
190
- 3. **Calibrate + promote (optional)** — find a tie-free prompt
191
- on-device, promote the 12B gate from long-prefix to exact `toEqual`
192
- like e4b, for a stronger standing guard.
193
- 4. **Measure** — `bun scripts/spec-bench.ts 12B` on a clean machine
194
- (preflight matters: dirty-machine numbers are garbage). Read
195
- acceptance + speedup-vs-baseline per γ.
196
- 5. **Record + decide** — numbers land in the eval DB. Promote into
197
- `benchmarks/RESULTS.md` only if quotable (clean machine).
198
- 6. **26B follow-on** — repeat with `26B` if 12B is encouraging; MoE
199
- target adds a validation cell (donor KV borrow through the MoE
200
- decoder), and a slower/heavier forward that should favor speculation
201
- even more — but the MoE forward also reads only top-8/128 experts at
202
- decode, so the per-token cost advantage is smaller than weight size
203
- suggests. Measure.
204
-
205
- ## Exit criteria
206
-
207
- - 12B spec parity gate green (rollback/trim correct on 12B caches).
208
- - 12B acceptance + tok/s-vs-baseline recorded in the eval DB across
209
- γ=1–4.
210
- - A decision written down: ship speculation as the **default** only for
211
- the (model, context) cells where it measurably wins; otherwise record
212
- it as a characterized default-off lever (same disposition as the e4b
213
- net-loss result — a documented option, never a deletion).
214
-
215
- ## Caveats / interactions
216
-
217
- - **Batched serving (Phase 18).** Speculation needs trimmable caches for
218
- partial-accept rollback — `generate.ts` throws once a `RotatingKVCache`
219
- ring wraps past the sliding window — and variable accept-lengths per
220
- slot break a uniform-B batch. Speculation is naturally a **B=1 latency**
221
- optimization; batched-throughput serving is a different regime. Layering
222
- both is real work, not free composition.
223
- - **One serve path, two backends.** The assistant-drafter loop and the
224
- Qwen MTP runtime are two backends of the *same* "draft γ → verify in one
225
- forward → accept-longest-prefix → rollback" abstraction. As Phase 14
226
- (MTP) and Phase 18 (slots) land, build one verify/accept executor, not
227
- two — the drafter vs MTP-head difference is just what fills the draft.
228
- - **Denominator discipline.** Speedups are only ever vs the *same model's*
229
- non-spec baseline. e4b's 54 tok/s is the wrong denominator for 12B.
@@ -1,415 +0,0 @@
1
- # SSD cold tier for the prompt/KV cache
2
-
3
- Status: **LANDED P1–P3 (2026-07-02, same day)** — P4 hardening extras remain.
4
- **Layer-0 upgrade (2026-07-05, unified-engine plan):** D2 finished properly —
5
- the two-tier take/restore/trim dance moved INSIDE `PromptCache.take()`
6
- (structural `ColdTier` interface; the server binds SsdCacheStore + model into
7
- it), so EVERY consumer tiers automatically: the serial lane's hand-rolled
8
- block in runGeneration is gone, and the BATCH SCHEDULER restores prefixes
9
- from disk at admission (gated end-to-end: tests/batch-scheduler.test.ts
10
- "SSD tier through the batch lane"). `PromptCache.onPut` drives the debounced
11
- write-behind snapshot for both lanes' puts. NEW: **idle demotion**
12
- (`--ssd-demote-idle`, default 300 s with the tier on) — entries unused past
13
- the threshold spill to disk and free their GPU arrays; RAM drains between
14
- agent bursts while prefixes stay reachable via zero-copy restore. The sweep
15
- runs only when the gateway is fully idle (a runExclusive would otherwise
16
- drain a live batch). Rationale (Josh): prefill is the cost being avoided —
17
- 12B prefill is 168 tok/s, so a 30k agent context is ~3 min of GPU compute vs
18
- a ~1 s NVMe restore; SSD-vs-RAM (40×) is the wrong comparison, SSD-vs-
19
- recompute (50–180×, growing with context) is the real one.
20
-
21
- **Measured (M1 Max, MiniCPM5, 13.7k-token prefix, kv_config quant):**
22
- - Restart TTFT: **12,083 ms (full prefill) → 236 ms** restored — 2% of full
23
- prefill, 13,675 tokens back via a 3 ms zero-copy mmap. Beats oMLX's 1–3 s
24
- claim and both exit criteria (<2.5 s, ≤10%).
25
- - Steady-state decode: **0% overhead** (192.0 vs 192.3 tok/s, flag off/on) —
26
- nothing runs on the token loop, as designed (vs oMLX's measured ~20%).
27
- - Correctness: SSD-restored + trimmed continuation is **identical to the RAM
28
- tier's cache-hit output** (control experiment); divergence from a fresh
29
- full prefill is the pre-existing bf16 prefix-reuse property both tiers
30
- share, not introduced by persistence.
31
-
32
- What landed: kv-store v2 (`src/kv-store.ts` — all five cache kinds incl.
33
- quantized + SSM, invalidation metadata, per-tensor hashes, atomic writes;
34
- `restoreState` added to both quantized cache classes), `src/ssd-cache.ts`
35
- (SsdCacheStore per D4), tier integration (`src/prompt-cache.ts` spill hook +
36
- retain thunks; `src/server.ts` tiered take with post-restore trim,
37
- debounced write-behind snapshots under the gateway lock, `/stats.ssd_cache`),
38
- flags `--ssd-cache <dir>` / `--ssd-cache-max <GB>` / `--ssd-cache-verify`.
39
- Tests: `tests/kv-store.test.ts` (fork-compare token-identical for bf16,
40
- quantized, SSM), `tests/ssd-cache.test.ts` (restart recovery, quarantine,
41
- corruption, eviction, supersede). One deviation from the plan below: find()
42
- returns PARTIAL prefix matches and the server trims after restore
43
- (divergent-tail conversations — full-entry-only missed the real pattern). Inspired by oMLX's paged SSD cache
44
- (Apache 2.0 — `omlx/cache/paged_ssd_cache.py`, `boundary_snapshot_store.py`;
45
- we port ideas, not code — add a `THIRD_PARTY_LICENSES.md` note if any code
46
- ends up derived). Their headline: coding-agent TTFT 30–90 s → 1–3 s by
47
- restoring long prefixes from SSD across requests AND restarts. Their
48
- measured costs (independent tests): ~2–3× cold TTFT and ~20% steady-state
49
- decode overhead — this design exists to get the win without those costs.
50
- Flag-gated, off by default.
51
-
52
- **Head start**: `src/kv-store.ts` already implements page-aligned KV
53
- persistence (`MLXBUNKV1`, 16 KiB-aligned tensors) with zero-copy COW mmap
54
- reload (`MmapFile.open(path, "cow")` → `MlxArray.fromPointer` — exactly
55
- the Metal page-alignment constraint, proven by expert-offload), including
56
- mid-rotation `RotatingKVCache` restore (`restoreState`,
57
- `src/model/gemma4-base.ts:880`). `scripts/experiments/cold-start.ts` is a
58
- working sub-1 s cold-restore harness. The SSD tier is wiring +
59
- quantized-cache support + an index/eviction/recovery layer.
60
-
61
- ## Current state / gaps
62
-
63
- - `src/prompt-cache.ts`: byte-capped LRU RAM tier (default 2 GB,
64
- `--prompt-cache`), prefix-entry based, adapter-namespaced, serial lane
65
- only, race-free under the gateway lock. **A restart loses everything.**
66
- - `kv-store.ts` gaps: (1) no quantized kinds — `saveKvCache` throws for
67
- `QuantizedKVCache`/`RotatingQuantizedKVCache` and neither has
68
- `restoreState`, yet serving defaults to the model's `kv_config.json`
69
- scheme, so the default production cache can't persist today; quantized
70
- state is 6 arrays/layer (packed u32 + scales/biases bf16 × K/V);
71
- (2) no invalidation metadata / checksums / atomic writes; (3) latent
72
- bug: `loadKvCache` checks `caches.length !== model.layers.length` but
73
- `makeCache()` returns donor layers only — wrong for KV-shared e4b
74
- (verify in Phase 1).
75
- - Versioning primitive exists: `configFingerprint`
76
- (`src/model/fingerprint.ts`) — sha256 over graph-shaping config incl.
77
- `kvQuant`.
78
-
79
- ## Design decisions
80
-
81
- **D1 — Whole-prefix-entry spill, not paged blocks.** oMLX pages for
82
- multi-user shared-prefix dedup; we are single-user with a prefix-entry
83
- cache that grows monotonically per conversation. Paging is exactly where
84
- their overheads come from: per-block SHA256 + save-queue bookkeeping
85
- *during decode* (their ~20% tax) and block-granular safetensors restore
86
- with copies (their 2–3× cold TTFT). Ours: **zero work on the token loop**
87
- (prefix tokens ARE the key), spill/persist only at request boundaries and
88
- evictions, restore = one zero-copy mmap with lazy page fault-in (~5 GB/s
89
- NVMe; 2 GB entry ≈ 0.4 s amortized into the first forward). Partial-prefix
90
- reuse works without paging: restore + `Cache.trim()`. Revisit paging only
91
- for multi-user batch dedup (v2, out of scope).
92
-
93
- **D2 — One store, two tiers.** `PromptCache` grows a cold tier behind the
94
- same `take`/`put` API:
95
- - RAM→SSD: `put()`'s evict loop spills (snapshot bytes → async writer →
96
- dispose GPU arrays) instead of just disposing.
97
- - SSD→RAM: `take()` prefers a strictly-longer SSD prefix; restored entry
98
- carries its `MmapFile`, unmapped on entry dispose (extensions copy into
99
- fresh anonymous buffers via concat-grow anyway).
100
- - **Restart persistence needs write-behind at request completion**, not
101
- just spill-on-evict: after `promptCache.put(...)` in `runGeneration`
102
- (`src/server.ts:1019`), a debounced, coalesced-per-ns async snapshot.
103
- Byte extraction (the only GPU-thread work) runs on the idle serial lane
104
- between requests; file I/O is async. (oMLX's boundary-snapshot idea at
105
- whole-entry granularity.)
106
-
107
- **D3 — Extend the existing format to `MLXBUNKV2`**, not safetensors
108
- (safetensors offsets aren't page-aligned; oMLX pays a copy via `mx.load`).
109
- Add: `"qkv"`/`"rotating-qkv"` kinds (six tensor slots + groupSize/bits;
110
- slice quantized buffers to `[.., :offset, :]` — don't persist 256-step
111
- slack), header fields `formatVersion`/`modelId`/`configFingerprint`/`ns`/
112
- `tokenizerHash`/`createdAt`, per-tensor checksums + header checksum.
113
- Integrity: header checksum always verified on scan; full verification
114
- opt-in (`--ssd-cache-verify`) because eager reads destroy lazy fault-in;
115
- default trusts atomic writes (tmp + fsync + rename). Corrupt/incompatible
116
- → delete + log, never fatal. `loadKvCache` becomes model-generic
117
- (accept `{ makeCache(): Cache[] }`, donor-count check).
118
-
119
- **D4 — Files are the database.**
120
- `<ssd-dir>/<configFingerprint>/<nsHash>/<uuid>.mlxkv`. Startup recovery =
121
- header-only directory scan → in-memory `SsdIndex`
122
- (`{ns, tokens, path, bytes, mtimeMs}[]`); tokens live in the header JSON.
123
- LRU = file mtime (`utimes()` on hit); byte cap enforced at write time by
124
- oldest-mtime eviction; disk-full → drop write, warn once, keep serving.
125
-
126
- **D5 — Flags & interactions.** `--ssd-cache <dir>` +
127
- `--ssd-cache-max <GiB>` (default 32) → `ServerOptions.ssdCacheDir/MaxBytes`.
128
- Requires the RAM tier (`--prompt-cache 0` + `--ssd-cache` = startup
129
- error). `--memory-budget` unchanged (restored caches are clean file-backed
130
- pages until dirtied; admission already assumes worst-case bf16). `/status`
131
- gains `ssd: { entries, bytes, max_bytes, restores, spills, restore_ms_last }`.
132
- v1 is serial-lane only (where `PromptCache` lives); batch-lane interplay is
133
- docs/design/batching-perf-path.md P3's problem.
134
-
135
- ## Phases
136
-
137
- - **P0 — Baseline bench (½ d)**: `scripts/experiments/ssd-cache-bench.ts`
138
- grown from `cold-start.ts`: ≥16k-token prefix on the standard snapshot,
139
- kv_config quant active; measure full-prefill TTFT / warm RAM-hit TTFT /
140
- steady-state decode. Exit: baseline table committed.
141
- - **P1 — kv-store v2 (2–3 d, highest uncertainty)**: `restoreState` on
142
- both quantized cache classes; all four kinds in save/load; header v2;
143
- atomic writes; model-generic load. Exit: round-trip tests all kinds
144
- (incl. wrapped ring) + **greedy decode parity** — N tokens continued
145
- from a restored cache bit-exact vs never-persisted; `tsc` 0.
146
- - **P2 — `src/ssd-cache.ts` store (1–2 d)**: `SsdCacheStore
147
- { scan, find(prompt, ns), store (async, extraction split from I/O),
148
- evictToCap, remove, stats }`. Exit: unit tests — restart recovery,
149
- incompatible-fingerprint quarantine, corrupt-header deletion, mtime cap
150
- eviction, disk-full soft-fail.
151
- - **P3 — Tier integration + flags (2–3 d)**: cold tier in `PromptCache`,
152
- debounced end-of-request snapshots, CLI/`/status`. **Exit (headline)**:
153
- (1) restart TTFT on a 16k prefix **< 2.5 s and ≤ 10% of full-prefill**
154
- (beat oMLX's 1–3 s; a true-cold miss runs the unchanged prefill path);
155
- (2) steady-state decode overhead **< 2%** vs flag off (vs their ~20%);
156
- (3) flag absent ⇒ zero behavior change.
157
- - **P4 — Hardening + docs (1–2 d)**: kill-during-write crash test,
158
- adapter-ns isolation, kv-quant-scheme-change invalidation, README/help,
159
- license note. Exit: suite green; on/off × cold/warm/restart matrix
160
- recorded.
161
-
162
- Total ~7–10 working days.
163
-
164
- ## Risks
165
-
166
- - **mmap lifetime vs mlx laziness**: unmap only in entry `dispose()` after
167
- successor state is evaluated; COW mapping already covers buffer donation.
168
- - **Byte-extraction stall on multi-GB entries**: runs between requests on
169
- the idle lane; measure in P3; chunk with yields if needed.
170
- - **COW dirty-page accounting**: extended restored caches migrate to
171
- anonymous memory — probe with `phys_footprint` (expert-offload
172
- discipline) before making footprint claims.
173
- - **Stale-token poisoning**: `configFingerprint` + `tokenizerHash` + ns;
174
- chat-template drift degrades to a shorter matched prefix (safe).
175
-
176
- ## Addendum 2026-07-07 — A7 closure: bounded memory on BOTH sides of the file
177
-
178
- The 07-06/07 serve benches flagged elevated ctx/restart-leg RSS on
179
- --ssd-cache arms ("A7"). Root-caused in three parts; the first two were
180
- real defects, both fixed in `src/kv-store.ts`:
181
-
182
- - **Write side** (residual after the v3 streaming writer of 4f9bad9): the
183
- per-tensor `rawBytes()` readback ended in a JS-heap `.slice()`; under GC
184
- lag the dead copies outlive the flush loop, re-spiking RSS toward the
185
- whole entry. The writer now hashes and writes from a ZERO-COPY view of
186
- the contiguous mlx buffer (`MlxArray.rawBytesView`) — no JS-heap copy
187
- exists at any point; save-transient mlx memory measured 0 bytes for
188
- contiguous sources (one tensor otherwise).
189
- - **Restore side** (never covered by A7's original scope): the zero-copy
190
- restore (COW mmap + `fromPointer`) became a per-restore PROCESS-LIFETIME
191
- mapping leak once the 2026-07-06 FFI-dtor fix removed the unmap signal,
192
- and the exactly-offset-sized restored buffers made the first
193
- post-restore decode step concat-copy the entire entry into fresh mlx
194
- memory. Restore is now a **streamed copy** — each tensor is copied into
195
- an mlx-owned leaf (`fromBytesCopy`, no dtor contract), its clean file
196
- pages dropped (`MADV_DONTNEED`; tensor offsets are 16 KiB-aligned = the
197
- arm64 page size), the mapping unmapped before `loadKvCache` returns, and
198
- plain-KV tensors land in STEP-rounded capacity with ≥1 token of slack so
199
- the first step updates in place. Measured (512 MB synthetic entry): mlx
200
- peak during restore+first-step = live entry + ONE tensor (552 vs 520 MB
201
- active); `vmmap` confirms no `.mlxkv` mapping survives; 12B real-model
202
- cold cache-load → first token 277 ms (parity with the old zero-copy
203
- ~240 ms claim — the old path paid the same whole-entry copy on the first
204
- decode step anyway). Byte identity of the copy-restore is pinned for all
205
- five cache kinds (save → load(verify) → re-save hash-identical;
206
- tests/kv-store.test.ts "copy-restore byte identity") plus the real-model
207
- bf16/quantized/SSM continuation suites.
208
- - **What was NOT a defect**: most of the benched leg delta is `ps` RSS
209
- ACCOUNTING — the write-behind's hash+write CPU-touches the live KV
210
- entry, which makes those already-allocated unified-memory pages visible
211
- to `ps` (GPU-written buffers and python-arm KV never show). The bench
212
- report now footnotes this instead of promising a pending fix.
213
-
214
- Supersedes the D2 line "restored entry carries its MmapFile, unmapped on
215
- entry dispose" and the D1 "restore = one zero-copy mmap with lazy page
216
- fault-in" mechanics above — the economics (SSD-vs-recompute) and the
217
- zero-work-on-token-loop property are unchanged.
218
-
219
- ## Addendum 2026-07-07 — write-behind scheduling contract: flush only while idle
220
-
221
- The 07-07 serve bench showed decode@ctx losing to mlx-lm on the bun arms
222
- (e4b −9.3%, 12B −3.9%) while short-ctx decode won on every model — and the
223
- mixed arm (4× smaller flush bytes) BEAT the bf16 arms at decode@ctx. Root
224
- cause: "non-blocking" write-behind was only non-blocking at the event-loop
225
- level. Every per-tensor flush step is (1) `ops.contiguous` — a kernel on
226
- the SAME GPU stream decode uses — then (2) `rawBytesView()` → a synchronous
227
- `mlx_array_eval` that blocks the JS thread until the stream drains, then
228
- (3) a synchronous multi-MB `writeSync`. The `setImmediate` pacing
229
- interleaved those slices exactly between decode tokens, so a ~16k entry's
230
- flush overlapping the bench's cached ctx repeats taxed their 64-token
231
- decode windows (reproduced standalone: e4b 9.5k-token entry, repeat decode
232
- 37.9 vs cold 47.1 tok/s; flat 44–46 across all three samples post-fix).
233
- mlx-lm runs no equivalent background work — the gap was pure self-inflicted
234
- contention.
235
-
236
- **The contract now:** the flush only progresses while the engine is idle.
237
- - `GenerationGateway.busy` covers BOTH lanes (mutex held/awaited — the
238
- serial lane shows zero rows — OR batch rows active/pending);
239
- `gateway.onIdle()` is the poll-based waiter (`generation-gateway.ts`).
240
- - `saveKvCacheAsync`/`SsdCacheStore.storeAsync` accept a per-step
241
- `waitTurn` gate awaited before EVERY tensor step (including the first),
242
- so a request arriving MID-flush pauses the remaining tensors until idle
243
- again; the server passes `() => gateway.onIdle()` at both chain sites
244
- (write-behind snapshots AND eviction/demotion spills). Gate failures are
245
- swallowed — scheduling advice must never corrupt the write path.
246
- - `MLX_BUN_SSD_WRITEBEHIND=0` disables write-behind snapshots entirely
247
- (kill switch + paired-A/B lever; restart survival then degrades to
248
- spill-on-evict only).
249
-
250
- Accepted tradeoffs: durability waits for a quiet moment (single-user
251
- serving quiesces constantly; sustained hammering defers the flush AND the
252
- spill clones' GPU-memory release, bounded by the serial chain); one
253
- in-flight tensor step (~10–15 MB) can still land ahead of a just-arrived
254
- request; and the restart window tightens — the flush now runs in idle gaps
255
- instead of overlapping decodes, so the bench's 2.5 s pre-kill beat must
256
- absorb it (verified surviving: e4b 9.5k entry, restart cached=9575 with
257
- the gate on).
258
-
259
- ## Addendum 2026-07-07 (later) — bounded retention + admission-safe snapshots
260
-
261
- The post-merge review found two operational holes in the contract above:
262
-
263
- 1. **Snapshot timer could stall batch admission.** `scheduleSsdSnapshot`'s
264
- debounced timer fired `gateway.runExclusive` unconditionally; the
265
- exclusive registers a serial WAITER, which holds batch admission until
266
- every running row drains naturally — a background durability timer
267
- periodically freezing admission under `--batch N` sustained load. Fixed
268
- with the same guard `demoteIdle` always had: the timer checks
269
- `activeRows/pendingRows` first and RE-ARMS (5 s, unref'd) while busy —
270
- entries are immutable, a late snapshot is equally valid.
271
- 2. **Starved-gate clone retention was unbounded.** Every eviction/demotion/
272
- snapshot chained GPU-pinning zero-copy clones onto a bare serial promise
273
- chain, and every flush step awaits `onIdle()` — under continuous traffic
274
- the chain starves and resident memory = prompt-cache cap + every queued
275
- clone, defeating the byte cap exactly under the load causing evictions.
276
- Now all three producers go through **`SpillQueue`** (kv-store.ts):
277
- queued bytes are capped (default 2 GB = a quarter of the 8 GB RAM-cache
278
- default; `MLX_BUN_SSD_SPILL_QUEUE_GB` overrides) — over cap the OLDEST
279
- not-mid-store item drops with its clones disposed immediately. A dropped
280
- spill is a future cache miss, never a wrong result: the contention-free
281
- alternative to letting the flush cut into decode. `/stats.ssd_cache`
282
- exposes `pending_spills` / `pending_spill_bytes` / `dropped_spills`.
283
-
284
- Explicitly accepted (documented, not built): **no shutdown flush** — a
285
- SIGINT/SIGTERM during (or within the debounce after) traffic loses queued
286
- write-behind entries; restart survival degrades to whatever already
287
- flushed. This is a best-effort cache tier; the cost of a lost entry is one
288
- re-prefill. `SpillQueue.drain()` exists if a shutdown hook is ever wanted.
289
-
290
- This acceptance was superseded by the 2026-08-22 durability fix below.
291
-
292
- ## Bug brief 2026-08-22: restart can outrun SSD write-behind
293
-
294
- ### User-visible failure
295
-
296
- A completed long-context request can report a full RAM-cache hit on repeat,
297
- then lose that prefix when the server restarts a few seconds later. The next
298
- request pays a full prefill even though `--ssd-cache` was enabled.
299
-
300
- The 2026-08-22 M1 Max serve matrix reproduced this with the standard restart
301
- leg, which waits 2.5 seconds after the long request before sending SIGTERM:
302
-
303
- | model and mode | cached tokens after restart | expected |
304
- |---|---:|---:|
305
- | MiniCPM5, unified bf16 | 15,817 | about 15.8k |
306
- | e4b, unified bf16 | 4 | about 15.9k |
307
- | e4b, serial bf16 | 15,940 | about 15.9k |
308
- | 12B, unified bf16 | 4 | about 15.9k |
309
- | 12B, serial bf16 | 15,866 | about 15.9k |
310
- | Qwen3.8, unified bf16 | 0 | about 15.1k |
311
- | Qwen3.8, serial bf16 | 0 | about 15.1k |
312
- | Qwen3.8, unified mixed KV | 15,112 | about 15.1k |
313
-
314
- The Bun 1.4 rerun reproduced the lane split before this brief was written:
315
- e4b unified restored 2 of 15,942 tokens, while e4b serial restored 15,939.
316
- The completed run also restored only 4 tokens for unified 12B versus 15,794
317
- for serial 12B, and 5 for mixed-KV 12B. Qwen bf16 restored zero in both
318
- lanes, while mixed-KV Qwen restored all 15,114 tokens. This is not a Bun
319
- 1.3-only result.
320
-
321
- ### Expected contract
322
-
323
- After a request completes and the server has reported the cache durable, a
324
- normal SIGTERM and restart must restore the longest compatible prefix. For
325
- the benchmark's exact-repeat request, `cached_tokens` should be at least
326
- `prompt_tokens - 1`. The server must never imply durability while a matching
327
- snapshot is still waiting on its debounce timer or the spill queue.
328
-
329
- ### Evidence and current diagnosis
330
-
331
- The read and reconstruction path works when the file reaches disk. MiniCPM,
332
- serial e4b and 12B, and mixed-KV Qwen all restore a full prefix through the
333
- same `PromptCache.take()` and `SsdCacheStore.restore()` path. That makes a
334
- model-format or prefix-matching defect unlikely as the primary cause.
335
-
336
- The likely failure is the durability boundary:
337
-
338
- 1. `PromptCache.onPut` schedules a snapshot with a one-second debounce.
339
- 2. A busy gateway re-arms that timer for five seconds.
340
- 3. The snapshot then joins a serial `SpillQueue`; large bf16 entries can still
341
- be queued or in flight when the benchmark's 2.5-second wait expires.
342
- 4. SIGTERM stops the process without forcing pending timers to fire or awaiting
343
- `SpillQueue.drain()`. The queue's own comment calls drain a future shutdown
344
- hook.
345
-
346
- Entry size explains the broad pattern. Small MiniCPM and smaller mixed-KV Qwen
347
- files finish in time; large Qwen bf16 files do not. The remaining unified versus
348
- serial split needs telemetry before assigning a second root cause. The unified
349
- lane may enqueue more snapshots, take longer to extract row caches, or hit the
350
- queue cap. The current report does not capture that state before termination.
351
-
352
- ### Fix plan
353
-
354
- 1. Expose `pending_snapshots`, `pending_spills`, `pending_spill_bytes`,
355
- `dropped_spills`, and the longest durable prefix in `/stats.ssd_cache`.
356
- `pending_snapshots` must include debounce and busy-rearm timers, not only
357
- entries already placed in `SpillQueue`.
358
- 2. Add one async durability operation that cancels debounce timers, snapshots
359
- matching RAM entries under the gateway lock, and awaits
360
- `SpillQueue.drain()`. Return success only after the atomic rename completes.
361
- 3. Route graceful SIGINT/SIGTERM through that operation with a bounded timeout.
362
- Stop new admission first. A timeout may drop the cache, but must log the
363
- number of pending and dropped snapshots.
364
- 4. Change the benchmark restart leg to request or poll durability instead of
365
- sleeping for 2.5 seconds. A fixed sleep measures SSD timing luck, not the
366
- restart contract.
367
- 5. Capture pre-kill stats and the recovered entry count in every restart row so
368
- a write miss cannot masquerade as a restore miss again.
369
-
370
- ### Implemented 2026-08-22
371
-
372
- - `SsdDurabilityCoordinator` owns debounce, busy retry, queue completion, and
373
- explicit flush as one state machine. A snapshot stays dirty until the atomic
374
- store succeeds. Queue drops and write failures remain retryable.
375
- - Dirty keys include the exact token sequence, so two conversations with the
376
- same namespace and token count cannot cancel each other.
377
- - `POST /admin/cache/flush` returns `200` only at the durability boundary and
378
- exposes pending, dropped, failed, entry-count, and longest-prefix evidence.
379
- - `mlx-bun serve` handles `SIGINT` and `SIGTERM` by stopping admission,
380
- draining active requests, and awaiting the same flush, bounded by
381
- `MLX_BUN_SHUTDOWN_TIMEOUT_MS`.
382
- - The standard restart benchmark calls the endpoint instead of sleeping and
383
- records its evidence in the Markdown report and eval database notes.
384
- - Model-free regression coverage holds an atomic store open and proves flush
385
- waits, forces the busy-rearm path, retries a failed write, preserves
386
- equal-length conversations, and reports a missing RAM boundary honestly.
387
-
388
- Focused real-server smoke on unified e4b passed the repaired path: a
389
- 4,026-token prompt flushed in 408 ms with `durable=true`, zero pending,
390
- dropped, or failed work, then restored 4,025 cached tokens after SIGTERM and
391
- respawn. The machine failed the clean-performance preflight, so this is cache
392
- correctness evidence only, not a performance result. The full clean 16k
393
- e4b/12B/Qwen matrix remains the acceptance run.
394
-
395
- ### Acceptance tests
396
-
397
- - A deterministic coordinator test holds `storeAsync`, requests a flush,
398
- releases the store, and proves the durability boundary waits for the atomic
399
- file. The focused real-server smoke exercises the SIGTERM path through that
400
- same operation before restart.
401
- - A busy-gateway test forces the five-second re-arm path, then requests a flush
402
- and proves the timer becomes an immediate queued snapshot without blocking an
403
- active generation.
404
- - Batch 8 and `--batch 1` both restore at least `prompt_tokens - 1` for e4b,
405
- 12B, and Qwen3.8 at roughly 16k context.
406
- - The mixed-KV and SSM cache kinds retain their existing continuation parity.
407
- - Steady-state decode remains unchanged because normal writes still pause while
408
- the engine is busy. Queued clone memory stays within the spill cap.
409
-
410
- Focused reproduction:
411
-
412
- ```sh
413
- ./benchmark.sh --models e4b,12B,qwen27b \
414
- --arms mlx-bun,mlx-bun-serial,mlx-bun-mixed
415
- ```