mlx-bun 0.3.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 (231) hide show
  1. package/README.md +57 -32
  2. package/docs/README.md +58 -42
  3. package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
  4. package/docs/archive/investigations/openjarvis-fit.md +198 -0
  5. package/docs/archive/investigations/qwen38-27b-campaign-log.md +1092 -0
  6. package/docs/archive/planning/release-notes-v0.4.0.md +45 -0
  7. package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
  8. package/docs/design/batching.md +1535 -50
  9. package/docs/design/decode-speed-program.md +394 -1171
  10. package/docs/design/generic-model-support.md +195 -3
  11. package/docs/design/kv-cache.md +538 -225
  12. package/docs/design/speculative-decoding.md +552 -50
  13. package/docs/design/turboquant.md +140 -17
  14. package/docs/design/unified-engine-frontier-plan.md +259 -150
  15. package/docs/planning/PRODUCT_ROADMAP.md +21 -6
  16. package/docs/planning/public-readiness-handoff.md +244 -0
  17. package/docs/planning/public-readiness.md +149 -0
  18. package/docs/reference/benchmarks.md +3537 -1112
  19. package/docs/reference/cli.md +102 -2
  20. package/docs/reference/distribution.md +45 -13
  21. package/docs/reference/environment.md +159 -13
  22. package/docs/reference/library-api.md +18 -4
  23. package/docs/reference/memory.md +1 -1
  24. package/docs/reference/models.md +28 -7
  25. package/docs/reference/server-api.md +257 -33
  26. package/docs/reference/server-config.md +372 -151
  27. package/docs/reference/troubleshooting.md +13 -21
  28. package/package.json +2 -2
  29. package/src/audio/audiotoolbox.ts +112 -0
  30. package/src/audio/conformer.ts +8 -0
  31. package/src/audio/mic-capture.ts +98 -0
  32. package/src/audio/silero-vad.ts +379 -0
  33. package/src/audio/whisper-decode.ts +774 -0
  34. package/src/audio/whisper-fast.ts +732 -0
  35. package/src/audio/whisper-format.ts +73 -0
  36. package/src/audio/whisper-mel.ts +185 -0
  37. package/src/audio/whisper-timing.ts +274 -0
  38. package/src/audio/whisper-tokenizer.ts +239 -0
  39. package/src/audio/whisper-transcribe.ts +410 -0
  40. package/src/backends/mlx/assistant-target.ts +46 -0
  41. package/src/backends/mlx/autoregressive-draft-rows.ts +120 -0
  42. package/src/backends/mlx/autoregressive.ts +22 -1
  43. package/src/backends/mlx/batch-group.ts +464 -236
  44. package/src/backends/mlx/cache-attachment.ts +67 -0
  45. package/src/backends/mlx/cache-layout.ts +31 -0
  46. package/src/backends/mlx/checkpoint-state.ts +53 -0
  47. package/src/backends/mlx/continuation-persistence.ts +107 -0
  48. package/src/backends/mlx/continuation-request.ts +43 -0
  49. package/src/backends/mlx/continuation.ts +77 -0
  50. package/src/backends/mlx/draft-target.ts +7 -22
  51. package/src/backends/mlx/encoder-cache.ts +25 -0
  52. package/src/backends/mlx/fill-append.ts +21 -8
  53. package/src/backends/mlx/fill-group.ts +497 -0
  54. package/src/backends/mlx/full-kv-row-append.ts +32 -0
  55. package/src/backends/mlx/full-kv-row-donor.ts +54 -0
  56. package/src/backends/mlx/full-transitioning-kv-rows.ts +65 -0
  57. package/src/backends/mlx/gateway-binding.ts +122 -19
  58. package/src/backends/mlx/kv-codec.ts +9 -0
  59. package/src/backends/mlx/kv-maintenance.ts +161 -0
  60. package/src/backends/mlx/kv-tensor-rows.ts +212 -0
  61. package/src/backends/mlx/mixed-iteration.ts +62 -0
  62. package/src/backends/mlx/model-prompt.ts +46 -32
  63. package/src/backends/mlx/model-serving.ts +5 -4
  64. package/src/backends/mlx/paged-cache-codec.ts +27 -0
  65. package/src/backends/mlx/prefill-cohort.ts +100 -0
  66. package/src/backends/mlx/prefill-policy.ts +26 -0
  67. package/src/backends/mlx/prefill-rows.ts +244 -0
  68. package/src/backends/mlx/prefill.ts +1 -1
  69. package/src/backends/mlx/projected-context-rows.ts +72 -0
  70. package/src/backends/mlx/prompt-input.ts +29 -0
  71. package/src/backends/mlx/qwen-prompt-input.ts +40 -0
  72. package/src/backends/mlx/request-state-policy.ts +54 -0
  73. package/src/backends/mlx/rollback.ts +45 -4
  74. package/src/backends/mlx/row-sampling.ts +49 -0
  75. package/src/backends/mlx/serial-executor.ts +51 -73
  76. package/src/backends/mlx/speculative-group.ts +438 -0
  77. package/src/backends/mlx/speculative-round.ts +161 -0
  78. package/src/backends/mlx/speculative.ts +24 -7
  79. package/src/backends/mlx/state-rows.ts +104 -0
  80. package/src/backends/mlx/state-views.ts +21 -1
  81. package/src/backends/mlx/target-layout-capability.ts +35 -0
  82. package/src/backends/mlx/transitioning-kv-rows.ts +173 -0
  83. package/src/cli.ts +333 -36
  84. package/src/contracts/execution.ts +21 -0
  85. package/src/contracts/object-cache.ts +8 -0
  86. package/src/contracts/pi-provider.ts +1 -1
  87. package/src/contracts/pi.ts +3 -1
  88. package/src/contracts/prefix-cache.ts +33 -0
  89. package/src/contracts/resources.ts +7 -0
  90. package/src/contracts/sampling.ts +23 -0
  91. package/src/contracts/scheduling.ts +27 -0
  92. package/src/engine/execution-plan.ts +38 -25
  93. package/src/engine/execution-tasks.ts +44 -0
  94. package/src/engine/namespaced-cache.ts +13 -0
  95. package/src/engine/resources.ts +12 -1
  96. package/src/engine/scheduler.ts +32 -7
  97. package/src/fill/fill-session.ts +14 -3
  98. package/src/fit.ts +29 -15
  99. package/src/generate.ts +105 -195
  100. package/src/grammar.ts +13 -0
  101. package/src/inference/draft-acceptance.ts +41 -0
  102. package/src/inference/draft-output.ts +49 -0
  103. package/src/inference/prefill.ts +6 -0
  104. package/src/inference/rollback.ts +2 -2
  105. package/src/kv-scheme.ts +92 -12
  106. package/src/kv-store.ts +183 -93
  107. package/src/lab/README.md +5 -1
  108. package/src/lab/paged-kv/paged-attention.ts +117 -0
  109. package/src/lab/paged-kv/paged-kv-rows.ts +111 -0
  110. package/src/lab/paged-kv/paged-kv.ts +126 -46
  111. package/src/md-modules.d.ts +5 -0
  112. package/src/mlx/array.ts +39 -1
  113. package/src/mlx/ffi.ts +19 -0
  114. package/src/mlx/normalized-argmax.ts +116 -0
  115. package/src/mlx/ops.ts +97 -10
  116. package/src/model/artifact-identity.ts +73 -0
  117. package/src/model/batched-kv.ts +235 -0
  118. package/src/model/batched-mask.ts +12 -4
  119. package/src/model/batched-quant.ts +8 -2
  120. package/src/model/batched-quantized-kv.ts +144 -0
  121. package/src/model/batched-rotating-quant.ts +76 -15
  122. package/src/model/batched-rotating-state.ts +76 -0
  123. package/src/model/batched-rotating.ts +64 -22
  124. package/src/model/batched-row-storage.ts +52 -0
  125. package/src/model/batched-ssm.ts +52 -0
  126. package/src/model/batched-turboquant-kv.ts +94 -0
  127. package/src/model/compiled-decode.ts +7 -1
  128. package/src/model/decoded-kv-donor.ts +12 -0
  129. package/src/model/delayed-quantized-kv.ts +72 -0
  130. package/src/model/delayed-rotating-quantized-kv.ts +93 -0
  131. package/src/model/delayed-turboquant-kv.ts +57 -0
  132. package/src/model/factory.ts +26 -0
  133. package/src/model/fingerprint.ts +12 -0
  134. package/src/model/full-prefill-padding.ts +56 -0
  135. package/src/model/full-prefill-row.ts +105 -0
  136. package/src/model/gemma4-base.ts +183 -82
  137. package/src/model/gemma4.ts +116 -28
  138. package/src/model/generated/gemma4-12b.ts +4 -1
  139. package/src/model/generated/gemma4-26b.ts +4 -1
  140. package/src/model/generated/gemma4-e4b.ts +4 -1
  141. package/src/model/glm52-cache.ts +80 -4
  142. package/src/model/glm52-mla.ts +12 -6
  143. package/src/model/glm52.ts +19 -40
  144. package/src/model/kv-attention-view.ts +70 -0
  145. package/src/model/minicpm5.ts +11 -7
  146. package/src/model/profile.ts +29 -4
  147. package/src/model/quantized-append-attention.ts +31 -0
  148. package/src/model/qwen3-delta-state.ts +74 -0
  149. package/src/model/qwen3-delta.ts +59 -36
  150. package/src/model/qwen3-mrope.ts +7 -9
  151. package/src/model/qwen3_5.ts +177 -75
  152. package/src/model/rotating-kv-donor.ts +35 -0
  153. package/src/model/rotating-kv-layout.ts +262 -0
  154. package/src/model/rotating-row-transaction.ts +35 -0
  155. package/src/model/speculative-rotating-kv.ts +101 -0
  156. package/src/model/ssm-prefill-padding.ts +39 -0
  157. package/src/model/support.ts +8 -0
  158. package/src/model/token-groups.ts +66 -0
  159. package/src/model/trellis-linear.ts +4 -4
  160. package/src/model/turboquant-codec.ts +89 -0
  161. package/src/model/universal/dense.ts +14 -13
  162. package/src/model/whisper.ts +435 -0
  163. package/src/native/mic_capture.swift +92 -0
  164. package/src/native-pack.ts +6 -5
  165. package/src/pi-web.ts +9 -1
  166. package/src/preflight.ts +31 -15
  167. package/src/prompt-cache.ts +306 -86
  168. package/src/runtime-config.ts +1 -5
  169. package/src/safetensors.ts +9 -2
  170. package/src/sampler.ts +37 -25
  171. package/src/serve/audio-routes.ts +323 -0
  172. package/src/serve/chat-request.ts +2 -0
  173. package/src/serve/chat-stage.ts +19 -14
  174. package/src/serve/completion-executor.ts +9 -1
  175. package/src/serve/discovery-routes.ts +17 -1
  176. package/src/serve/generated-token-history.ts +57 -0
  177. package/src/serve/generation-gateway.ts +88 -38
  178. package/src/serve/model-binding.ts +7 -1
  179. package/src/serve/model-host.ts +3 -4
  180. package/src/serve/preparation.ts +3 -3
  181. package/src/serve/prompt-response-trace.ts +14 -0
  182. package/src/serve/request-plan.ts +6 -3
  183. package/src/serve/request-prep.ts +40 -25
  184. package/src/serve/text-completion-stage.ts +2 -2
  185. package/src/serve/transcription-server.ts +64 -0
  186. package/src/serve/transcription-service.ts +502 -0
  187. package/src/server.ts +185 -261
  188. package/src/spec/assistant-rows.ts +93 -0
  189. package/src/spec/assistant-source.ts +28 -18
  190. package/src/spec/cache-identity.ts +22 -0
  191. package/src/spec/deepspec-rows.ts +29 -0
  192. package/src/spec/deepspec-source.ts +12 -10
  193. package/src/spec/dflash-source.ts +28 -4
  194. package/src/spec/drafter.ts +153 -142
  195. package/src/spec/dspark/deepspec-module.ts +95 -120
  196. package/src/spec/dspark/module-dflash.ts +126 -170
  197. package/src/spec/glm52-mtp-graph.ts +29 -0
  198. package/src/spec/glm52-mtp-rows.ts +132 -0
  199. package/src/spec/glm52-mtp-source.ts +54 -136
  200. package/src/spec/glm52-mtp-state.ts +33 -0
  201. package/src/spec/ngram-source.ts +115 -21
  202. package/src/spec/projected-draft-rows.ts +73 -0
  203. package/src/spec/qwen-mtp-module.ts +171 -0
  204. package/src/spec/qwen-mtp-rows.ts +230 -0
  205. package/src/spec/qwen-mtp-source.ts +84 -226
  206. package/src/spec/qwen-mtp-state.ts +30 -0
  207. package/src/spec/serve-loop.ts +73 -69
  208. package/src/spec/source.ts +93 -15
  209. package/src/spec/two-model-rows.ts +62 -0
  210. package/src/spec/two-model.ts +45 -103
  211. package/src/ssd-cache.ts +107 -36
  212. package/src/ssd-durability.ts +29 -27
  213. package/src/storage/host-buffer.ts +24 -0
  214. package/src/storage/kv-writer.ts +83 -0
  215. package/src/storage/kv-writer.worker.js +264 -0
  216. package/src/storage/retention-policy.ts +37 -0
  217. package/src/tiered-prompt-cache.ts +45 -0
  218. package/src/vision/embedder.ts +8 -0
  219. package/src/vision/prompt.ts +101 -65
  220. package/src/vision/qwen3vl-prompt.ts +101 -64
  221. package/src/vision/qwen3vl-tower.ts +11 -0
  222. package/src/vision/siglip.ts +8 -0
  223. package/src/web/app.html +12 -0
  224. package/src/web/app.js +217 -57
  225. package/src/web/src/chat.ts +4 -1
  226. package/src/web/src/composer.ts +3 -0
  227. package/src/web/src/voice.ts +141 -0
  228. package/src/web/sw.js +1 -1
  229. package/src/backends/mlx/memory-guard.ts +0 -40
  230. package/src/spec/prefix-state.ts +0 -45
  231. /package/docs/{planning → archive/planning}/release-notes-v0.3.0.md +0 -0
