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
@@ -1,3 +1,5 @@
1
+ import { namespacedCache } from "../../engine/namespaced-cache";
2
+ import { runtimeConfig, runtimeFlag, withRuntimeConfig, type RuntimeConfig } from "../../runtime-config";
1
3
  import type { GenerateOptions } from "../../generate";
2
4
  import type { RuntimeModel } from "../../model/factory";
3
5
  import { KVCache, type Cache } from "../../model/gemma4-base";
@@ -24,18 +26,29 @@ export function maybePageKv(
24
26
  for (let i = 0; i < cache.length; i++) {
25
27
  if (cache[i] instanceof KVCache) {
26
28
  cache[i]!.dispose(); // fresh (offset 0) — nothing stored yet
27
- cache[i] = new PagedKVCache(capacityTokens, blockSize);
29
+ cache[i] = new PagedKVCache(capacityTokens, blockSize, undefined,
30
+ options.kvBits === 4 || options.kvBits === 8 ? { bits: options.kvBits, groupSize: options.kvGroupSize ?? 64 } : undefined);
28
31
  }
29
32
  }
30
33
  }
31
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
+
32
41
  export function bindPagedRequestState(model: RuntimeModel, options: GenerateOptions,
33
- capacityTokens: number): MlxRequestStatePolicy | undefined {
42
+ capacityTokens: number, cache?: RowPromptCache, runtime: RuntimeConfig = runtimeConfig()): MlxRequestStatePolicy | undefined {
34
43
  if (!options.pagedKv) return undefined;
35
44
  const blockSize = options.pagedKv.blockSize ?? PagedKVCache.DEFAULT_BLOCK_SIZE;
36
- return { key: `paged:${blockSize}`, create() {
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, () => {
37
50
  const caches = model.makeCache();
38
- try { maybePageKv(caches, { pagedKv: { blockSize } }, capacityTokens); return caches; }
51
+ try { maybePageKv(caches, stateOptions, capacityTokens); return caches; }
39
52
  catch (error) { disposeResources(caches); throw error; }
40
- } };
53
+ }) };
41
54
  }
@@ -3,7 +3,7 @@ import type { Cache } from "../../model/gemma4-base";
3
3
 
4
4
  interface TransactionLayer<Acceptance> {
5
5
  fits(drafts: number): boolean;
6
- begin(): void;
6
+ begin(drafts: number): void;
7
7
  resolve(accepted: Acceptance, drafts: number): void;
8
8
  }
9
9
 
