mlx-bun 0.4.0 → 0.5.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 (158) hide show
  1. package/README.md +57 -32
  2. package/docs/README.md +55 -42
  3. package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
  4. package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
  5. package/docs/design/batching.md +220 -7
  6. package/docs/design/decode-speed-program.md +121 -66
  7. package/docs/design/generic-model-support.md +116 -0
  8. package/docs/design/kv-cache.md +380 -211
  9. package/docs/design/speculative-decoding.md +165 -26
  10. package/docs/design/turboquant.md +20 -7
  11. package/docs/design/unified-engine-frontier-plan.md +120 -13
  12. package/docs/planning/PRODUCT_ROADMAP.md +5 -6
  13. package/docs/planning/public-readiness-handoff.md +244 -0
  14. package/docs/planning/public-readiness.md +149 -0
  15. package/docs/reference/benchmarks.md +3463 -3489
  16. package/docs/reference/cli.md +62 -1
  17. package/docs/reference/distribution.md +29 -4
  18. package/docs/reference/environment.md +104 -8
  19. package/docs/reference/library-api.md +18 -4
  20. package/docs/reference/memory.md +1 -1
  21. package/docs/reference/models.md +17 -5
  22. package/docs/reference/server-api.md +181 -11
  23. package/docs/reference/server-config.md +232 -105
  24. package/package.json +2 -2
  25. package/src/audio/audiotoolbox.ts +112 -0
  26. package/src/audio/conformer.ts +8 -0
  27. package/src/audio/mic-capture.ts +98 -0
  28. package/src/audio/silero-vad.ts +379 -0
  29. package/src/audio/whisper-decode.ts +774 -0
  30. package/src/audio/whisper-fast.ts +732 -0
  31. package/src/audio/whisper-format.ts +73 -0
  32. package/src/audio/whisper-mel.ts +185 -0
  33. package/src/audio/whisper-timing.ts +274 -0
  34. package/src/audio/whisper-tokenizer.ts +239 -0
  35. package/src/audio/whisper-transcribe.ts +410 -0
  36. package/src/backends/mlx/autoregressive.ts +22 -1
  37. package/src/backends/mlx/batch-group.ts +111 -27
  38. package/src/backends/mlx/cache-layout.ts +3 -3
  39. package/src/backends/mlx/continuation-persistence.ts +5 -4
  40. package/src/backends/mlx/encoder-cache.ts +25 -0
  41. package/src/backends/mlx/fill-append.ts +21 -8
  42. package/src/backends/mlx/fill-group.ts +497 -0
  43. package/src/backends/mlx/gateway-binding.ts +56 -12
  44. package/src/backends/mlx/kv-maintenance.ts +30 -2
  45. package/src/backends/mlx/mixed-iteration.ts +62 -0
  46. package/src/backends/mlx/model-prompt.ts +46 -32
  47. package/src/backends/mlx/model-serving.ts +4 -3
  48. package/src/backends/mlx/paged-cache-codec.ts +27 -0
  49. package/src/backends/mlx/prefill-cohort.ts +15 -4
  50. package/src/backends/mlx/prefill-policy.ts +26 -0
  51. package/src/backends/mlx/prefill-rows.ts +57 -13
  52. package/src/backends/mlx/prompt-input.ts +29 -0
  53. package/src/backends/mlx/qwen-prompt-input.ts +40 -0
  54. package/src/backends/mlx/request-state-policy.ts +18 -5
  55. package/src/backends/mlx/rollback.ts +6 -3
  56. package/src/backends/mlx/serial-executor.ts +16 -7
  57. package/src/backends/mlx/speculative-group.ts +105 -26
  58. package/src/backends/mlx/speculative.ts +24 -7
  59. package/src/backends/mlx/target-layout-capability.ts +1 -1
  60. package/src/backends/mlx/transitioning-kv-rows.ts +8 -0
  61. package/src/cli.ts +322 -0
  62. package/src/contracts/execution.ts +10 -0
  63. package/src/contracts/object-cache.ts +8 -0
  64. package/src/contracts/pi-provider.ts +1 -1
  65. package/src/contracts/pi.ts +3 -1
  66. package/src/contracts/prefix-cache.ts +13 -3
  67. package/src/contracts/scheduling.ts +9 -0
  68. package/src/engine/execution-plan.ts +23 -12
  69. package/src/engine/execution-tasks.ts +44 -0
  70. package/src/engine/namespaced-cache.ts +13 -0
  71. package/src/engine/scheduler.ts +22 -2
  72. package/src/fill/fill-session.ts +14 -3
  73. package/src/generate.ts +28 -22
  74. package/src/grammar.ts +13 -0
  75. package/src/inference/prefill.ts +6 -0
  76. package/src/kv-scheme.ts +22 -0
  77. package/src/kv-store.ts +115 -79
  78. package/src/lab/README.md +5 -1
  79. package/src/lab/paged-kv/paged-attention.ts +117 -0
  80. package/src/lab/paged-kv/paged-kv-rows.ts +33 -5
  81. package/src/lab/paged-kv/paged-kv.ts +108 -42
  82. package/src/md-modules.d.ts +5 -0
  83. package/src/mlx/array.ts +37 -0
  84. package/src/mlx/ffi.ts +19 -0
  85. package/src/mlx/normalized-argmax.ts +116 -0
  86. package/src/mlx/ops.ts +97 -10
  87. package/src/model/batched-turboquant-kv.ts +6 -5
  88. package/src/model/delayed-quantized-kv.ts +3 -3
  89. package/src/model/delayed-rotating-quantized-kv.ts +1 -1
  90. package/src/model/delayed-turboquant-kv.ts +6 -4
  91. package/src/model/factory.ts +26 -0
  92. package/src/model/fingerprint.ts +12 -0
  93. package/src/model/full-prefill-row.ts +1 -1
  94. package/src/model/gemma4-base.ts +25 -6
  95. package/src/model/gemma4.ts +82 -2
  96. package/src/model/kv-attention-view.ts +4 -1
  97. package/src/model/profile.ts +29 -4
  98. package/src/model/quantized-append-attention.ts +31 -0
  99. package/src/model/qwen3-mrope.ts +7 -9
  100. package/src/model/qwen3_5.ts +99 -37
  101. package/src/model/support.ts +8 -0
  102. package/src/model/token-groups.ts +66 -0
  103. package/src/model/trellis-linear.ts +2 -2
  104. package/src/model/turboquant-codec.ts +6 -0
  105. package/src/model/whisper.ts +435 -0
  106. package/src/native/mic_capture.swift +92 -0
  107. package/src/native-pack.ts +6 -5
  108. package/src/pi-web.ts +9 -1
  109. package/src/prompt-cache.ts +265 -43
  110. package/src/runtime-config.ts +1 -5
  111. package/src/safetensors.ts +9 -2
  112. package/src/sampler.ts +5 -4
  113. package/src/serve/audio-routes.ts +323 -0
  114. package/src/serve/chat-request.ts +2 -0
  115. package/src/serve/chat-stage.ts +17 -12
  116. package/src/serve/completion-executor.ts +9 -1
  117. package/src/serve/discovery-routes.ts +17 -1
  118. package/src/serve/generated-token-history.ts +57 -0
  119. package/src/serve/generation-gateway.ts +52 -11
  120. package/src/serve/model-binding.ts +7 -1
  121. package/src/serve/preparation.ts +3 -3
  122. package/src/serve/prompt-response-trace.ts +14 -0
  123. package/src/serve/request-plan.ts +2 -0
  124. package/src/serve/request-prep.ts +38 -21
  125. package/src/serve/transcription-server.ts +64 -0
  126. package/src/serve/transcription-service.ts +502 -0
  127. package/src/server.ts +132 -164
  128. package/src/spec/assistant-rows.ts +1 -0
  129. package/src/spec/deepspec-rows.ts +1 -1
  130. package/src/spec/dflash-source.ts +1 -1
  131. package/src/spec/glm52-mtp-source.ts +1 -0
  132. package/src/spec/ngram-source.ts +27 -5
  133. package/src/spec/projected-draft-rows.ts +2 -2
  134. package/src/spec/qwen-mtp-rows.ts +11 -0
  135. package/src/spec/qwen-mtp-source.ts +3 -0
  136. package/src/spec/serve-loop.ts +5 -3
  137. package/src/spec/source.ts +14 -1
  138. package/src/spec/two-model-rows.ts +1 -0
  139. package/src/spec/two-model.ts +2 -2
  140. package/src/ssd-cache.ts +76 -17
  141. package/src/ssd-durability.ts +20 -22
  142. package/src/storage/host-buffer.ts +24 -0
  143. package/src/storage/kv-writer.ts +83 -0
  144. package/src/storage/kv-writer.worker.js +264 -0
  145. package/src/storage/retention-policy.ts +37 -0
  146. package/src/tiered-prompt-cache.ts +45 -0
  147. package/src/vision/embedder.ts +8 -0
  148. package/src/vision/prompt.ts +101 -65
  149. package/src/vision/qwen3vl-prompt.ts +101 -64
  150. package/src/vision/qwen3vl-tower.ts +11 -0
  151. package/src/vision/siglip.ts +8 -0
  152. package/src/web/app.html +12 -0
  153. package/src/web/app.js +160 -0
  154. package/src/web/src/chat.ts +4 -1
  155. package/src/web/src/composer.ts +3 -0
  156. package/src/web/src/voice.ts +141 -0
  157. package/src/web/sw.js +1 -1
  158. /package/docs/{planning → archive/planning}/release-notes-v0.4.0.md +0 -0