@@ -0,0 +1,72 @@
1
+ import type { MlxArray } from "../../mlx/array";
2
+ import { Dtype } from "../../mlx/ffi";
3
+ import * as ops from "../../mlx/ops";
4
+ import { BatchedKVCache } from "../../model/batched-kv";
5
+ import type { Cache } from "../../model/gemma4-base";
6
+ import { MlxStateRows } from "./state-rows";
7
+ import { disposeResources } from "../../engine/resources";
8
+
9
+ export interface ProjectedContext { k: MlxArray; v: MlxArray; }
10
+ export interface ContextProjection {
11
+ project(hidden: MlxArray, positions: number | MlxArray): ProjectedContext[];
12
+ }
13
+
14
+ /** Incremental projected context reuses ordinary full-attention row storage.
15
+ * The projection graph owns numerics; this object owns positions and validity. */
16
+ export class MlxProjectedContextRows {
17
+ readonly state: MlxStateRows<BatchedKVCache>;
18
+ constructor(layers: number, readonly graph: ContextProjection) {
19
+ this.state = new MlxStateRows(Array.from({ length: layers }, () => new BatchedKVCache()));
20
+ }
21
+ get positions(): readonly number[] { return this.state.caches[0]!.rowOffsets; }
22
+ prepareAppend(rows: readonly (readonly Cache[])[]) { return this.state.prepareAppendMany(rows); }
23
+ append(hidden: MlxArray, lengths: readonly number[]): void {
24
+ const width = Math.max(...lengths), B = lengths.length, positions = this.positions;
25
+ using context = hidden.slice([0, 0, 0], [B, width, hidden.shape[2]!]);
26
+ using offsets = positions.every(position => position === positions[0]) ? null : ops.fromInt32([...positions], [B]);
27
+ const projected = this.graph.project(context, offsets ?? positions[0]!);
28
+ try {
29
+ for (const [layer, cache] of this.state.caches.entries()) {
30
+ cache.specRoundBegin();
31
+ for (const array of cache.updateAndFetch(projected[layer]!.k, projected[layer]!.v)) array.dispose();
32
+ if (lengths.every(length => length === width)) cache.specRoundCommit();
33
+ else cache.specRoundRollback(lengths);
34
+ }
35
+ } finally { disposeResources(projected.flatMap(pair => [pair.k, pair.v])); }
36
+ }
37
+ materialize(): void { ops.evalAll(this.state.caches.flatMap(cache => cache.state())); }
38
+ readAttention() {
39
+ const views: import("../../model/gemma4-base").KvDonorRows[] = [];
40
+ try { for (const cache of this.state.caches) views.push(cache.captureDonorRows()); }
41
+ catch (error) { disposeResources(views.flatMap(view => [view.keys, view.values])); throw error; }
42
+ const masks = new Map<string, MlxArray | null>();
43
+ return {
44
+ attend(layer: number, query: MlxArray, blockKeys: MlxArray, blockValues: MlxArray, scale = 1): MlxArray {
45
+ const view = views[layer]!, width = view.keys.shape[2]!, block = blockKeys.shape[2]!, B = view.offsets.length;
46
+ // Membership and coverage advance together in every context layer.
47
+ const key = `${width}:${block}:${view.keys.dtype}`;
48
+ if (!masks.has(key)) {
49
+ let mask: MlxArray | null = null;
50
+ if (view.starts.some(start => start !== 0) || view.ends.some(end => end !== width)) {
51
+ using columns = ops.arange(0, width, 1, Dtype.int32);
52
+ using lower = ops.fromInt32([...view.starts], [B, 1, 1, 1]);
53
+ using upper = ops.fromInt32([...view.ends], [B, 1, 1, 1]);
54
+ using before = ops.less(columns, lower), after = ops.greaterEqual(columns, upper);
55
+ using invalid = ops.logicalOr(before, after);
56
+ using floats = invalid.astype(view.keys.dtype);
57
+ using prefix = ops.mulScalar(floats, -1e9);
58
+ using suffix = ops.zeros([B, 1, 1, block], view.keys.dtype);
59
+ mask = ops.concatAxis([prefix, suffix], 3);
60
+ }
61
+ masks.set(key, mask);
62
+ }
63
+ using keys = ops.concatAxis([view.keys, blockKeys], 2);
64
+ using values = ops.concatAxis([view.values, blockValues], 2);
65
+ const mask = masks.get(key)!;
66
+ return ops.sdpa(query, keys, values, scale, mask ? "array" : "", mask);
67
+ },
68
+ dispose() { disposeResources([...views.flatMap(view => [view.keys, view.values]), ...[...masks.values()].filter(value => value !== null)]); },
69
+ };
70
+ }
71
+ dispose(): void { this.state.dispose(); }
72
+ }
@@ -0,0 +1,29 @@
1
+ import type { MlxArray } from "../../mlx/array";
2
+ import type { Cache } from "../../model/gemma4-base";
3
+
4
+ /** Prepared native input with an indivisible prefill. The preparation owner
5
+ * retains its tensors; the model binding returns an owned final hidden row.
6
+ * Decode, sampling and row retirement use the ordinary execution group. */
7
+ export interface MlxPromptInput {
8
+ forward(ids: MlxArray, caches: Cache[], start?: number): MlxArray;
9
+ readonly decodeState?: MlxDecodeState;
10
+ }
11
+
12
+ /** Additional model-owned forward state. Equal keys declare compatible row
13
+ * state; the backend supplies the current row order after joins/retirement. */
14
+ export interface MlxDecodeState {
15
+ readonly key: string;
16
+ forward(ids: MlxArray, caches: Cache[], rows: readonly MlxDecodeState[]): MlxArray;
17
+ }
18
+
19
+ /** Preserve the ordinary generator's projection geometry for a prepared
20
+ * embedding sequence: process the whole prompt, then project only its tip. */
21
+ export function bindEmbeddingsInput(
22
+ forward: (ids: MlxArray, caches: Cache[], start: number) => MlxArray,
23
+ ): MlxPromptInput {
24
+ return { forward(ids, caches, start = 0) {
25
+ using hidden = forward(ids, caches, start);
26
+ const [batch, length, width] = hidden.shape;
27
+ return hidden.slice([0, length! - 1, 0], [batch!, length!, width!]);
28
+ } };
29
+ }
@@ -0,0 +1,40 @@
1
+ import type { Qwen35Model } from "../../model/qwen3_5";
2
+ import { mropePositionIds, type MropeRequestState } from "../../model/qwen3-mrope";
3
+ import type { MlxArray } from "../../mlx/array";
4
+ import type { Cache } from "../../model/gemma4-base";
5
+ import * as ops from "../../mlx/ops";
6
+ import { Dtype } from "../../mlx/ffi";
7
+ import { bindEmbeddingsInput, type MlxDecodeState, type MlxPromptInput } from "./prompt-input";
8
+
9
+ class QwenMediaDecodeState implements MlxDecodeState {
10
+ readonly key = "qwen-interleaved-mrope";
11
+ constructor(readonly model: Qwen35Model, readonly delta: number) {}
12
+
13
+ forward(ids: MlxArray, caches: Cache[], rows: readonly MlxDecodeState[]): MlxArray {
14
+ const B = rows.length, L = ids.shape[1]!;
15
+ const cache = caches[this.model.faIdx]!;
16
+ // The cache supplies logical positions, including left padding, on device.
17
+ // No offset readback or request-global position mutation is needed.
18
+ const offsets = (cache as Cache & { ropeOffsetArr?: MlxArray }).ropeOffsetArr;
19
+ using deltas = ops.fromInt32(rows.map(row => (row as QwenMediaDecodeState).delta), [B]);
20
+ using scalar = offsets ? null : ops.fromInt32([cache.offset], []);
21
+ using starts = ops.add(offsets ?? scalar!, deltas);
22
+ using startRows = ops.reshape(starts, [B, 1]);
23
+ using indices = ops.arange(0, L, 1, Dtype.int32);
24
+ using positions = ops.add(startRows, indices);
25
+ using axis = ops.reshape(positions, [1, B, L]);
26
+ using grid = ops.concatAxis([axis, axis, axis], 0);
27
+ return this.model.forwardHiddenAtPositions(ids, caches, grid);
28
+ }
29
+ }
30
+
31
+ /** The prompt owns its grid; only its continuation delta survives into decode. */
32
+ export function bindQwenMediaInput(model: Qwen35Model, embeddings: MlxArray,
33
+ state: MropeRequestState): MlxPromptInput {
34
+ const input = bindEmbeddingsInput((ids, caches, start) => {
35
+ using positions = mropePositionIds(state, start, ids.shape[1]!);
36
+ return start > 0 ? model.forwardHiddenAtPositions(ids, caches, positions)
37
+ : model.forwardEmbeddingsAtPositions(embeddings, caches, positions);
38
+ });
39
+ return { ...input, decodeState: new QwenMediaDecodeState(model, state.delta) };
40
+ }
@@ -0,0 +1,54 @@
1
+ import { namespacedCache } from "../../engine/namespaced-cache";
2
+ import { runtimeConfig, runtimeFlag, withRuntimeConfig, type RuntimeConfig } from "../../runtime-config";
3
+ import type { GenerateOptions } from "../../generate";
4
+ import type { RuntimeModel } from "../../model/factory";
5
+ import { KVCache, type Cache } from "../../model/gemma4-base";
6
+ import { PagedKVCache } from "../../lab/paged-kv/paged-kv";
7
+ import type { RowPromptCache } from "./batch-group";
8
+ import { disposeResources } from "../../engine/resources";
9
+
10
+ /** Request state construction and reusable-prefix policy. Equal keys declare
11
+ * compatible state layouts; the executor need not inspect their settings. */
12
+ export interface MlxRequestStatePolicy {
13
+ readonly key: string;
14
+ readonly promptCache?: RowPromptCache;
15
+ create(): Cache[];
16
+ }
17
+
18
+ /** Replace fresh full-attention storage before prefill. Sliding layers retain
19
+ * their layout; pre-warmed library caches retain their existing storage. */
20
+ export function maybePageKv(
21
+ cache: Cache[], options: GenerateOptions, capacityTokens: number,
22
+ ): void {
23
+ if (!options.pagedKv) return;
24
+ if (cache.some((c) => c.offset > 0)) return;
25
+ const blockSize = options.pagedKv.blockSize ?? PagedKVCache.DEFAULT_BLOCK_SIZE;
26
+ for (let i = 0; i < cache.length; i++) {
27
+ if (cache[i] instanceof KVCache) {
28
+ cache[i]!.dispose(); // fresh (offset 0) — nothing stored yet
29
+ cache[i] = new PagedKVCache(capacityTokens, blockSize, undefined,
30
+ options.kvBits === 4 || options.kvBits === 8 ? { bits: options.kvBits, groupSize: options.kvGroupSize ?? 64 } : undefined);
31
+ }
32
+ }
33
+ }
34
+
35
+ export function pagedPrefixNamespace(options: GenerateOptions, base: string,
36
+ direct = runtimeFlag("MLX_BUN_PAGED_ATTN", false)): string {
37
+ return JSON.stringify(["paged-v1", options.pagedKv?.blockSize ?? PagedKVCache.DEFAULT_BLOCK_SIZE,
38
+ options.kvBits ?? 0, options.kvGroupSize ?? 64, direct, base]);
39
+ }
40
+
41
+ export function bindPagedRequestState(model: RuntimeModel, options: GenerateOptions,
42
+ capacityTokens: number, cache?: RowPromptCache, runtime: RuntimeConfig = runtimeConfig()): MlxRequestStatePolicy | undefined {
43
+ if (!options.pagedKv) return undefined;
44
+ const blockSize = options.pagedKv.blockSize ?? PagedKVCache.DEFAULT_BLOCK_SIZE;
45
+ const stateOptions = { pagedKv: { blockSize }, kvBits: options.kvBits, kvGroupSize: options.kvGroupSize };
46
+ const direct = runtime.flag("MLX_BUN_PAGED_ATTN", false);
47
+ const namespace = (base: string) => pagedPrefixNamespace(stateOptions, base, direct);
48
+ return { key: namespace(""),
49
+ promptCache: cache ? namespacedCache(cache, namespace) : undefined, create: () => withRuntimeConfig(runtime, () => {
50
+ const caches = model.makeCache();
51
+ try { maybePageKv(caches, stateOptions, capacityTokens); return caches; }
52
+ catch (error) { disposeResources(caches); throw error; }
53
+ }) };
54
+ }
@@ -1,7 +1,39 @@
1
1
  import type { SpeculativeTransaction } from "../../inference/rollback";