@@ -20,8 +20,11 @@ export function bindRowCacheRollback(
20
20
  ): SpeculativeTransaction<readonly number[]> {
21
21
  return bindTransaction(caches.map(cache => ({
22
22
  fits: () => true,
23
- begin: () => cache.specRoundBegin(),
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(); },
24
26
  resolve(accepted: readonly number[], drafts: number) {
27
+ if (drafts === 0) return;
25
28
  if (accepted.every(count => count === drafts)) cache.specRoundCommit();
26
29
  else cache.specRoundRollback(accepted.every(count => count === accepted[0])
27
30
  ? accepted[0]! + 1 : accepted.map(count => count + 1));
@@ -75,7 +78,7 @@ function bindTransaction<Acceptance>(
75
78
  if (active !== undefined || !layers.every((layer) => layer.fits(drafts)))
76
79
  throw new Error("speculative state cannot begin this round");
77
80
  active = drafts;
78
- try { for (const layer of layers) layer.begin(); }
81
+ try { for (const layer of layers) layer.begin(drafts); }
79
82
  catch (error) { invalid = true; throw error; }
80
83
  },
81
84
  resolve(accepted) {
@@ -1,3 +1,4 @@
1
+ import { pagedPrefixNamespace } from "./request-state-policy";
1
2
  import type { SpecRunServices } from "../../spec/serve-loop";
2
3
  import { cleanupFailure, disposeResources, ownResource } from "../../engine/resources";
3
4
  import { bindGeneration } from "../../generate";
@@ -66,6 +67,14 @@ export interface MlxSerialServices {
66
67
  export function createMlxSerialExecutor(binding: MlxSerialBinding, services: MlxSerialServices): SerialRun {
67
68
  const { promptCache, checkpoints: ssdStore } = services;
68
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
+ }
69
78
  let releaseContinuation: (() => void) | undefined;
70
79
  let caches: Cache[] = [];
71
80
  let retain: (() => void) | undefined;
@@ -94,16 +103,16 @@ export function createMlxSerialExecutor(binding: MlxSerialBinding, services: Mlx
94
103
  }
95
104
  // Cache entries are adapter-specific: KV computed under one adapter
96
105
  // must never seed another's (or the base's) prefill.
97
- 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;
98
108
  // Paged-KV request scope (docs/design/kv-cache.md): media
99
109
  // prompts (bidir overlay) and LoRA-adapter requests are v1 non-goals —
100
110
  // they run the PLAIN cache path even under --paged-kv (scope the flag
101
111
  // per request, never 400). Effective value computed ONCE so the
102
112
  // prompt-cache bypass below and the generate() options can't disagree.
103
113
  const pagedKv = execution.pagedKv ? options.pagedKv : undefined;
104
- // Paged requests bypass the prompt cache entirely (v1 non-goal:
105
- // PagedKVCache has no cloneKvCaches/restore path — the vision
106
- // 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.
107
116
  const skipPromptCache = !execution.promptCache;
108
117
  const checkpointEvery = services.checkpointEveryTokens;
109
118
  const checkpointEligible = execution.checkpoint;
@@ -124,7 +133,7 @@ export function createMlxSerialExecutor(binding: MlxSerialBinding, services: Mlx
124
133
  const generationPromptIds = checkpoint?.cacheTokens ?? promptIds;
125
134
  const entry = skipPromptCache || resuming
126
135
  ? null
127
- : promptCache.take(promptIds, cacheNs);
136
+ : promptCache.take(promptIds, cacheNs, options.cacheSessionId);
128
137
  if (entry) { caches = entry.caches; retain = entry.retain; }
129
138
  if (!checkpoint && !entry) caches = binding.makeCache();
130
139
  closeCacheLookup?.();
@@ -204,7 +213,7 @@ export function createMlxSerialExecutor(binding: MlxSerialBinding, services: Mlx
204
213
  try {
205
214
  const snapshot = ownResource(services.cloneState(caches), disposeResources);
206
215
  try {
207
- promptCache.put(promptIds.slice(0, boundary), snapshot.borrow(), cacheNs);
216
+ promptCache.put(promptIds.slice(0, boundary), snapshot.borrow(), cacheNs, undefined, undefined, options.cacheSessionId);
208
217
  snapshot.transfer();
209
218
  } finally { snapshot.close(); }
210
219
  } catch (e) {
@@ -229,7 +238,7 @@ export function createMlxSerialExecutor(binding: MlxSerialBinding, services: Mlx
229
238
  if (!skipPromptCache) {
230
239
  // put() fires onPut → the debounced write-behind SSD snapshot
231
240
  // (wired below), covering the batch lane's puts too.
232
- promptCache.put(s.cacheTokens, caches, cacheNs, retain);
241
+ promptCache.put(s.cacheTokens, caches, cacheNs, retain, undefined, options.cacheSessionId);
233
242
  caches = []; retain = undefined; // ownership returned to the prefix store
234
243
  }
235
244
  return s;
@@ -1,6 +1,7 @@
1
1
  import {captureSpeculativeOptions, speculativePrefixNamespace} from "../../spec/cache-identity";
2
2
  import type { RuntimeModel } from "../../model/factory";
3
3
  import type { GenerateOptions } from "../../generate";
4
+ import type { FillSession, Proposal } from "../../fill/fill-session";
4
5
  import type { DraftProvider, DraftRowGroup, DraftPrefillGroup, DraftRowCheckpoint } from "../../spec/source";
5
6
  import { makeSampler, makeStepSampler, readStepExtras, type NumberStepSampler, type Sampler } from "../../sampler";
6
7
  import { MlxArray } from "../../mlx/array";
@@ -11,16 +12,19 @@ import { cloneKvCaches } from "../../kv-store";
11
12
  import type { Cache } from "../../model/gemma4-base";
12
13
  import { targetCacheLayout } from "./cache-layout";
13
14
  import { MlxStateRows } from "./state-rows";
14
- import { bindLegacySpeculativeModel, type MlxSpeculativeBinding } from "./speculative";
15
+ import { bindSpeculativeTargetModel, type MlxSpeculativeTargetBinding } from "./speculative";
15
16
  import { bindLegacyDraftTarget } from "./draft-target";
16
17
  import { bindRowCacheRollback } from "./rollback";
17
18
  import { createKvMaintenance } from "./kv-maintenance";
18
19
  import { disposeAttachments, type CheckpointAttachment } from "./checkpoint-state";
19
20
  import { advanceSpeculativeOutputs } from "./speculative-round";
20
21
  import { MlxPrefillRows, type MlxPrefillState } from "./prefill-rows";
22
+ import type { MlxForwardWork } from "./mixed-iteration";
21
23
  import type { MlxGroupMethodHost, MlxGroupMethodRequest, MlxGroupPreparation, MlxGroupedMethod, Row } from "./batch-group";
22
24
 
23
25
  interface RequestState {
26
+ fill?: FillSession;
27
+ proposal?: { value: Proposal; emitted: number };
24
28
  sampling: NumberStepSampler;
25
29
  draftSampling: Sampler;
26
30
  retain?: () => void;
@@ -33,8 +37,8 @@ interface RequestState {
33
37
 
34
38
  /** Binding owns graph/layout selection. The executor receives only the method
35
39
  * key and lifecycle; sampling, checkpoints and numerical state remain ports. */
36
- export function bindSpeculativeGroupRequests(model: RuntimeModel, provider: DraftProvider, depth: number) {
37
- const binding = bindLegacySpeculativeModel(model, provider);
40
+ export function bindSpeculativeGroupRequests(model: RuntimeModel, provider: Pick<DraftProvider, "id" | "grouped">, depth: number) {
41
+ const binding = bindSpeculativeTargetModel(model);
38
42
  return (input: GenerateOptions): MlxGroupMethodRequest => {
39
43
  const options = captureSpeculativeOptions(input);
40
44
  return {
@@ -54,7 +58,18 @@ class SpeculativeGroup implements MlxGroupedMethod {
54
58
  #steps = 0;
55
59
 
56
60
  constructor(readonly host: MlxGroupMethodHost, readonly model: RuntimeModel,
57
- readonly provider: DraftProvider, readonly binding: MlxSpeculativeBinding, readonly depth: number) {}
61
+ readonly provider: Pick<DraftProvider, "id" | "grouped">, readonly binding: MlxSpeculativeTargetBinding, readonly depth: number) {}
62
+
63
+ get runningTokens(): number {
64
+ const rows = this.host.rows;
65
+ if (!rows.length) return 0;
66
+ // Target verification consumes the pending token and the draft candidates.
67
+ const echo = Math.max(...rows.map(row => {
68
+ const proposal = this.#requests.get(row)?.proposal;
69
+ return proposal?.emitted ? proposal.value.ids.length - proposal.emitted : 0;
70
+ }));
71
+ return rows.length * (1 + (echo || Math.min(this.depth, Math.max(...rows.map(row => row.req.maxTokens - row.generated)))));
72
+ }
58
73
 
59
74
  prepare(first: Row): MlxGroupPreparation {
60
75
  const method = this;
@@ -91,7 +106,7 @@ class SpeculativeGroup implements MlxGroupedMethod {
91
106
  try {
92
107
  caches = snapshot(); attachments.push(prefix!.capture(index).attachment);
93
108
  method.host.promptCache!.put(state.row.req.promptIds.slice(0, state.pos), caches,
94
- state.request.namespace, undefined, attachments);
109
+ state.request.namespace, undefined, attachments, state.row.req.cacheSessionId);
95
110
  caches = []; attachments = [];
96
111
  } finally { disposeResources([...caches, { dispose: () => disposeAttachments(attachments) }]); }
97
112
  };
@@ -103,11 +118,13 @@ class SpeculativeGroup implements MlxGroupedMethod {
103
118
  acceptanceLengths: [], tokensPerForward: 0, forwardsSaved: 0, draftedByPos: [], acceptedByPos: [] };
104
119
  const sampler = makeSampler(options);
105
120
  const request: RequestState = { draftSampling: sampler, namespace: "", prefixLength: 0,
121
+ fill: method.provider.grouped?.supportsExternalTokens ? options.fill : undefined,
106
122
  processed: method.host.promptCache ? [] : undefined,
107
123
  sampling: makeStepSampler(options, { tokenRepresentation: "number", grammarWait: "before-sample",
108
124
  historyUpdate: "after-sample", initialHistory: row.req.promptIds, acceptGrammar: true,
109
125
  eosTokenIds: row.req.eosTokenIds, captureSelectedLogprob: options.logprobs === true,
110
126
  captureTopLogprobs: options.topLogprobs }) };
127
+ if (request.fill) row.fill = request.fill.stats;
111
128
  let caches: Cache[] = [], retained: (() => void) | undefined;
112
129
  let appended = false;
113
130
  const previous = prefix?.rowCount ?? 0;
@@ -118,7 +135,7 @@ class SpeculativeGroup implements MlxGroupedMethod {
118
135
  const pendingPrompt = prefix.prefillMode !== "full" && method.binding.prefillTailSplit && prompt.length > 1;
119
136
  request.prefixLength = prompt.length - Number(pendingPrompt);
120
137
  request.namespace = speculativePrefixNamespace(prefix.namespace, row.cacheNamespace ?? "", options);
121
- const hit = method.host.promptCache?.take(prompt, request.namespace);
138
+ const hit = method.host.promptCache?.take(prompt, request.namespace, row.req.cacheSessionId);
122
139
  if (hit) {
123
140
  try {
124
141
  disposeResources(caches); caches = hit.caches.splice(0);
@@ -134,7 +151,7 @@ class SpeculativeGroup implements MlxGroupedMethod {
134
151
  } else { prefix.append([null]); appended = true; }
135
152
  return { row, request, solo: caches, retain: retained, pos: row.cachedTokens,
136
153
  end: request.prefixLength, pendingPrompt, transferred: false, closed: false,
137
- chunkSize: options.prefillChunkSize ?? method.host.prefillChunkSize,
154
+ chunkSize: options.prefillChunkSize ?? row.req.prefillChunkSize ?? method.host.prefillChunkSize,
138
155
  boundary: method.host.promptCache ? Math.min(row.req.snapshotAt ?? prompt.length, prompt.length - 1) : -1 };
139
156
  } catch (error) {
140
157
  return cleanupFailure(error, () => disposeResources([
@@ -148,9 +165,9 @@ class SpeculativeGroup implements MlxGroupedMethod {
148
165
  return { start: state.pos, end, kind: end === state.end ? "final" : "drain",
149
166
  snapshot: end === state.boundary && end > state.row.cachedTokens, batchYield: true, maintain: true };
150
167
  },
151
- async forward(ids, caches) {
168
+ async forward(ids, caches, _states, work) {
152
169
  const taps = prefix!.tapLayers;
153
- const output = await method.binding.forward(ids, caches, taps.length ? [...taps] : undefined);
170
+ const output = await method.binding.forward(ids, caches, taps.length ? [...taps] : undefined, work);
154
171
  context = output.ctxML;
155
172
  return output.hidden;
156
173
  },
@@ -184,12 +201,14 @@ class SpeculativeGroup implements MlxGroupedMethod {
184
201
  method.host.finish(row, "stop"); return;
185
202
  }
186
203
  const more = await method.host.publish(row, row.current, readStepExtras(sampled.extras));
204
+ state.request.fill?.observe(row.current);
187
205
  if (more === false || row.req.grammar?.isTerminated || row.generated >= row.req.maxTokens) {
188
206
  if (method.host.promptCache && !row.req.signal?.aborted) capture(state, () => cloneKvCaches(state.solo), index);
189
207
  disposeResources(state.solo.splice(0)); state.retain?.(); state.retain = undefined;
190
208
  method.host.finish(row, more === false || row.req.grammar?.isTerminated ? "stop" : "length"); return;
191
209
  }
192
210
  }
211
+ method.#proposeEcho(row, state.request);
193
212
  ready.push({ row, request: state.request, caches: state.solo, retain: state.retain,
194
213
  draft: prefix!.capture(index), transferred: false });
195
214
  state.transferred = true;
@@ -211,7 +230,7 @@ class SpeculativeGroup implements MlxGroupedMethod {
211
230
  get canAdmit() { return target.canAdmit; },
212
231
  get tokenWeight() { return target.tokenWeight + ready.reduce((sum, state) => sum + state.row.promptTokens - state.row.cachedTokens, 0); },
213
232
  admit: row => target.admit(row),
214
- advance: async () => {
233
+ advance: async work => {
215
234
  // Publication returns to scheduling before prepared state joins active
216
235
  // decode. This preserves the first-output flush boundary at every B.
217
236
  if (ready.length) {
@@ -225,7 +244,8 @@ class SpeculativeGroup implements MlxGroupedMethod {
225
244
  maintain.prepareBatch?.(state.caches);
226
245
  this.#target ??= new MlxStateRows(state.caches.map(targetCacheLayout));
227
246
  this.#draft ??= this.provider.grouped!.open({ target: bindLegacyDraftTarget(this.model, this.#target.caches),
228
- checkpoints: [], sampling: { sample: (lp, steps) => this.#sampleDraftRows(lp, steps) } });
247
+ checkpoints: [], sampling: { sample: (lp, steps) => this.#sampleDraftRows(lp, steps) },
248
+ constraints: { propose: async (row, maxTokens) => this.host.rows[row]!.req.grammar?.proposeTokens(maxTokens) ?? [] } });
229
249
  applyStateChanges([() => this.#target!.prepareAppend(state.caches),
230
250
  () => this.#draft!.prepareAppend([state.draft]), () => ({ commit: () => {
231
251
  state.request.retain = state.retain; state.retain = undefined;
@@ -236,7 +256,7 @@ class SpeculativeGroup implements MlxGroupedMethod {
236
256
  }
237
257
  return target.rows.length === 0;
238
258
  }
239
- const done = await target.advance();
259
+ const done = await target.advance(work);
240
260
  return done && ready.length === 0;
241
261
  },
242
262
  dispose() {
@@ -259,7 +279,7 @@ class SpeculativeGroup implements MlxGroupedMethod {
259
279
  } finally { disposeResources(tokens); }
260
280
  }
261
281
 
262
- async advance(): Promise<void> {
282
+ async advance(work?: MlxForwardWork): Promise<void> {
263
283
  const active = [...this.host.rows];
264
284
  const live = active.flatMap((row, index) => row.req.signal?.aborted ? [] : [index]);
265
285
  if (live.length !== active.length) {
@@ -269,6 +289,19 @@ class SpeculativeGroup implements MlxGroupedMethod {
269
289
  const rows = [...this.host.rows];
270
290
  if (!rows.length) return;
271
291
  const depth = Math.min(this.depth, Math.max(...rows.map(row => row.req.maxTokens - row.generated)));
292
+ // The ordinary MTP round already supplies samples that can establish a
293
+ // copied prefix. Do not verify a wide span before any sample matches it.
294
+ const proposals = rows.map(row => {
295
+ const proposal = this.#requests.get(row)!.proposal;
296
+ return proposal?.emitted ? proposal.value.ids.slice(proposal.emitted) : [];
297
+ });
298
+ const echoDepth = Math.max(...proposals.map(ids => ids.length));
299
+ let externalTokens: MlxArray | undefined;
300
+ const rollback = bindRowCacheRollback(this.#target!.caches, rows.length);
301
+ const fills = echoDepth ? rows.flatMap((row, index) => {
302
+ const request = this.#requests.get(row)!;
303
+ return proposals[index]!.length ? [request.fill!] : [];
304
+ }) : [];
272
305
  const halted = new Set<Row>();
273
306
  const pin = this.binding.pinVerify?.();
274
307
  try {
@@ -280,11 +313,27 @@ class SpeculativeGroup implements MlxGroupedMethod {
280
313
  const more = await this.host.publish(row, ids[0]!, metadata?.[0]);
281
314
  if (more === false) { halted.add(row); return false; }
282
315
  } },
283
- })), depth, this.#draft!, {
284
- transaction: bindRowCacheRollback(this.#target!.caches, rows.length),
316
+ })), echoDepth || depth, echoDepth ? {
317
+ draft: () => proposals,
318
+ commit: (accepted, context) => this.#draft!.consume!(externalTokens!, context, accepted.map(n => n + 1)),
319
+ } : this.#draft!, {
320
+ transaction: !echoDepth ? rollback : { ...rollback,
321
+ begin(depth) {
322
+ const start = performance.now(); rollback.begin(depth);
323
+ const elapsed = performance.now() - start;
324
+ for (const fill of fills) fill.noteVerifyEvent(elapsed);
325
+ },
326
+ resolve(accepted) {
327
+ const start = performance.now(); rollback.resolve(accepted);
328
+ const elapsed = performance.now() - start;
329
+ for (const fill of fills) fill.stats.checkpointMs += elapsed;
330
+ },
331
+ },
285
332
  forward: async ids => {
333
+ if (echoDepth) externalTokens = ops.copyOf(ids);
286
334
  const taps = this.#draft!.tapLayers;
287
- const result = await this.binding.forward(ids, this.#target!.caches, taps.length ? [...taps] : undefined);
335
+ const result = await this.binding.forward(ids, this.#target!.caches, taps.length ? [...taps] : undefined,
336
+ work ? (tokens, caches, options) => work(tokens, caches, { ...options, preserveTokenGeometry: true }) : undefined);
288
337
  const context = result.ctxML ?? result.hidden;
289
338
  try { return { logits: this.binding.projectLogits(result.hidden), context }; }
290
339
  catch (error) { context.dispose(); throw error; }
@@ -295,23 +344,39 @@ class SpeculativeGroup implements MlxGroupedMethod {
295
344
  for (let index = 0; index < rows.length; index++) {
296
345
  const row = rows[index]!, output = completed.outputs[index]!, round = completed.rounds[index]!;
297
346
  const request = this.#requests.get(row)!;
347
+ if (request.fill) {
348
+ for (const token of round.acceptance.emitted.slice(0, output.generated)) {
349
+ const proposal = request.proposal;
350
+ if (proposal && token === proposal.value.ids[proposal.emitted]) {
351
+ if (++proposal.emitted === proposal.value.ids.length) this.#settleEcho(request);
352
+ } else {
353
+ this.#settleEcho(request); request.fill.observe(token);
354
+ }
355
+ }
356
+ if (output.kind === "continue") this.#proposeEcho(row, request);
357
+ else this.#settleEcho(request);
358
+ }
298
359
  if (output.kind !== "failed" && output.kind !== "cancelled")
299
360
  request.processed?.push(row.current, ...round.drafts.slice(0, output.accepted));
300
361
  const stats = row.spec!;
301
- stats.drafted += round.drafts.length;
302
- stats.accepted += round.acceptance.accepted;
362
+ stats.drafted += echoDepth ? 0 : round.drafts.length;
363
+ stats.accepted += echoDepth ? 0 : round.acceptance.accepted;
303
364
  stats.rejected = stats.drafted - stats.accepted;
304
- stats.rounds!++; stats.targetCalls++;
305
- stats.acceptanceLengths!.push(round.acceptance.accepted);
306
- for (let p = 0; p < round.drafts.length; p++) stats.draftedByPos![p] = (stats.draftedByPos![p] ?? 0) + 1;
307
- for (let p = 0; p < round.acceptance.accepted; p++) stats.acceptedByPos![p] = (stats.acceptedByPos![p] ?? 0) + 1;
365
+ stats.targetCalls++;
366
+ if (!echoDepth) {
367
+ stats.rounds!++;
368
+ stats.acceptanceLengths!.push(round.acceptance.accepted);
369
+ for (let p = 0; p < round.drafts.length; p++) stats.draftedByPos![p] = (stats.draftedByPos![p] ?? 0) + 1;
370
+ for (let p = 0; p < round.acceptance.accepted; p++) stats.acceptedByPos![p] = (stats.acceptedByPos![p] ?? 0) + 1;
371
+ }
308
372
  row.sampled += round.acceptance.emitted.length + Number(round.acceptance.sawEos);
309
373
  // A consumer may stop inside a verified burst before its terminal EOS.
310
374
  if (output.kind === "stop" && !halted.has(row) && round.acceptance.sawEos &&
311
375
  output.generated === round.acceptance.emitted.length && row.generated < row.req.maxTokens)
312
376
  row.generated++;
313
- stats.tokensPerForward = row.generated / stats.rounds!;
314
- stats.forwardsSaved = Math.max(0, row.generated - 1 - stats.rounds!);
377
+ const forwards = stats.targetCalls - 1;
378
+ stats.tokensPerForward = row.generated / forwards;
379
+ stats.forwardsSaved = Math.max(0, row.generated - 1 - forwards);
315
380
  if (output.kind === "continue") { row.current = output.pending; keep.push(index); }
316
381
  else if (output.kind === "failed") row.reject(output.error);
317
382
  else if (output.kind === "cancelled") row.reject(row.req.signal?.reason ?? new Error(output.reason));
@@ -322,7 +387,19 @@ class SpeculativeGroup implements MlxGroupedMethod {
322
387
  }
323
388
  if (keep.length !== rows.length) this.host.filterRows(keep);
324
389
  if (++this.#steps % 256 === 0) clearCache();
325
- } finally { pin?.close(); }
390
+ } finally { externalTokens?.dispose(); pin?.close(); }
391
+ }
392
+
393
+ #proposeEcho(row: Row, request: RequestState): void {
394
+ if (request.proposal || !request.fill) return;
395
+ const value = request.fill.propose(row.req.maxTokens - row.generated, "verify");
396
+ if (value) request.proposal = { value, emitted: 0 };
397
+ }
398
+
399
+ #settleEcho(request: RequestState): void {
400
+ if (!request.proposal) return;
401
+ request.fill!.commit(request.proposal.value, request.proposal.emitted);
402
+ request.proposal = undefined;
326
403
  }
327
404
 
328
405
  /** The method aligns token coverage and captures its state. The cache owns
@@ -334,7 +411,7 @@ class SpeculativeGroup implements MlxGroupedMethod {
334
411
  target = this.#target!.extractRow(index);
335
412
  attachments.push(this.#draft!.capture(index).attachment);
336
413
  this.host.promptCache.put([...row.req.promptIds.slice(0, request.prefixLength), ...request.processed], target,
337
- request.namespace, undefined, attachments);
414
+ request.namespace, undefined, attachments, row.req.cacheSessionId);
338
415
  target = []; attachments = [];
339
416
  } finally { disposeResources([...target, { dispose: () => disposeAttachments(attachments) }]); }
340
417
  }
@@ -344,6 +421,7 @@ class SpeculativeGroup implements MlxGroupedMethod {
344
421
  const retained = new Set(keep.map(index => this.host.rows[index]!));
345
422
  const releases: { dispose(): void }[] = [];
346
423
  for (const [row, request] of this.#requests) if (!retained.has(row)) {
424
+ this.#settleEcho(request);
347
425
  this.#requests.delete(row);
348
426
  releases.push(request.sampling, { dispose: () => request.retain?.() });
349
427
  }
@@ -351,6 +429,7 @@ class SpeculativeGroup implements MlxGroupedMethod {
351
429
  }
352
430
 
353
431
  dispose(): void {
432
+ for (const request of this.#requests.values()) this.#settleEcho(request);
354
433
  const resources = [...(this.#target ? [this.#target] : []), ...(this.#draft ? [this.#draft] : []),
355
434
  ...[...this.#requests.values()].flatMap(request => [request.sampling, { dispose: () => request.retain?.() }])];
356
435
  this.#target = null; this.#draft = null; this.#requests.clear();
@@ -10,12 +10,16 @@ import type { SpeculativeTransaction } from "../../inference/rollback";
10
10
  import { bindCacheRollback } from "./rollback";
11
11
  import type { GraphDescriptor } from "../../inference/graph";
12
12
  import { bindLegacyDraftTarget } from "./draft-target";
13
+ import type { MlxForwardWork } from "./mixed-iteration";
14
+ import type { PrefillPolicy } from "../../inference/prefill";
15
+ import { resolveMlxPrefillPolicy } from "./prefill-policy";
13
16
 
14
17
  /** Bound target operations and draft construction for one speculative run.
15
18
  * A replacement graph supplies this entire port, including any hidden taps.
16
19
  * The verifier never needs a RuntimeModel or an artifact-family check. */
17
20
  export interface MlxSpeculativeBinding {
18
21
  readonly runtime?: RuntimeConfig;
22
+ readonly prefillPolicy?: PrefillPolicy;
19
23
  readonly memory?: MlxModelMemory;
20
24
  readonly descriptor: GraphDescriptor;
21
25
  readonly eosTokenIds: readonly number[];
@@ -23,27 +27,29 @@ export interface MlxSpeculativeBinding {
23
27
  makeCache(): Cache[];
24
28
  openDraft(sampler: Sampler, caches: Cache[]): DraftSource;
25
29
  bindRollback(caches: Cache[]): SpeculativeTransaction;
26
- forward(ids: MlxArray, caches: Cache[], tapLayers?: number[]):
30
+ forward(ids: MlxArray, caches: Cache[], tapLayers?: number[], work?: MlxForwardWork):
27
31
  Promise<{ hidden: MlxArray; ctxML: MlxArray | null }>;
28
32
  projectLogits(hidden: MlxArray): MlxArray;
29
33
  /** Establish the implementation's verify kernel context; restore on close. */
30
34
  pinVerify?(): { close(): void };
31
35
  }
32
36
 
37
+ export type MlxSpeculativeTargetBinding = Omit<MlxSpeculativeBinding, "openDraft">;
38
+
33
39
  /** Legacy mutable tap/kernel fields require the gateway's exclusive lease. */
34
- export function bindLegacySpeculativeModel(model: RuntimeModel, provider: DraftProvider): MlxSpeculativeBinding {
40
+ export function bindSpeculativeTargetModel(model: RuntimeModel): MlxSpeculativeTargetBinding {
35
41
  const runtime = runtimeConfig();
36
42
  return {
37
43
  runtime,
44
+ prefillPolicy: resolveMlxPrefillPolicy(model.config, runtime),
38
45
  memory: model,
39
46
  descriptor: Object.freeze({ id: `legacy-spec:${model.config.modelType}`, backend: "mlx",
40
47
  graphAbi: "mlx-hidden-bsh-v1", stateAbi: "legacy-cache-array-v1", artifact: "legacy-resident-model" }),
41
48
  eosTokenIds: model.config.eosTokenIds,
42
49
  prefillTailSplit: runtime.flag("MLX_BUN_PREFILL_TAIL_SPLIT", true),
43
50
  makeCache: model.makeCache.bind(model),
44
- openDraft: (sampler, caches) => provider.open({ sampler, target: bindLegacyDraftTarget(model, caches) }),
45
51
  bindRollback: bindCacheRollback,
46
- forward: (ids, caches, tapLayers) => legacyForwardWithTaps(model, ids, caches, tapLayers),
52
+ forward: (ids, caches, tapLayers, work) => legacyForwardWithTaps(model, ids, caches, tapLayers, work),
47
53
  projectLogits: model.logitsFromHidden.bind(model),
48
54
  ...("setSpecKernelPinned" in model ? {
49
55
  pinVerify() {
@@ -54,6 +60,12 @@ export function bindLegacySpeculativeModel(model: RuntimeModel, provider: DraftP
54
60
  };
55
61
  }
56
62
 
63
+ /** Serial compatibility binds draft construction separately from target work. */
64
+ export function bindLegacySpeculativeModel(model: RuntimeModel, provider: DraftProvider): MlxSpeculativeBinding {
65
+ return { ...bindSpeculativeTargetModel(model),
66
+ openDraft: (sampler, caches) => provider.open({ sampler, target: bindLegacyDraftTarget(model, caches) }) };
67
+ }
68
+
57
69
  export function assertMlxSpeculativeBinding(binding: MlxSpeculativeBinding): void {
58
70
  const descriptor = binding.descriptor;
59
71
  if (descriptor.backend !== "mlx" || descriptor.graphAbi !== "mlx-hidden-bsh-v1" ||
@@ -71,8 +83,10 @@ async function legacyForwardWithTaps(
71
83
  ids: MlxArray,
72
84
  caches: Cache[],
73
85
  tapLayers: number[] | undefined,
86
+ work?: MlxForwardWork,
74
87
  ): Promise<{ hidden: MlxArray; ctxML: MlxArray | null }> {
75
88
  if (!tapLayers) {
89
+ if (work) return { hidden: await work(ids, caches), ctxML: null };
76
90
  const asyncModel = model as RuntimeModel & {
77
91
  forwardHiddenAsync?: (
78
92
  ids: MlxArray,
@@ -88,10 +102,13 @@ async function legacyForwardWithTaps(
88
102
  const m = model;
89
103
  const previousTap = m.hiddenTap;
90
104
  const cap = new Map<number, MlxArray>();
91
- m.hiddenTap = { layers: new Set(tapLayers), captured: cap };
105
+ const layers = new Set(tapLayers);
106
+ if (!work) m.hiddenTap = { layers, captured: cap };
92
107
  let hidden: MlxArray | null = null;
93
108
  try {
94
- hidden = model.forwardHidden(ids, caches);
109
+ hidden = work ? await work(ids, caches, { captureLayer(layer, h) {
110
+ if (layers.has(layer)) cap.set(layer, ops.contiguous(h));
111
+ } }) : model.forwardHidden(ids, caches);
95
112
  const perLayer = tapLayers.map((li) => {
96
113
  const a = cap.get(li);
97
114
  if (!a) throw new Error(`spec tap: layer ${li} not captured`);
@@ -109,6 +126,6 @@ async function legacyForwardWithTaps(
109
126
  // are already gone (cap cleared, hidden nulled).
110
127
  hidden?.dispose();
111
128
  for (const [, a] of cap) a.dispose();
112
- m.hiddenTap = previousTap;
129
+ if (!work) m.hiddenTap = previousTap;
113
130
  }
114
131
  }
@@ -27,7 +27,7 @@ export function targetRowLayoutFactory(cache: Cache): (() => TargetRowLayout) |
27
27
  if (cache instanceof DelayedTurboQuantKVCache || cache instanceof DelayedQuantizedKVCache ||
28
28
  cache instanceof BatchedTurboQuantKVCache || cache instanceof BatchedKVCache ||
29
29
  cache instanceof BatchedQuantizedKVCache || cache instanceof BatchedSSMCache) return () => cache.makeEmptyBatch();
30
- if (cache instanceof TurboQuantKVCache) return () => new BatchedTurboQuantKVCache(cache.kBits, cache.vBits);
30
+ if (cache instanceof TurboQuantKVCache) return () => new BatchedTurboQuantKVCache(cache.kBits, cache.vBits, cache.fusedDecode);
31
31
  if (cache instanceof SSMCache) return () => new BatchedSSMCache();
32
32
  if (cache instanceof QuantizedKVCache) return () => new BatchedQuantizedKVCache(cache.groupSize, cache.bits);
33
33
  if (cache instanceof KVCache) return () => new BatchedKVCache();
@@ -17,6 +17,7 @@ export interface SpeculativeTransitionedKvLayout extends TransitionedKvLayout {
17
17
  }
18
18
  export interface KvRowTransition<Layout extends TransitionedKvLayout> {
19
19
  readonly signature: string;
20
+ readonly conversionOffset?: number;
20
21
  maintain(rows: Cache[]): void;
21
22
  converted(row: Cache): boolean;
22
23
  makeLayout(): Layout;
@@ -77,6 +78,13 @@ export abstract class TransitioningKvRows<Layout extends TransitionedKvLayout> i
77
78
  get rowOffsets(): readonly number[] { return this.packed?.rowOffsets ?? this.positions.rowOffsets; }
78
79
  get leftPad(): readonly number[] { return this.packed?.leftPad ?? this.positions.leftPad; }
79
80
  get offset(): number { return this.packed?.offset ?? this.positions.offset; }
81
+ maxAppendTokens(): number {
82
+ if (this.packed) return this.packed.maxAppendTokens?.() ?? Infinity;
83
+ const end = this.transition.conversionOffset;
84
+ if (end === undefined) return Infinity;
85
+ return Math.min(Infinity, ...this.rowOffsets.map((offset, row) =>
86
+ offset < end && !this.transition.converted(this.rows[row]!) ? end - offset : Infinity));
87
+ }
80
88
  get batchSize(): number | null { return this.packed?.batchSize ?? this.positions.batchSize; }
81
89
  get ropeOffsetArr(): MlxArray | undefined { return this.packed ? this.packed.ropeOffsetArr : this.positions.ropeOffsetArr; }
82
90
  bytesPerToken(): number { return this.packed?.bytesPerToken?.() ?? Math.max(0, ...this.rows.map(row => row.bytesPerToken?.() ?? 0)); }