@@ -20,7 +20,7 @@
20
20
  // sources in priority order, clamps what they return, and counts. It never
21
21
  // touches MLX, the tokenizer, or the template. Row COMPILATION is
22
22
  // ./schema-rows.ts.
23
- import { flagOn, runtimeNumber, runtimeValue } from "../runtime-config";
23
+ import { runtimeFlag, runtimeNumber, runtimeValue } from "../runtime-config";
24
24
  import { EchoSource, type EchoConfig } from "./echo-index";
25
25
  import type {
26
26
  Proposal, ProposalOrigin, ProposalPolicy, ProposalSource, TokenView,
@@ -153,7 +153,7 @@ export function fillEchoConfig(): FillEchoConfig {
153
153
  * the engine's fill path. Off by default (the invariant it checks is the
154
154
  * easiest bug in the feature, and the check costs a JS compare). */
155
155
  export function fillTraceEnabled(): boolean {
156
- return flagOn("MLX_BUN_FILL_TRACE", false) || fillTracePath() !== null;
156
+ return runtimeFlag("MLX_BUN_FILL_TRACE", false) || fillTracePath() !== null;
157
157
  }
158
158
 
159
159
  /** MLX_BUN_FILL_TRACE=<file.jsonl> — additionally append one record per
@@ -319,12 +319,23 @@ export class FillSession {
319
319
  /** Feed one SAMPLED token, then ask the sources what follows. `budget` is
320
320
  * the caller's remaining allowance (max_tokens − generated). */
321
321
  push(tokenId: number, budget: number = Number.POSITIVE_INFINITY): Proposal | null {
322
+ this.observe(tokenId);
323
+ return this.propose(budget);
324
+ }
325
+
326
+ /** Record sampled output independently of when the method requests proposals. */
327
+ observe(tokenId: number): void {
322
328
  this.stats.decodeSteps++;
323
329
  this.#append([tokenId]);
330
+ }
331
+
332
+ /** Select a continuation after a committed output boundary. Methods may
333
+ * request only verified proposals while retaining ordinary sampled policy. */
334
+ propose(budget: number = Number.POSITIVE_INFINITY, policy?: ProposalPolicy): Proposal | null {
324
335
  this.#budget = budget;
325
336
  for (const source of this.#sources) {
326
337
  const proposal = source.propose(this);
327
- if (!proposal) continue;
338
+ if (!proposal || (policy && proposal.policy !== policy)) continue;
328
339
  if (proposal.branchStop) this.stats.branchStops++;
329
340
  const clamped = this.#clamp(proposal.ids, budget, proposal.origin === "echo");
330
341
  if (!clamped) continue;
package/src/generate.ts CHANGED
@@ -27,7 +27,7 @@ import { denoiseAsync } from "./diffusion/diffusion-generate";
27
27
  import { bindLegacyDenoisingModel, type MlxDenoisingBinding } from "./backends/mlx/diffusion";
28
28
  import type { RuntimeModel } from "./model/factory";
29
29
  import {
30
- assertMlxAutoregressiveBinding, bindLegacyAutoregressiveModel,
30
+ assertMlxAutoregressiveBinding, bindLegacyAutoregressiveModel, supportsCommittedAppendCache,
31
31
  type MlxAutoregressiveBinding, type MlxModelMemory, type MlxDecodeStep, type MlxTokenAppend,
32
32
  } from "./backends/mlx/autoregressive";
33
33
  import { createKvMaintenance } from "./backends/mlx/kv-maintenance";
@@ -61,6 +61,8 @@ import {
61
61
  } from "./fill/fill-session";
62
62
 
63
63
  export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions {
64
+ /** Application session affinity; independent of numerical cache identity. */
65
+ cacheSessionId?: string;
64
66
  /** Resolved host policy. Direct compatibility calls resolve from their
65
67
  * captured binding when this is absent. */
66
68
  decodePolicy?: Readonly<Pick<import("./contracts/execution").ResolvedExecution, "compiledDecode" | "grammarJump">>;
@@ -201,17 +203,16 @@ export function shouldUseGrammarJump(
201
203
  }
202
204
 
203
205
  /** Composition gate for token fast-forwarding, enforced inside the engine
204
- * (the serve layer refuses more shapes it alone can see — a user-fixed seed,
205
- * a mounted draft model, continuous placement: src/serve/request-prep.ts,
206
- * src/serve/chat-stage.ts). Throws when MLX_BUN_FILL names the unbuilt echo
207
- * tier — an operator who asked for K3c must not get K3b silently.
206
+ * The serving planner owns request-level eligibility, including explicit
207
+ * seeds in serial execution and mounted draft models. Shared strict fill
208
+ * has its own grouped method and uses the same append-format declaration.
208
209
  * - grammar: forced-token production is the grammar's job; two producers in
209
210
  * one iteration is forbidden (asserted in the loop).
210
211
  * - logprobs/top_logprobs: injected tokens are never sampled, so they have
211
212
  * no distribution row (same rule as shouldUseGrammarJump).
212
213
  * - promptEmbeddings: vision/audio prompts (and mRoPE) — untested shape.
213
- * - kvBits/kvConfig/turboQuant: post-conversion multi-token append is
214
- * L-generic but unvalidated; refuse in v1 rather than pay it silently. */
214
+ * - affine KV: the model's append binding declares its supported formats.
215
+ * Verify-policy quantized appends remain a separate gate. */
215
216
  export function shouldUseFill(
216
217
  options: Pick<
217
218
  GenerateOptions,
@@ -219,6 +220,7 @@ export function shouldUseFill(
219
220
  | "kvBits" | "kvConfig" | "turboQuant"
220
221
  >,
221
222
  runtime: RuntimeConfig = runtimeConfig(),
223
+ append?: Pick<MlxTokenAppend, "affineKvBits" | "turboQuantFormats"> | null,
222
224
  ): boolean {
223
225
  if (!options.fill) return false;
224
226
  if (resolveFillMode(runtime.value("MLX_BUN_FILL") ?? "off") === "off") return false;
@@ -226,9 +228,7 @@ export function shouldUseFill(
226
228
  !options.logprobs &&
227
229
  !(options.topLogprobs && options.topLogprobs > 0) &&
228
230
  options.promptEmbeddings === undefined &&
229
- !options.kvBits &&
230
- !options.kvConfig?.length &&
231
- options.turboQuant === undefined;
231
+ supportsCommittedAppendCache(append, options);
232
232
  }
233
233
 
234
234
  /** Compatibility entry point for callers that perform a single conversion.
@@ -599,7 +599,7 @@ async function* generateInner(
599
599
  const {
600
600
  maxTokens = 512,
601
601
  eosTokenIds = binding.eosTokenIds,
602
- prefillChunkSize = 2048,
602
+ prefillChunkSize = binding.prefillPolicy?.chunkSize(promptTokens.length) ?? 2048,
603
603
  } = options;
604
604
 
605
605
  try {
@@ -641,6 +641,7 @@ async function* generateInner(
641
641
  const ownsCache = !options.cache;
642
642
  const resuming = options.initialPendingToken !== undefined;
643
643
  let cache: Cache[] = options.cache ?? [];
644
+ let appender: MlxTokenAppend | null = null;
644
645
  try {
645
646
  if (ownsCache) cache = binding.makeCache();
646
647
  if (!cache.length) throw new Error("AR binding returned an empty cache");
@@ -655,6 +656,9 @@ async function* generateInner(
655
656
  // Replace fresh full-attention caches before any forward. The deepest
656
657
  // write is prompt + maxTokens - 1, so capacity covers every decode step.
657
658
  maybePageKv(cache, options, promptTokens.length + maxTokens);
659
+ appender = options.fill ? binding.createAppend?.({
660
+ hasAdapters: !!options.adapters?.length, pagedKv: !!options.pagedKv,
661
+ }) ?? null : null;
658
662
  } catch (error) {
659
663
  if (ownsCache) for (const state of cache) state.dispose();
660
664
  stepSampler.dispose();
@@ -668,7 +672,7 @@ async function* generateInner(
668
672
  // window) layers append multi-token writes through #updateConcat — O(window)
669
673
  // per append rather than the O(L) a plain ring pays — so v1 warns and skips
670
674
  // the whole feature for those models rather than paying it silently.
671
- let fillOn = shouldUseFill(options, runtime);
675
+ let fillOn = shouldUseFill(options, runtime, appender);
672
676
  if (fillOn && cache.some((c) => c instanceof RotatingKVCache)) {
673
677
  fillOn = false;
674
678
  if (!warnedFillRotating) {
@@ -688,7 +692,7 @@ async function* generateInner(
688
692
  // restore a pre-round snapshot and bit-exactly replay the accepted prefix.
689
693
  // A model whose caches can do neither still gets assert-policy fills; verify
690
694
  // proposals are dropped and counted (stats.verifyUnsupported).
691
- const verifyCapable = fillOn && cache.every(
695
+ const verifyCapable = fillOn && !options.kvBits && !options.kvConfig?.length && !options.turboQuant && cache.every(
692
696
  (c) => c.isTrimmable() || typeof rewindable(c).specRoundRollback === "function",
693
697
  );
694
698
  closeBatchSetup?.();
@@ -720,7 +724,6 @@ async function* generateInner(
720
724
  let pendingExtras: StepExtras | null = null;
721
725
  let nextExtras: StepExtras | null = null;
722
726
  let decoder: MlxDecodeStep | null = null;
723
- let appender: MlxTokenAppend | null = null;
724
727
  let finished = false;
725
728
  let threw = false;
726
729
  let executionError: unknown;
@@ -823,8 +826,7 @@ async function* generateInner(
823
826
  nextPending = null;
824
827
  disposeStepExtras(nextExtras);
825
828
  nextExtras = null;
826
- // No-op under the fill exclusions (kv quant refuses fill); kept so the
827
- // boundary stays correct if those exclusions ever loosen.
829
+ // Conversion runs at the ordinary committed-token boundary as well.
828
830
  maintainKv(cache);
829
831
 
830
832
  let checkpointMs = 0;
@@ -840,11 +842,18 @@ async function* generateInner(
840
842
  let logitsAll: MlxArray | null = null;
841
843
  try {
842
844
  const chunkSize = (state: readonly Cache[]) => {
843
- const maxChunk = appender?.maxChunkSize(state) ?? 1;
845
+ const maxChunk = Math.min(appender?.maxChunkSize(state) ?? 1,
846
+ maintainKv.maxAppendTokens?.(state) ?? Number.POSITIVE_INFINITY);
844
847
  return fill.appendChunkSize > 0 ? Math.min(fill.appendChunkSize, maxChunk) : maxChunk;
845
848
  };
849
+ const committedGraph = appender ? appender.forwardHidden.bind(appender) : graph.forwardHidden.bind(graph);
850
+ const forwardCommitted = maintainKv.maxAppendTokens ? async (ids: MlxArray, state: Cache[]) => {
851
+ const hidden = await committedGraph(ids, state);
852
+ try { maintainKv(state); return hidden; }
853
+ catch (error) { hidden.dispose(); throw error; }
854
+ } : committedGraph;
846
855
  hf = await appendFillHidden(
847
- !verify && appender ? appender.forwardHidden.bind(appender) : graph.forwardHidden.bind(graph),
856
+ verify ? graph.forwardHidden.bind(graph) : forwardCommitted,
848
857
  cache, ids, verify ? () => ids.length : chunkSize,
849
858
  );
850
859
  const [, Lf, Hf] = hf.shape as [number, number, number];
@@ -1032,9 +1041,6 @@ async function* generateInner(
1032
1041
  decoder = options.decodePolicy?.compiledDecode === false ? null : binding.createDecode?.({
1033
1042
  hasAdapters: !!options.adapters?.length, pagedKv: !!options.pagedKv,
1034
1043
  }) ?? null;
1035
- appender = fillOn ? binding.createAppend?.({
1036
- hasAdapters: !!options.adapters?.length, pagedKv: !!options.pagedKv,
1037
- }) ?? null : null;
1038
1044
  let stop = false;
1039
1045
  /** Token id read eagerly at the top of the loop for grammar (reused for
1040
1046
  * the yield, avoiding a second readback). -1 when grammar is off — the
@@ -1050,7 +1056,7 @@ async function* generateInner(
1050
1056
  const grammarJump = options.decodePolicy?.grammarJump ?? shouldUseGrammarJump(options, runtime);
1051
1057
  // Yield token zero before its own decode forward. The captured runtime
1052
1058
  // keeps this scheduling choice stable across consumer awaits.
1053
- const earlyFirstToken = runtime.value("MLX_BUN_EARLY_FIRST_TOKEN") === "1" &&
1059
+ const earlyFirstToken = runtime.flag("MLX_BUN_EARLY_FIRST_TOKEN", true) &&
1054
1060
  maxTokens > 1 && !resuming && !fillOn && !options.grammar;
1055
1061
  while (!stop) {
1056
1062
  options.signal?.throwIfAborted();
package/src/grammar.ts CHANGED
@@ -314,6 +314,19 @@ export class GrammarController {
314
314
  return this.terminated;
315
315
  }
316
316
 
317
+ /** Propose the forced string without advancing the matcher. Tokenization
318
+ * can differ at its first boundary; verification and the normal grammar
319
+ * sampler accept or reject the candidates before anything is committed. */
320
+ async proposeTokens(maxTokens: number): Promise<number[]> {
321
+ await this.ready();
322
+ if (this.terminated || !this.encodeRaw) return [];
323
+ return wasmQueue(async () => {
324
+ if (this.disposed) return [];
325
+ const text = this.matcher.findJumpForwardString();
326
+ return text ? this.encodeRaw!(text).slice(0, maxTokens) : [];
327
+ });
328
+ }
329
+
317
330
  /** Jump-forward decoding (SGLang's technique via xgrammar's
318
331
  * findJumpForwardString; opt-in — MLX_BUN_GRAMMAR_JUMP=1): when the grammar
319
332
  * admits exactly one continuation string (JSON keys/punctuation, enum
@@ -1,3 +1,9 @@
1
+ /** Request-level chunk policy. Execution consumes the selected size; model
2
+ * backends can account for their temporary workspace without changing admission. */
3
+ export interface PrefillPolicy {
4
+ chunkSize(promptTokens: number): number;
5
+ }
6
+
1
7
  export interface PrefillPosition {
2
8
  readonly length: number;
3
9
  readonly position: number;
package/src/kv-scheme.ts CHANGED
@@ -172,6 +172,28 @@ export class KvScheme {
172
172
  return "bf16";
173
173
  }
174
174
 
175
+ /** Configured attention storage, not live allocator occupancy. Recurrent
176
+ * layers retain their own state and never acquire an attention KV codec. */
177
+ describe(config: ModelConfig) {
178
+ const layers: Record<string, number> = {};
179
+ const attention = { global: 0, sliding_window: 0 };
180
+ let recurrent_layers = 0;
181
+ const perLayer = new Map(this.options.kvConfig?.map(entry => [entry.layerIdx, entry.bits]));
182
+ for (let layer = 0; layer < config.text.numHiddenLayers; layer++) {
183
+ const type = config.text.layerTypes[layer] ?? "full_attention";
184
+ if (type === "linear_attention") { recurrent_layers++; continue; }
185
+ const sliding = type === "sliding_attention";
186
+ attention[sliding ? "sliding_window" : "global"]++;
187
+ const bits = this.kind === "affine-config" ? perLayer.get(layer)
188
+ : this.kind === "affine-uniform" ? this.options.kvBits : undefined;
189
+ const codec = this.kind === "turbo" && !sliding
190
+ ? `turbo-k${this.options.turboQuant!.kBits}v${this.options.turboQuant!.vBits}`
191
+ : bits ? `kv${bits}` : "bf16";
192
+ layers[codec] = (layers[codec] ?? 0) + 1;
193
+ }
194
+ return { mode: this.label, layers, attention, recurrent_layers };
195
+ }
196
+
175
197
  bytesAt(config: ModelConfig, tokens: number): number {
176
198
  // TurboQuant stays conservatively billed as bf16 until its packed layout
177
199
  // exposes a stable projector.
package/src/kv-store.ts CHANGED
@@ -43,8 +43,14 @@ import { cleanupFailure, disposeResources } from "./engine/resources";
43
43
  // whole entry again.
44
44
 
45
45
  import { openSync, writeSync, readSync, closeSync, fsyncSync, renameSync, rmSync } from "node:fs";
46
+ import { pagedCacheCodec } from "./backends/mlx/paged-cache-codec";
47
+ import { HostBuffer } from "./storage/host-buffer";
48
+ import { readFileSync } from "node:fs";
49
+ import { join, dirname } from "node:path";
50
+ import { createHash } from "node:crypto";
46
51
  import { MmapFile, MADV_DONTNEED } from "./mmap";
47
52
  import { MlxArray } from "./mlx/array";
53
+ import { kvWriter, kvReader, type KvWriteRequest } from "./storage/kv-writer";
48
54
  import type { Dtype } from "./mlx/ffi";
49
55
  import * as ops from "./mlx/ops";
50
56
  import {
@@ -67,6 +73,7 @@ const hash64 = (bytes: Uint8Array): string => Bun.hash(bytes).toString(16).padSt
67
73
 
68
74
  export type CacheKind =
69
75
  | "kv"
76
+ | "paged"
70
77
  | "rotating"
71
78
  | "qkv"
72
79
  | "rotating-qkv"
@@ -76,7 +83,8 @@ export type CacheKind =
76
83
  | "mla-dsa"
77
84
  | "mtp-mla";
78
85
 
79
- interface TensorSlot {
86
+ export interface TensorSlot {
87
+ blocks?: Array<{ hash: string; bytes: number }>;
80
88
  off: number;
81
89
  bytes: number;
82
90
  shape: number[];
@@ -85,6 +93,8 @@ interface TensorSlot {
85
93
  }
86
94
 
87
95
  export interface CacheHeaderEntry {
96
+ paged?: { capacityTokens: number; blockSize: number; blockTable: number[]; numBlocks: number;
97
+ headDim: number; vHeadDim: number; dtype: Dtype; direct: boolean };
88
98
  kind: CacheKind;
89
99
  offset: number;
90
100
  minimumReusableOffset?: number;
@@ -146,7 +156,7 @@ export interface KvFileHeader extends Omit<KvSaveMeta, "attachments"> {
146
156
  attachments?: Array<Omit<CheckpointAttachment, "tensors"> & { tensors: TensorSlot[] }>;
147
157
  /** Absent in earlier v3 files, which used mlx-cache-v3. */
148
158
  codecProvider?: string;
149
- formatVersion: 3 | 4;
159
+ formatVersion: 3 | 4 | 5;
150
160
  createdAt: number;
151
161
  tokens: number[];
152
162
  caches: CacheHeaderEntry[];
@@ -430,7 +440,7 @@ const CACHE_CODECS = {
430
440
  if (state.length === 0) throw new Error("cannot clone an empty cache");
431
441
  try {
432
442
  const [kIdx, kScales, kZeros, vPacked, vScales] = state;
433
- const clone = new TurboQuantKVCache(c.kBits, c.vBits);
443
+ const clone = new TurboQuantKVCache(c.kBits, c.vBits, c.fusedDecode);
434
444
  clone.restoreState({
435
445
  kIdx: context.view(kIdx!),
436
446
  kScales: context.view(kScales!),
@@ -457,6 +467,7 @@ const CACHE_CODECS = {
457
467
  },
458
468
  headerTrimmable: () => true,
459
469
  },
470
+ paged: pagedCacheCodec,
460
471
  mla: {
461
472
  matches: (cache) => cacheSignature(cache) === "kv:mla:target",
462
473
  snapshot: (cache, context) => snapshotMla("mla", cache, context),
@@ -615,12 +626,8 @@ export function cloneKvCaches(caches: Cache[], codecs: CacheCodecProvider = lega
615
626
  return out;
616
627
  }
617
628
 
618
- /** Shared v3 writer core, one `yield` after each tensor write. The sync
619
- * wrapper drains it in a tight loop; the async wrapper awaits a macrotask
620
- * between yields so the EVENT LOOP KEEPS SERVING while a multi-hundred-MB
621
- * entry flushes (the write-behind persistence contract: durability never
622
- * blocks a request). try/finally still runs on early generator close. */
623
- function* saveKvCacheSteps(path: string, tokens: number[], caches: Cache[], meta: KvSaveMeta, codecs: CacheCodecProvider): Generator<void, void, void> {
629
+ /** Shared file layout and codec planning for synchronous and worker writes. */
630
+ function planKvCache(tokens: number[], caches: Cache[], meta: KvSaveMeta, codecs: CacheCodecProvider): { header: KvFileHeader; sources: TensorSource[] } {
624
631
  // Plan pass: header entries + lazy tensor sources, NO bytes materialized.
625
632
  const entries: CacheHeaderEntry[] = [];
626
633
  const sources: TensorSource[] = [];
@@ -652,6 +659,17 @@ function* saveKvCacheSteps(path: string, tokens: number[], caches: Cache[], meta
652
659
  formatVersion: attachments?.length ? 4 : 3, createdAt: Date.now(), ...identity,
653
660
  ...(attachments?.length ? { attachments } : {}), codecProvider: codecs.id, tokens, caches: entries,
654
661
  };
662
+ return { header, sources };
663
+ } catch (error) {
664
+ for (const source of sources) if (source.disposeAfter) source.arr.dispose();
665
+ throw error;
666
+ }
667
+ }
668
+
669
+ function* saveKvCacheSteps(path: string, tokens: number[], caches: Cache[], meta: KvSaveMeta, codecs: CacheCodecProvider): Generator<void, void, void> {
670
+ const { header, sources } = planKvCache(tokens, caches, meta, codecs);
671
+ const entries = header.caches, attachments = header.attachments;
672
+ try {
655
673
  const headerLen = new TextEncoder().encode(JSON.stringify(header)).length;
656
674
  const dataStart = alignUp(PREFIX_LEN + headerLen);
657
675
 
@@ -719,35 +737,27 @@ export function saveKvCache(path: string, tokens: number[], caches: Cache[], met
719
737
  for (const _ of saveKvCacheSteps(path, tokens, caches, meta, codecs)) { /* drain */ }
720
738
  }
721
739
 
722
- /** Non-blocking variant: yields the event loop after every tensor write so
723
- * serving interleaves with the flush. Caller owns `caches` lifetime for
724
- * the duration (pass zero-copy clones, dispose after).
725
- *
726
- * `runStep` (optional) runs each blocking tensor step inside the caller's
727
- * exclusion domain. Waiting for an idle observation is not sufficient: a
728
- * request can acquire the engine between that check and `steps.next()`,
729
- * putting rawBytesView's GPU sync on the decode stream during prefill. */
740
+ /** Prepare immutable native storage on the owner thread, then pack, hash and
741
+ * write it on the CPU worker. The optional runner covers preparation only.
742
+ * Callers retain cache and attachment ownership until this promise settles. */
730
743
  export async function saveKvCacheAsync(
731
744
  path: string, tokens: number[], caches: Cache[], meta: KvSaveMeta = {},
732
745
  runStep?: <T>(step: () => T) => Promise<T>, codecs: CacheCodecProvider = legacyCacheCodecs,
746
+ storage: Pick<KvWriteRequest, "layout" | "blockBytes" | "segmented"> = {},
733
747
  ): Promise<void> {
734
- const steps = saveKvCacheSteps(path, tokens, caches, meta, codecs);
748
+ const { header, sources } = planKvCache(tokens, caches, meta, codecs);
735
749
  try {
736
- while (true) {
737
- const next = runStep ? await runStep(() => steps.next()) : steps.next();
738
- if (next.done) break;
739
- await new Promise<void>((r) => setImmediate(r));
740
- }
741
- } catch (error) {
742
- // A rejected execution lease can leave the writer suspended with an
743
- // open fd. Throw through it so its existing failure cleanup runs.
744
- steps.throw(error);
745
- throw error;
750
+ // Snapshot preparation belongs to the MLX owner. After publication the
751
+ // worker sees only immutable bytes and never evaluates or calls MLX.
752
+ const prepare = () => sources.map(source => source.arr.storageView());
753
+ const tensors = runStep ? await runStep(prepare) : prepare();
754
+ await kvWriter.write({ path, header, tensors, ...storage });
755
+ } finally {
756
+ for (const source of sources) if (source.disposeAfter) source.arr.dispose();
746
757
  }
747
758
  }
748
759
 
749
- /** One pending write-behind item: an entry's copied tokens + OWNED
750
- * zero-copy cache clones (the queue disposes them on every exit path). */
760
+ /** Queued immutable snapshot; the queue releases its shared views on settle. */
751
761
  export interface SpillItem {
752
762
  attachments?: CheckpointAttachment[];
753
763
  tokens: number[];
@@ -755,23 +765,10 @@ export interface SpillItem {
755
765
  ns: string;
756
766
  }
757
767
 
758
- /** Bounded write-behind queue (2026-07-07 post-merge review fix).
759
- *
760
- * Pending spill/snapshot clones pin their entries' GPU buffers until the
761
- * generation-locked flush gets a turn; the old bare promise chain queued them
762
- * WITHOUT BOUND, so under sustained traffic (gate starved, evictions
763
- * ongoing) resident memory = prompt-cache cap + every queued clone —
764
- * allocator pressure exactly under the load that caused the evictions.
765
- *
766
- * Policy: queued bytes are capped. Over cap, the OLDEST not-in-flight
767
- * item is dropped and its clones disposed immediately — cache semantics
768
- * (a dropped spill is a future cache miss, never a wrong result; the
769
- * contention-free alternative to letting the flush cut into decode).
770
- * The item being enqueued is never its own victim (soft cap: one
771
- * oversized entry may exceed the cap alone rather than never spilling).
772
- * Items run strictly serially in enqueue order via an internal chain;
773
- * store failures are swallowed (cold tier is best-effort) but clones
774
- * are disposed on every settle path. */
768
+ /** Serial snapshot queue with optional capacity shedding. TieredPromptCache
769
+ * disables shedding and owns residency itself. Interrupted-generation
770
+ * persistence uses the cap for supersedable intervals. Queue ownership ends
771
+ * after each write settles; the cache can retain independent shared views. */
775
772
  type SpillRec = SpillItem & {
776
773
  bytes: number;
777
774
  dropped: boolean;
@@ -790,7 +787,7 @@ export class SpillQueue {
790
787
  readonly capBytes: number,
791
788
  /** Byte size of a clone set (prompt-cache's cacheBytes). */
792
789
  readonly bytesOf: (caches: Cache[]) => number,
793
- /** The actual write (server passes storeAsync + the idle gate). */
790
+ /** The asynchronous storage operation. */
794
791
  readonly store: (item: SpillItem) => Promise<unknown>,
795
792
  /** Clone disposal — frees the pinned GPU memory. */
796
793
  readonly disposeClones: (caches: Cache[]) => void,
@@ -891,7 +888,7 @@ export function readKvHeader(path: string): KvFileHeader & { dataStart: number }
891
888
  if (BigInt(Bun.hash(body)) !== expectHash)
892
889
  throw new Error(`${path}: header hash mismatch (truncated or corrupt)`);
893
890
  const header = JSON.parse(new TextDecoder().decode(body)) as KvFileHeader;
894
- if (header.formatVersion !== 3 && header.formatVersion !== 4)
891
+ if (header.formatVersion !== 3 && header.formatVersion !== 4 && header.formatVersion !== 5)
895
892
  throw new Error(`${path}: unsupported formatVersion ${header.formatVersion}`);
896
893
  return { ...header, dataStart };
897
894
  } finally {
@@ -997,19 +994,8 @@ function withStepCapacity(a: MlxArray, offset: number): MlxArray {
997
994
  }
998
995
  }
999
996
 
1000
- /** Reload by STREAMED COPY (see the header note): every tensor is copied
1001
- * into an mlx-owned leaf; the mapping is read-only, its pages dropped
1002
- * per-tensor, and unmapped before returning — the caches own their bytes
1003
- * outright (no dtor contract, no pinned mapping, nothing to retain).
1004
- * `model` is anything with makeCache() — the entry count is validated
1005
- * against the DONOR cache list (model.layers.length was wrong for
1006
- * KV-shared models like e4b, whose makeCache() returns donors only). */
1007
- export function loadKvCache(
1008
- path: string,
1009
- model: { makeCache(): Cache[] },
1010
- expect: KvLoadExpect = {}, codecs: CacheCodecProvider = legacyCacheCodecs,
1011
- ): LoadedKvCache {
1012
- const header = readKvHeader(path);
997
+ function validateKvHeader(path: string, header: KvFileHeader, model: { makeCache(): Cache[] },
998
+ expect: KvLoadExpect, codecs: CacheCodecProvider): void {
1013
999
  if ((header.codecProvider ?? legacyCacheCodecs.id) !== codecs.id)
1014
1000
  throw new Error(`${path}: cache codec provider mismatch`);
1015
1001
  for (const entry of header.caches) codecs.forHeader(entry);
@@ -1036,8 +1022,11 @@ export function loadKvCache(
1036
1022
  for (const c of proto) c.dispose();
1037
1023
  }
1038
1024
 
1039
- const mmap = MmapFile.open(path, "ro");
1040
- const dataStart = header.dataStart;
1025
+ }
1026
+
1027
+ /** Decode cache layouts independently of the transport supplying arrays. */
1028
+ function restoreKvArrays(path: string, header: KvFileHeader, codecs: CacheCodecProvider,
1029
+ readArray: (slot: TensorSlot) => MlxArray): LoadedKvCache {
1041
1030
  // Every tensor materialized for the CURRENT entry, drained on any throw
1042
1031
  // (2026-07-07 review: a hash mismatch under --ssd-cache-verify — or any
1043
1032
  // per-tensor failure — orphaned the entry's already-built tensors: the
@@ -1046,19 +1035,7 @@ export function loadKvCache(
1046
1035
  // is safe (dispose is idempotent).
1047
1036
  const pending: MlxArray[] = [];
1048
1037
  const arr = (slot: TensorSlot): MlxArray => {
1049
- const view = mmap.view(dataStart + slot.off, slot.bytes);
1050
- if (expect.verify && hash64(view) !== slot.hash)
1051
- throw new Error(`${path}: tensor hash mismatch at offset ${slot.off}`);
1052
- // mlx_array_new_data COPIES synchronously — the view only has to
1053
- // outlive this call, so the mapping can be advised/unmapped freely.
1054
- const a = MlxArray.fromBytesCopy(view, slot.shape, slot.dtype as Dtype);
1055
- pending.push(a);
1056
- // Drop the just-read clean pages so the load's transient stays at
1057
- // live entry + one tensor (ALIGN = 16 KiB == the arm64 page size, so
1058
- // every tensor offset is page-aligned by construction).
1059
- const len = Math.min(alignUp(slot.bytes), mmap.size - (dataStart + slot.off));
1060
- mmap.advise(dataStart + slot.off, len, MADV_DONTNEED);
1061
- return a;
1038
+ const a = readArray(slot); pending.push(a); return a;
1062
1039
  };
1063
1040
  /** withStepCapacity whose (possibly fresh) result is pending-tracked. */
1064
1041
  const grownArr = (slot: TensorSlot, offset: number): MlxArray => {
@@ -1092,8 +1069,67 @@ export function loadKvCache(
1092
1069
  for (const a of pending) a.dispose(); // the mid-entry orphans
1093
1070
  for (const c of caches) c.dispose();
1094
1071
  throw err;
1095
- } finally {
1096
- mmap.unmap(); // every tensor was copied out — nothing aliases the file
1097
1072
  }
1098
1073
  return { tokens: header.tokens, header, caches, ...(attachments.length ? { attachments } : {}) };
1099
1074
  }
1075
+
1076
+ /** CPU-only asynchronous reads into unpublished allocations; no tensor copy
1077
+ * crosses the event loop. Native array construction uses the shared codecs. */
1078
+ export async function loadKvCacheAsync(path: string, model: { makeCache(): Cache[] },
1079
+ expect: KvLoadExpect = {}, codecs: CacheCodecProvider = legacyCacheCodecs): Promise<LoadedKvCache> {
1080
+ const header = readKvHeader(path);
1081
+ validateKvHeader(path, header, model, expect, codecs);
1082
+ const slots = [...header.caches, ...(header.attachments ?? [])].flatMap(e => e.tensors);
1083
+ const buffers = new Map<TensorSlot, HostBuffer>();
1084
+ try {
1085
+ for (const slot of slots) buffers.set(slot, new HostBuffer(slot.bytes));
1086
+ await kvReader.read({ path, header, pointers: slots.map(slot => buffers.get(slot)!.pointer), verify: expect.verify });
1087
+ return restoreKvArrays(path, header, codecs, slot =>
1088
+ MlxArray.adoptHostBuffer(buffers.get(slot)!, slot.shape, slot.dtype as Dtype));
1089
+ } finally { for (const buffer of buffers.values()) buffer.dispose(); }
1090
+ }
1091
+
1092
+ /** Reload by STREAMED COPY (see the header note): every tensor is copied
1093
+ * into an mlx-owned leaf; the mapping is read-only, its pages dropped
1094
+ * per-tensor, and unmapped before returning — the caches own their bytes
1095
+ * outright (no dtor contract, no pinned mapping, nothing to retain).
1096
+ * `model` is anything with makeCache() — the entry count is validated
1097
+ * against the DONOR cache list (model.layers.length was wrong for
1098
+ * KV-shared models like e4b, whose makeCache() returns donors only). */
1099
+ export function loadKvCache(
1100
+ path: string,
1101
+ model: { makeCache(): Cache[] },
1102
+ expect: KvLoadExpect = {}, codecs: CacheCodecProvider = legacyCacheCodecs,
1103
+ ): LoadedKvCache {
1104
+ const header = readKvHeader(path);
1105
+ validateKvHeader(path, header, model, expect, codecs);
1106
+ if (header.formatVersion === 5) {
1107
+ return restoreKvArrays(path, header, codecs, slot => {
1108
+ const bytes = new Uint8Array(slot.bytes);
1109
+ let offset = 0;
1110
+ for (const block of slot.blocks ?? []) {
1111
+ if (!/^[a-f0-9]{64}$/.test(block.hash) || block.bytes <= 0 || offset + block.bytes > bytes.length)
1112
+ throw new Error(`${path}: invalid KV block reference`);
1113
+ const part = readFileSync(join(dirname(path), "blocks", block.hash));
1114
+ if (part.length !== block.bytes || (expect.verify && createHash("sha256").update(part).digest("hex") !== block.hash))
1115
+ throw new Error(`${path}: KV block mismatch`);
1116
+ bytes.set(part, offset); offset += part.length;
1117
+ }
1118
+ if (offset !== bytes.length)
1119
+ throw new Error(`${path}: KV tensor mismatch`);
1120
+ return MlxArray.fromBytesCopy(bytes, slot.shape, slot.dtype as Dtype);
1121
+ });
1122
+ }
1123
+ const mmap = MmapFile.open(path, "ro"), dataStart = header.dataStart;
1124
+ try {
1125
+ return restoreKvArrays(path, header, codecs, slot => {
1126
+ const view = mmap.view(dataStart + slot.off, slot.bytes);
1127
+ if (expect.verify && hash64(view) !== slot.hash)
1128
+ throw new Error(`${path}: tensor hash mismatch at offset ${slot.off}`);
1129
+ const array = MlxArray.fromBytesCopy(view, slot.shape, slot.dtype as Dtype);
1130
+ const len = Math.min(alignUp(slot.bytes), mmap.size - (dataStart + slot.off));
1131
+ mmap.advise(dataStart + slot.off, len, MADV_DONTNEED);
1132
+ return array;
1133
+ });
1134
+ } finally { mmap.unmap(); }
1135
+ }
package/src/lab/README.md CHANGED
@@ -14,5 +14,9 @@ fate (PLAN.md "Maintainability program", D6 darlings). Rules, gate-enforced by
14
14
  | module | flag / entry | oracle |
15
15
  |---|---|---|
16
16
  | `curve/` (curve sampler + designer page) | `--curve-*`, `/curves` | none (Lab) |
17
- | `paged-kv/` | `--paged-kv` (serial-only) | parity vs plain KVCache (bit-exact), no measured win |
17
+ | `paged-kv/` | `--paged-kv`; direct read via `MLX_BUN_PAGED_ATTN` | gathered storage is bit-exact; direct Metal is tolerance-tested, with paired M1/M4 measurements in benchmarks.md |
18
18
  | `expert-trace/` | `MLX_BUN_EXPERT_TRACE` hook in gemma4 | closed study (expert-offload) |
19
+
20
+ The optional paged layout has a separate backend persistence codec. Its
21
+ allowlisted import keeps `kv-store` independent of row-execution modules and
22
+ preserves RAM/SSD reuse without promoting direct-attention numerics to L1.