2
2
  import type { Cache } from "../../model/gemma4-base";
3
3
 
4
- /** Bind the full legacy recurrent transaction or the trim implementation once.
4
+ interface TransactionLayer<Acceptance> {
5
+ fits(drafts: number): boolean;
6
+ begin(drafts: number): void;
7
+ resolve(accepted: Acceptance, drafts: number): void;
8
+ }
9
+
10
+ /** State storage that can retain a different verified prefix for each row.
11
+ * Both attention and recurrent layouts implement this same transaction. */
12
+ export interface RowSpeculativeState {
13
+ specRoundBegin(): void;
14
+ specRoundCommit(): void;
15
+ specRoundRollback(keep: number | readonly number[]): void;
16
+ }
17
+
18
+ export function bindRowCacheRollback(
19
+ caches: readonly RowSpeculativeState[], rows: number,
20
+ ): SpeculativeTransaction<readonly number[]> {
21
+ return bindTransaction(caches.map(cache => ({
22
+ fits: () => true,
23
+ // A zero-candidate round consumes only the committed pending token.
24
+ // It cannot reject any input, so retaining rollback state is unnecessary.
25
+ begin: drafts => { if (drafts > 0) cache.specRoundBegin(); },
26
+ resolve(accepted: readonly number[], drafts: number) {
27
+ if (drafts === 0) return;
28
+ if (accepted.every(count => count === drafts)) cache.specRoundCommit();
29
+ else cache.specRoundRollback(accepted.every(count => count === accepted[0])
30
+ ? accepted[0]! + 1 : accepted.map(count => count + 1));
31
+ },
32
+ })), (accepted, drafts) => accepted.length === rows &&
33
+ accepted.every(count => Number.isSafeInteger(count) && count >= 0 && count <= drafts));
34
+ }
35
+
36
+ /** Bind the recurrent transaction or the trim implementation once.
5
37
  * Ring-capacity checks still happen at each safe boundary before any writes. */
6
38
  export function bindCacheRollback(caches: readonly Cache[]): SpeculativeTransaction {
7
39
  const layers = caches.map((cache) => {
@@ -24,6 +56,15 @@ export function bindCacheRollback(caches: readonly Cache[]): SpeculativeTransact
24
56
  },
25
57
  };
26
58
  });
59
+ return bindTransaction(layers, (accepted, drafts) =>
60
+ Number.isSafeInteger(accepted) && accepted >= 0 && accepted <= drafts);
61
+ }
62
+
63
+ /** One transaction lifecycle for scalar and row-wise state bindings. */
64
+ function bindTransaction<Acceptance>(
65
+ layers: readonly TransactionLayer<Acceptance>[],
66
+ accepts: (accepted: Acceptance, drafts: number) => boolean,
67
+ ): SpeculativeTransaction<Acceptance> {
27
68
  let active: number | undefined;
28
69
  let invalid = false;
29
70
  const validate = (drafts: number) => {
@@ -37,12 +78,12 @@ export function bindCacheRollback(caches: readonly Cache[]): SpeculativeTransact
37
78
  if (active !== undefined || !layers.every((layer) => layer.fits(drafts)))
38
79
  throw new Error("speculative state cannot begin this round");
39
80
  active = drafts;
40
- try { for (const layer of layers) layer.begin(); }
81
+ try { for (const layer of layers) layer.begin(drafts); }
41
82
  catch (error) { invalid = true; throw error; }
42
83
  },
43
84
  resolve(accepted) {
44
- validate(accepted);
45
- if (active === undefined || accepted > active) throw new Error("invalid speculative acceptance count");
85
+ if (invalid) throw new Error("speculative state is invalid and must be discarded");
86
+ if (active === undefined || !accepts(accepted, active)) throw new Error("invalid speculative acceptance count");
46
87
  try { for (const layer of layers) layer.resolve(accepted, active); }
47
88
  catch (error) { invalid = true; throw error; }
48
89
  active = undefined;
@@ -0,0 +1,49 @@
1
+ import type { MlxArray } from "../../mlx/array";
2
+ import * as ops from "../../mlx/ops";
3
+ import type { TokenLogprobs } from "../../contracts/generation";
4
+ import { readStepExtras, stepExtrasArrays, disposeStepExtras,
5
+ type DeviceStepSampler, type StepExtras } from "../../sampler";
6
+ import { disposeResources, cleanupFailure } from "../../engine/resources";
7
+
8
+ type TokenSink = (token: number, logprobs?: TokenLogprobs) => void | boolean | Promise<void | boolean>;
9
+
10
+ /** Request-owned sampling and output metadata. Execution groups receive token
11
+ * tensors and an output callback; they need no logprob or retention policy. */
12
+ export function createRowSampling(
13
+ sampler: DeviceStepSampler, onToken: TokenSink,
14
+ ) {
15
+ if (!sampler.capturesLogprobs) return {
16
+ sample: (logits: MlxArray, step: number) => sampler.sample(logits, step).token,
17
+ plainGreedy: sampler.isPlainGreedy, onToken, dispose: () => sampler.dispose(),
18
+ };
19
+
20
+ // A pipelined group can sample the next token before emitting the previous
21
+ // one. Keep captures by request-local step, independently of row position.
22
+ const pending = new Map<number, StepExtras>();
23
+ let emitted = 0;
24
+ return {
25
+ sample(logits: MlxArray, step: number): MlxArray {
26
+ const result = sampler.sample(logits, step);
27
+ try {
28
+ ops.asyncEvalAll([result.token, ...stepExtrasArrays(result.extras)]);
29
+ if (result.extras) pending.set(step, result.extras);
30
+ return result.token;
31
+ } catch (error) {
32
+ return cleanupFailure(error, () => disposeResources([result.token,
33
+ { dispose: () => disposeStepExtras(result.extras) }]));
34
+ }
35
+ },
36
+ // The vectorized token-only shortcut bypasses the per-request sampler.
37
+ plainGreedy: false,
38
+ onToken(token: number) {
39
+ const extras = pending.get(emitted) ?? null;
40
+ pending.delete(emitted++);
41
+ return onToken(token, readStepExtras(extras));
42
+ },
43
+ dispose() {
44
+ const extras = [...pending.values()];
45
+ pending.clear();
46
+ disposeResources([sampler, ...extras.map(value => ({ dispose: () => disposeStepExtras(value) }))]);
47
+ },
48
+ };
49
+ }
@@ -1,32 +1,31 @@
1
+ import { pagedPrefixNamespace } from "./request-state-policy";
2
+ import type { SpecRunServices } from "../../spec/serve-loop";
1
3
  import { cleanupFailure, disposeResources, ownResource } from "../../engine/resources";
2
4
  import { bindGeneration } from "../../generate";
3
5
  import type { RuntimeModel } from "../../model/factory";
4
6
  import { Qwen35Model } from "../../model/qwen3_5";
5
7
  import type { Cache } from "../../model/gemma4";
6
- import type { PromptCache } from "../../prompt-cache";
8
+ import type { MlxPrefixCache } from "./checkpoint-state";
7
9
  import type { SsdCacheStore } from "../../ssd-cache";
8
10
  import type { DraftProvider } from "../../spec/source";
9
11
  import type { SerialRun, Vision } from "../../serve/generation-gateway";
10
- import { generationCheckpointKey } from "../../serve/checkpoint-identity";
12
+ import { bindContinuationPolicy } from "./continuation";
11
13
  import { runtimeConfig, withRuntimeConfig, type RuntimeConfig } from "../../runtime-config";
12
14
  import { bindLegacySpeculativeModel } from "./speculative";
13
- import { enterMlxMemoryGuard, type MlxMemoryBudget } from "./memory-guard";
14
15
 
15
16
  /** Native serial execution depends on this bound port, never a model union.
16
17
  * Weights remain borrowed. The gateway supplies the exclusive runtime lease. */
17
18
  export interface MlxSerialBinding {
18
19
  readonly runtime: RuntimeConfig;
19
20
  readonly generate: ReturnType<typeof bindGeneration>;
20
- readonly speculate?: SerialRun;
21
+ readonly speculate?: (
22
+ promptIds: Parameters<SerialRun>[0], options: Parameters<SerialRun>[1],
23
+ onToken: Parameters<SerialRun>[2], services?: SpecRunServices,
24
+ ) => ReturnType<SerialRun>;
21
25
  makeCache(): Cache[];
22
26
  enterMedia?(vision?: Vision): () => void;
23
- enterMemoryGuard?(budget: SerialMemoryBudget, promptTokens: number,
24
- prefillChunkSize?: number): { check(): void; close(): void };
25
27
  }
26
28
 
27
- /** Admission's ceiling and the cache that can release memory under the GPU lease. */
28
- export type SerialMemoryBudget = MlxMemoryBudget;
29
-
30
29
  /** Family-specific context is bound once at the compatibility boundary. */
31
30
  export function bindLegacySerialModel(
32
31
  model: RuntimeModel,
@@ -37,13 +36,10 @@ export function bindLegacySerialModel(
37
36
  runtime: runtimeConfig(),
38
37
  generate: bindGeneration(model),
39
38
  makeCache: model.makeCache.bind(model),
40
- enterMemoryGuard(budget, promptTokens, prefillChunkSize) {
41
- return enterMlxMemoryGuard(model, budget, promptTokens, prefillChunkSize);
42
- },
43
- ...(speculative && draft ? { speculate: (async (prompt, options, onToken) => {
39
+ ...(speculative && draft ? { speculate: (async (prompt, options, onToken, services) => {
44
40
  const { specRun } = await import("../../spec/serve-loop");
45
- return specRun(speculative, draft.numDraftTokens, prompt, options, onToken);
46
- }) satisfies SerialRun } : {}),
41
+ return specRun(speculative, draft.numDraftTokens, prompt, options, onToken, services);
42
+ }) satisfies NonNullable<MlxSerialBinding["speculate"]> } : {}),
47
43
  ...(model instanceof Qwen35Model ? { enterMedia(vision?: Vision) {
48
44
  const previous = model.mrope;
49
45
  if (vision?.mrope) model.mrope = vision.mrope;
@@ -53,11 +49,11 @@ export function bindLegacySerialModel(
53
49
  }
54
50
 
55
51
  export interface MlxSerialServices {
56
- readonly promptCache: Pick<PromptCache, "take" | "put"> & Partial<Pick<PromptCache, "maxBytes">>;
52
+ readonly promptCache: MlxPrefixCache & { readonly maxBytes?: number };
57
53
  readonly checkpoints: Pick<SsdCacheStore, "findGenerationCheckpoint" | "restore" |
58
54
  "storeGenerationCheckpoint" | "removeGenerationCheckpoints"> | null;
59
55
  readonly checkpointEveryTokens?: number;
60
- readonly memoryBudget?: SerialMemoryBudget;
56
+ readonly checkpointPersistence?: import("./continuation-persistence").ContinuationPersistence;
61
57
  /** Artifact, implementation, state ABI and codec identity captured at load. */
62
58
  readonly identity: unknown;
63
59
  adapterNamespace(adapters: string[]): string;
@@ -71,17 +67,25 @@ export interface MlxSerialServices {
71
67
  export function createMlxSerialExecutor(binding: MlxSerialBinding, services: MlxSerialServices): SerialRun {
72
68
  const { promptCache, checkpoints: ssdStore } = services;
73
69
  return (promptIds, options, onToken, vision, trace, execution) => withRuntimeConfig(binding.runtime, async () => {
70
+ if (promptCache.reclaim) {
71
+ const publish = onToken;
72
+ let steps = 0;
73
+ onToken = (token, logprobs) => {
74
+ if (steps++ % 256 === 0) promptCache.reclaim!();
75
+ return publish(token, logprobs);
76
+ };
77
+ }
78
+ let releaseContinuation: (() => void) | undefined;
74
79
  let caches: Cache[] = [];
75
80
  let retain: (() => void) | undefined;
76
81
  let closeMedia: (() => void) | undefined;
77
- let memoryGuard: ReturnType<NonNullable<MlxSerialBinding["enterMemoryGuard"]>> | undefined;
78
82
  const cleanup = ownResource(null, () => disposeResources([
79
83
  { dispose() {
80
84
  disposeResources(caches);
81
85
  retain?.(); // backing release requires successful cache disposal
82
86
  } },
83
87
  { dispose: () => closeMedia?.() },
84
- { dispose: () => memoryGuard?.close() },
88
+ { dispose: () => releaseContinuation?.() },
85
89
  ...[options.grammar, vision?.embeddings, vision?.imageMask,
86
90
  vision?.multimodalMask, options.visionPixels].filter((value) => value != null),
87
91
  ]));
@@ -90,53 +94,48 @@ export function createMlxSerialExecutor(binding: MlxSerialBinding, services: Mlx
90
94
  if (!execution) throw new Error("serial execution requires a resolved plan");
91
95
  if (execution.method === "speculative") {
92
96
  if (!binding.speculate) throw new Error("resolved speculation requires a bound verifier");
93
- return await binding.speculate(promptIds, {
94
- ...options,
95
- speculativeCacheBytes: binding.runtime.flag("MLX_BUN_MTP_PROMPT_CACHE", false)
96
- ? services.promptCache.maxBytes ?? 0 : 0,
97
- }, onToken);
97
+ return await binding.speculate(promptIds, options, onToken, {
98
+ ...(binding.runtime.flag("MLX_BUN_MTP_PROMPT_CACHE", true) && services.promptCache.maxBytes !== 0
99
+ ? { prefixCache: services.promptCache } : {}),
100
+ cloneState: services.cloneState,
101
+ cacheNamespace: options.adapters?.length ? services.adapterNamespace(options.adapters) : "",
102
+ });
98
103
  }
99
104
  // Cache entries are adapter-specific: KV computed under one adapter
100
105
  // must never seed another's (or the base's) prefill.
101
- const cacheNs = options.adapters?.length ? services.adapterNamespace(options.adapters) : "";
106
+ const baseNs = options.adapters?.length ? services.adapterNamespace(options.adapters) : "";
107
+ const cacheNs = execution.pagedKv ? pagedPrefixNamespace(options, baseNs) : baseNs;
102
108
  // Paged-KV request scope (docs/design/kv-cache.md): media
103
109
  // prompts (bidir overlay) and LoRA-adapter requests are v1 non-goals —
104
110
  // they run the PLAIN cache path even under --paged-kv (scope the flag
105
111
  // per request, never 400). Effective value computed ONCE so the
106
112
  // prompt-cache bypass below and the generate() options can't disagree.
107
113
  const pagedKv = execution.pagedKv ? options.pagedKv : undefined;
108
- // Paged requests bypass the prompt cache entirely (v1 non-goal:
109
- // PagedKVCache has no cloneKvCaches/restore path — the vision
110
- // precedent). Fresh caches per request, disposed on completion.
114
+ // The resolved policy controls reuse; paged layouts have their own
115
+ // numerical namespace and use the same RAM/SSD cache interface.
111
116
  const skipPromptCache = !execution.promptCache;
112
117
  const checkpointEvery = services.checkpointEveryTokens;
113
118
  const checkpointEligible = execution.checkpoint;
114
- const checkpointKey = checkpointEligible
115
- ? generationCheckpointKey(promptIds, options, cacheNs, execution, services.identity)
116
- : null;
119
+ const continuation = checkpointEligible ? bindContinuationPolicy({ store: ssdStore!,
120
+ restore: entry => ssdStore!.restore(entry, binding), prompt: promptIds,
121
+ options, execution, identity: services.identity, namespace: cacheNs, persistence: services.checkpointPersistence }) : null;
122
+ releaseContinuation = continuation?.release;
123
+ const checkpointKey = continuation?.key;
117
124
  // Both tiers in one call (Layer 0): take() prefers a strictly-longer
118
125
  // SSD prefix, restores it zero-copy, and trims — see PromptCache.take.
119
126
  const closeCacheLookup = trace?.begin("cache.lookup_restore", {
120
127
  mechanism: "serial",
121
128
  bypassed: skipPromptCache,
122
129
  });
123
- const checkpointEntry = checkpointKey
124
- ? ssdStore!.findGenerationCheckpoint(promptIds, checkpointKey, cacheNs)
125
- : null;
126
- const restoredCheckpoint = checkpointEntry
127
- ? ssdStore!.restore(checkpointEntry, binding)
128
- : null;
129
- caches = restoredCheckpoint?.caches ?? [];
130
- const checkpoint = restoredCheckpoint?.header.generationCheckpoint;
131
- if (restoredCheckpoint && !checkpoint)
132
- throw new Error("restored generation checkpoint has no continuation metadata");
133
- const resuming = Boolean(restoredCheckpoint && checkpoint);
134
- const generationPromptIds = resuming ? restoredCheckpoint!.tokens : promptIds;
130
+ const checkpoint = continuation?.restore();
131
+ caches = checkpoint?.caches ?? [];
132
+ const resuming = Boolean(checkpoint);
133
+ const generationPromptIds = checkpoint?.cacheTokens ?? promptIds;
135
134
  const entry = skipPromptCache || resuming
136
135
  ? null
137
- : promptCache.take(promptIds, cacheNs);
136
+ : promptCache.take(promptIds, cacheNs, options.cacheSessionId);
138
137
  if (entry) { caches = entry.caches; retain = entry.retain; }
139
- if (!restoredCheckpoint && !entry) caches = binding.makeCache();
138
+ if (!checkpoint && !entry) caches = binding.makeCache();
140
139
  closeCacheLookup?.();
141
140
  // Prompt-boundary snapshot (the multi-turn agent fix, 2026-07-04): the
142
141
  // prompt+gen entry put() below is UNTRIMMABLE at context > sliding
@@ -160,12 +159,6 @@ export function createMlxSerialExecutor(binding: MlxSerialBinding, services: Mlx
160
159
  const snapshotBoundary =
161
160
  !skipPromptCache && !resuming && boundary >= 256 &&
162
161
  boundary > (entry?.tokens.length ?? 0);
163
- if (services.memoryBudget) {
164
- memoryGuard = binding.enterMemoryGuard?.(
165
- services.memoryBudget, generationPromptIds.length, options.prefillChunkSize,
166
- );
167
- memoryGuard?.check();
168
- }
169
162
  closeMedia = binding.enterMedia?.(vision);
170
163
  if (resuming) {
171
164
  const replay = generationPromptIds.slice(promptIds.length);
@@ -190,7 +183,7 @@ export function createMlxSerialExecutor(binding: MlxSerialBinding, services: Mlx
190
183
  ? {
191
184
  initialPendingToken: checkpoint!.pendingToken,
192
185
  initialGeneratedTokens: checkpoint!.generatedTokens,
193
- originalPromptTokens: checkpoint!.originalPromptTokens,
186
+ originalPromptTokens: promptIds.length,
194
187
  }
195
188
  : {}),
196
189
  ...(checkpointEligible
@@ -202,23 +195,9 @@ export function createMlxSerialExecutor(binding: MlxSerialBinding, services: Mlx
202
195
  generatedTokens: number;
203
196
  pendingToken: number;
204
197
  }) => {
205
- const stored = await ssdStore!.storeGenerationCheckpoint(
206
- state.cacheTokens,
207
- state.caches,
208
- {
209
- key: checkpointKey!,
210
- cacheNs,
211
- originalPromptTokens: promptIds.length,
212
- generatedTokens: state.generatedTokens,
213
- pendingToken: state.pendingToken,
214
- seed: options.seed ?? 0,
215
- seedWasExplicit: options.seedWasExplicit === true,
216
- },
217
- );
218
- if (stored)
219
- console.log(
220
- `[generation-checkpoint] saved ${state.generatedTokens} emitted tokens`,
221
- );
198
+ if (services.checkpointPersistence)
199
+ continuation!.captureOwned({ ...state, caches: services.cloneState(state.caches) });
200
+ else await continuation!.capture(state);
222
201
  },
223
202
  }
224
203
  : {}),
@@ -234,7 +213,7 @@ export function createMlxSerialExecutor(binding: MlxSerialBinding, services: Mlx
234
213
  try {
235
214
  const snapshot = ownResource(services.cloneState(caches), disposeResources);
236
215
  try {
237
- promptCache.put(promptIds.slice(0, boundary), snapshot.borrow(), cacheNs);
216
+ promptCache.put(promptIds.slice(0, boundary), snapshot.borrow(), cacheNs, undefined, undefined, options.cacheSessionId);
238
217
  snapshot.transfer();
239
218
  } finally { snapshot.close(); }
240
219
  } catch (e) {
@@ -252,15 +231,14 @@ export function createMlxSerialExecutor(binding: MlxSerialBinding, services: Mlx
252
231
  : {}),
253
232
  }, { trace, mechanism: "serial" });
254
233
  for await (const t of gen) {
255
- if (t.index % 256 === 0) memoryGuard?.check();
256
234
  if ((await onToken(t.token, t.logprobs)) === false) break;
257
235
  }
258
236
  const s = gen.stats!; // set on completion AND on early break
259
- if (checkpointKey) ssdStore!.removeGenerationCheckpoints(checkpointKey);
237
+ continuation?.complete();
260
238
  if (!skipPromptCache) {
261
239
  // put() fires onPut → the debounced write-behind SSD snapshot
262
240
  // (wired below), covering the batch lane's puts too.
263
- promptCache.put(s.cacheTokens, caches, cacheNs, retain);
241
+ promptCache.put(s.cacheTokens, caches, cacheNs, retain, undefined, options.cacheSessionId);
264
242
  caches = []; retain = undefined; // ownership returned to the prefix store
265
243
  }
266
244
  return s;