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
@@ -1,11 +1,13 @@
1
+ import { runMixedTokenIteration, type MlxForwardWork, type MlxPreparationWork } from "./mixed-iteration";
2
+ import type { MixedTokenModel } from "../../model/token-groups";
3
+ import type { OrdinaryContinuation } from "./continuation";
4
+ import type { MlxRequestStatePolicy } from "./request-state-policy";
1
5
  import { AdmissionRejected } from "../../engine/admission";
2
- import type { ExecutionGroup } from "../../contracts/scheduling";
6
+ import type { ExecutionContext, ExecutionGroup } from "../../contracts/scheduling";
3
7
  import { driveExecutionGroup } from "../../engine/scheduler";
4
- import { disposeResources } from "../../engine/resources";
8
+ import { disposeResources, withResource } from "../../engine/resources";
5
9
  import type { DisposableResource } from "../../contracts/resources";
6
- import { nextPrefillStep } from "../../inference/prefill";
7
- import { executeMlxPrefillStep } from "./prefill";
8
- import { enterMlxMemoryGuard, type MlxMemoryBudget } from "./memory-guard";
10
+ import { MlxPrefillCohort, type PrefillState } from "./prefill-cohort";
9
11
  // Continuous-batching scheduler for `--batch N` serving (phase S2, the engine
10
12
  // loop). Owns ONE running batch and drives it forward one decode step at a
11
13
  // time, admitting waiting requests and evicting finished ones between steps —
@@ -63,6 +65,8 @@ import { enterMlxMemoryGuard, type MlxMemoryBudget } from "./memory-guard";
63
65
  // optimization is a later refinement (batching-v2-plan item a).
64
66
 
65
67
  import { MlxArray } from "../../mlx/array";
68
+ import type { PrefillPolicy } from "../../inference/prefill";
69
+ import { resolveMlxPrefillPolicy } from "./prefill-policy";
66
70
  import * as ops from "../../mlx/ops";
67
71
  import { activeMemory, cacheMemory, clearCache, Dtype, peakMemory } from "../../mlx/ffi";
68
72
  import { runtimeConfig, withRuntimeConfig, type RuntimeConfig } from "../../runtime-config";
@@ -87,14 +91,17 @@ import {
87
91
  type QuantRow,
88
92
  } from "../../model/batched-quant";
89
93
  import { BatchedRotatingQuantCache } from "../../model/batched-rotating-quant";
90
- import type { KvQuantSpec } from "../../config";
94
+ import { cloneSingleRowState, leaseCacheStates } from "./state-views";
95
+ import { ownedCacheLayout } from "./cache-layout";
96
+ import { createKvMaintenance, type KvMaintenance } from "./kv-maintenance";
91
97
  import type { KvScheme } from "../../kv-scheme";
92
98
  import { BatchedRotatingCache } from "../../model/batched-rotating";
93
- import { cloneKvCaches } from "../../kv-store";
94
99
  import { SSMCache } from "../../model/qwen3-delta";
95
100
  import type { RuntimeModel } from "../../model/factory";
96
101
  import type { GrammarController } from "../../grammar";
97
- import { toLogprobs } from "../../sampler";
102
+ import { independentGreedySampling } from "../../sampler";
103
+ import { ExecutionTasks } from "../../engine/execution-tasks";
104
+ import { CancellationSource, GenerationCancelled } from "../../engine/cancellation";
98
105
  import { acquireModelWiredLimit } from "../../generate";
99
106
  import { batchRowKvBytes } from "../../serve/kv-budget";
100
107
  import type { PromptResponseTrace } from "../../serve/prompt-response-trace";
@@ -132,19 +139,70 @@ export function stepTraceReport(): string {
132
139
  * step 0..maxTokens-1 for this row (never beyond maxTokens). */
133
140
  export type RowSampler = (logits1V: MlxArray, step: number) => MlxArray;
134
141
 
135
- export interface BatchRequest {
142
+ /** A method prepares and advances rows; the shared executor owns queueing. */
143
+ export interface MlxGroupPreparation {
144
+ readonly rows: readonly Row[];
145
+ readonly tokenWeight?: number;
146
+ readonly canAdmit?: boolean;
147
+ readonly supportsMixedWork?: boolean;
148
+ admit?(row: Row): void;
149
+ advance(work?: MlxPreparationWork): Promise<boolean>;
150
+ dispose(): void;
151
+ }
152
+ export interface MlxGroupMethodHost {
153
+ readonly rows: readonly Row[];
154
+ readonly runtime: RuntimeConfig;
155
+ readonly prefillChunkSize: number;
156
+ readonly promptCache: RowPromptCache | undefined;
157
+ join(row: Row): void;
158
+ filterRows(keep: number[]): void;
159
+ publish(row: Row, token: number, logprobs?: import("../../generate").TokenLogprobs): Promise<void | boolean>;
160
+ finish(row: Row, reason: "stop" | "length"): void;
161
+ }
162
+ export interface MlxGroupedMethod {
163
+ /** Maximum target tokens in the next iteration, including candidates. */
164
+ readonly runningTokens?: number;
165
+ prepare(row: Row): MlxGroupPreparation;
166
+ advance(work?: MlxForwardWork): Promise<void>;
167
+ filterRows(keep: readonly number[], discard: boolean): void;
168
+ dispose(): void;
169
+ }
170
+ export interface MlxGroupMethodRequest {
171
+ /** Equal keys declare compatible method state, independent of queue size. */
172
+ readonly key: string;
173
+ readonly data: unknown;
174
+ open(host: MlxGroupMethodHost): MlxGroupedMethod;
175
+ }
176
+
177
+ export type BatchRequest = BatchRequestFields & (
178
+ | { method: MlxGroupMethodRequest; sample?: RowSampler }
179
+ | { method?: undefined; sample: RowSampler }
180
+ );
181
+
182
+ interface BatchRequestFields {
183
+ /** Borrowed prepared input; its owner retains it through preparation. */
184
+ promptInput?: import("./prompt-input").MlxPromptInput;
185
+ cacheSessionId?: string;
186
+ /** Optional ordinary continuation policy; owns persistence and sampler recovery. */
187
+ continuation?: OrdinaryContinuation;
188
+ statePolicy?: MlxRequestStatePolicy;
189
+ context?: ExecutionContext;
190
+ /** Resolve loaded-state identity under the execution lease, after any
191
+ * queued adapter replacement has finished. */
192
+ cacheNamespace?: string | (() => string);
136
193
  /** Resolved replay permission; standalone group callers retain auto mode. */
137
194
  compiledDecode?: boolean;
138
195
  promptIds: number[];
196
+ /** Request policy overrides the execution group's captured prefill default. */
197
+ prefillChunkSize?: number;
139
198
  maxTokens: number;
140
199
  eosTokenIds: number[];
141
- sample: RowSampler;
142
200
  /** Called per emitted (non-EOS) token, in order. Returning `false` halts this
143
201
  * row (a decoded-text stop sequence fired) — matches generate()'s onToken
144
202
  * contract. EOS terminates the row WITHOUT an onToken call. Throwing evicts
145
203
  * THIS row only (its submit promise rejects; siblings continue). May be
146
204
  * async; keep it cheap — it runs inline in the step loop. */
147
- onToken: (token: number) => void | boolean | Promise<void | boolean>;
205
+ onToken: (token: number, logprobs?: import("../../generate").TokenLogprobs) => void | boolean | Promise<void | boolean>;
148
206
  /** Diagnostic-only request-local trace. */
149
207
  trace?: PromptResponseTrace;
150
208
  /** Closes the gateway's queue/admission span when this row leaves pending. */
@@ -178,6 +236,8 @@ export interface BatchRequest {
178
236
  }
179
237
 
180
238
  export interface BatchStats {
239
+ spec?: import("../../generate").GenerateStats["spec"];
240
+ fill?: import("../../generate").GenerateStats["fill"];
181
241
  promptTokens: number;
182
242
  generatedTokens: number;
183
243
  /** Prompt tokens served from the prompt cache (Phase 3.2): a joiner's solo
@@ -192,6 +252,8 @@ export interface BatchStats {
192
252
  decodeMs: number;
193
253
  }
194
254
 
255
+ let nextMixedWorkId = 0;
256
+
195
257
  /** The slice of PromptCache the scheduler drives (structural — the server's
196
258
  * PromptCache satisfies it). take() on admission (any joiner: a restored
197
259
  * prefix + suffix prefill is byte-safe whether the row later merges or
@@ -200,14 +262,11 @@ export interface BatchStats {
200
262
  * finish INSIDE a multi-row batch get their KV EXTRACTED per row into
201
263
  * fresh serial caches first (mlx-lm server.py:872 extract_cache — the
202
264
  * cross-request reuse concurrent agents live on). */
203
- export interface RowPromptCache {
204
- take(prompt: number[], ns?: string): {
205
- tokens: number[]; caches: Cache[]; retain?: () => void;
206
- } | null;
207
- put(tokens: number[], caches: Cache[], ns?: string, retain?: () => void): void;
208
- }
265
+ export type RowPromptCache = import("./checkpoint-state").MlxPrefixCache;
209
266
 
210
- interface Row {
267
+ export interface Row {
268
+ spec?: import("../../generate").GenerateStats["spec"];
269
+ fill?: import("../../generate").GenerateStats["fill"];
211
270
  req: BatchRequest;
212
271
  resolve: (s: BatchStats) => void;
213
272
  reject: (e: unknown) => void;
@@ -220,6 +279,8 @@ interface Row {
220
279
  /** performance.now() marks for BatchStats timing (0 = not reached). */
221
280
  admittedAt: number;
222
281
  firstTokenAt: number;
282
+ closeTokenZero?: () => void;
283
+ cacheNamespace?: string;
223
284
  /** Generated tokens whose KV actually entered the cache (fed as a step
224
285
  * input) — serial generate()'s `forwarded` list. promptIds+fed is the
225
286
  * exact token coverage of the row's caches, the put() entry key.
@@ -242,30 +303,14 @@ interface Row {
242
303
  merged: boolean;
243
304
  }
244
305
 
245
- /** A joiner mid-prefill: `pos` prompt tokens are already in `solo`. Advanced
246
- * one chunk per loop iteration, interleaved with batch decode steps. */
247
- interface PrefillState {
248
- row: Row;
249
- solo: Cache[];
250
- pos: number;
251
- /** Release hook carried by an SSD-restored prompt-cache entry (must run
252
- * only after `solo`/the adopted caches are disposed — see PromptCacheEntry). */
253
- retain?: () => void;
254
- /** Pending boundary-snapshot position: #prefillChunk splits its chunking
255
- * exactly here, clones + put()s the trim-free prefix entry, then nulls
256
- * it. null = no snapshot for this row (short prompt / cached past it). */
257
- snapAt: number | null;
258
- closePrefill?: () => void;
259
- }
260
-
261
306
  /** Held while the batch is active; the serial fallback acquires the same lock. */
262
307
  export interface ExclusiveLock {
263
308
  acquire(): Promise<() => void>;
264
309
  }
265
310
 
266
311
  export interface MlxBatchExecutionGroupOptions {
267
- /** Shared with serial execution; checked under the GPU lease after cache take. */
268
- memoryBudget?: MlxMemoryBudget;
312
+ /** Scheduling work budget for extending an existing preparation cohort. */
313
+ prefillBatchTokenLimit?: number;
269
314
  runtime?: RuntimeConfig;
270
315
  maxQueued?: number;
271
316
  stateCodecs?: import("../../kv-store").CacheCodecProvider;
@@ -290,6 +335,8 @@ export interface MlxBatchExecutionGroupOptions {
290
335
  * the same chunk boundaries as the serial path, so a row's quantized bytes
291
336
  * preserve the L2 composition. Unsupported schemes fail at construction. */
292
337
  kvScheme?: KvScheme;
338
+ /** Model binding owns support for precision transitions. */
339
+ kvBatchCapabilities?: { delayedAffine?: boolean };
293
340
  /** Prompt-cache hook (Phase 3.2): admission take()s the longest usable
294
341
  * prefix into the joiner's solo caches (suffix-only prefill — the
295
342
  * multi-turn chat TTFT path); rows that finish never-merged put() their
@@ -311,6 +358,7 @@ type LayerInner =
311
358
  type Row1 = { keys: MlxArray; values: MlxArray };
312
359
 
313
360
  export class MlxBatchExecutionGroup {
361
+ readonly #tasks = new ExecutionTasks();
314
362
  readonly #runtime: RuntimeConfig;
315
363
  readonly #noPipeline: boolean;
316
364
  readonly #stepTrace: boolean;
@@ -320,7 +368,8 @@ export class MlxBatchExecutionGroup {
320
368
  #inners: LayerInner[] | null = null; // per-layer batched KV; null when empty
321
369
  #fullLeftPad: number[] = []; // per-row padding for FULL layers (rot self-tracks)
322
370
  #pending: Row[] = [];
323
- #prefill: PrefillState | null = null; // the (single) joiner mid-prefill
371
+ #prefill: MlxGroupPreparation | null = null; // the (single) joiner mid-prefill
372
+ #preparationPublishedOutput = false;
324
373
  /** Sampled-but-unread token array [B], aligned with #running — the decode
325
374
  * pipeline register. Filtered/disposed alongside the batched KV. */
326
375
  #pendingToks: MlxArray | null = null;
@@ -329,7 +378,11 @@ export class MlxBatchExecutionGroup {
329
378
  * accounting must skip it — see Row.fed/fedTainted). Filtered/cleared in
330
379
  * lockstep with the register. */
331
380
  #pendingReal: boolean[] | null = null;
381
+ #method: MlxGroupedMethod | undefined;
382
+ #methodKey: string | undefined;
383
+ #decodeStateKey: string | undefined;
332
384
  #steps = 0; // decode-step counter (clearCache cadence)
385
+ #cacheMaintenanceSteps = 0;
333
386
  #looping = false;
334
387
  #closed = false;
335
388
  #driver: Promise<void> | null = null;
@@ -338,24 +391,30 @@ export class MlxBatchExecutionGroup {
338
391
  readonly #lock: ExclusiveLock | undefined;
339
392
  readonly #admissionHeld: (() => boolean) | undefined;
340
393
  readonly #prefillChunkSize: number;
394
+ readonly #prefillPolicy: PrefillPolicy;
395
+ readonly #prefillBatchTokenLimit: number;
341
396
  readonly #prefillTailSplit: boolean;
342
397
  readonly #kvBudgetBytes: number | undefined;
343
- readonly #memoryBudget: MlxMemoryBudget | undefined;
344
- readonly #promptCache: RowPromptCache | undefined;
398
+ readonly #defaultPromptCache: RowPromptCache | undefined;
399
+ #statePolicy?: MlxRequestStatePolicy;
400
+ get #promptCache(): RowPromptCache | undefined {
401
+ return this.#statePolicy ? this.#statePolicy.promptCache : this.#defaultPromptCache;
402
+ }
345
403
  /** Retain hook of the currently-ADOPTED row's cache entry (at most one:
346
404
  * only a lone adopted row holds un-copied entry caches). Runs after the
347
405
  * adopted caches are disposed, or transfers back on put(). */
348
406
  #adoptedRetain: (() => void) | null = null;
407
+ #contextKey: string | undefined;
408
+ #releaseContext: (() => void) | undefined;
349
409
  readonly #kinds: ("full" | "rot" | "ssm" | "owned-batch")[];
350
410
  readonly #rotMaxSize: number[]; // per-layer sliding window (rot layers only)
351
411
  readonly #compressedProjectors: Array<(tokens: number) => number> | null;
352
412
  readonly #batchCacheMaxTokens: number | null;
353
413
  readonly #kvScheme: KvScheme | undefined;
354
- /** layerIdx → mixed-precision spec (Phase 3.1); null = bf16 batch (v1). */
355
- readonly #kvByLayer: Map<number, KvQuantSpec> | null;
414
+ readonly #maintainKv: KvMaintenance | null;
356
415
  /** Compiled decode runner for the B=1 serial-class case (Phase 3.2) —
357
416
  * same eligibility gate as generate.ts (gemma dense, kill switch
358
- * MLX_BUN_COMPILED_DECODE); adapters never reach the batch lane. Set
417
+ * MLX_BUN_COMPILED_DECODE); adapter requests disable replay in their plan. Set
359
418
  * to null permanently on a failed step (serial disables per
360
419
  * generation; the scheduler is one long-lived "generation"). */
361
420
  #compiled: CompiledDecode | null;
@@ -370,17 +429,19 @@ export class MlxBatchExecutionGroup {
370
429
  this.#maxBatch = Math.max(1, Math.floor(opts.maxBatch));
371
430
  this.#lock = opts.lock;
372
431
  this.#admissionHeld = opts.admissionHeld;
373
- this.#prefillChunkSize = Math.max(1, Math.floor(opts.prefillChunkSize ?? 2048));
432
+ this.#prefillPolicy = resolveMlxPrefillPolicy(model.config, this.#runtime, opts.prefillChunkSize);
433
+ this.#prefillChunkSize = this.#prefillPolicy.chunkSize(0);
434
+ this.#prefillBatchTokenLimit = opts.prefillBatchTokenLimit ?? 2048;
374
435
  this.#prefillTailSplit = this.#runtime.flag("MLX_BUN_PREFILL_TAIL_SPLIT", true);
375
436
  this.#kvBudgetBytes = opts.kvBudgetBytes;
376
- this.#memoryBudget = opts.memoryBudget;
377
- this.#promptCache = opts.promptCache;
437
+ this.#defaultPromptCache = opts.promptCache;
378
438
  this.#kvScheme = opts.kvScheme;
379
439
  const proto = withRuntimeConfig(this.#runtime, () => model.makeCache()); // fresh caches hold no buffers
380
440
  if (this.#kvScheme && !this.#kvScheme.batchable(
381
441
  model.config,
382
442
  (layerIdx) =>
383
443
  isPlainKvCache(proto[layerIdx]) || isRotatingPlainCache(proto[layerIdx]),
444
+ proto.length, opts.kvBatchCapabilities,
384
445
  )) {
385
446
  for (const cache of proto) cache.dispose();
386
447
  throw new Error(`unsupported KV scheme for batch scheduler: ${this.#kvScheme.kind}`);
@@ -398,11 +459,10 @@ export class MlxBatchExecutionGroup {
398
459
  ? proto.map((cache) => (tokens: number) => cache.projectedBytes(tokens))
399
460
  : null;
400
461
  this.#batchCacheMaxTokens = proto.every(isBatchableCache)
401
- ? Math.min(...proto.map((cache) => cache.maxTokens ?? Number.MAX_SAFE_INTEGER))
462
+ ? Math.min(...proto.map((cache) => cache.maxTokens ?? Infinity))
402
463
  : null;
403
- const kvConfig = this.#kvScheme?.options.kvConfig;
404
- this.#kvByLayer = kvConfig?.length
405
- ? new Map(kvConfig.map((entry) => [entry.layerIdx, entry]))
464
+ this.#maintainKv = this.#kvScheme?.quantized
465
+ ? createKvMaintenance(this.#kvScheme.options)
406
466
  : null;
407
467
  this.#rotMaxSize = proto.map((c) => (isRotatingPlainCache(c) ? c.maxSize : 0));
408
468
  for (const c of proto) c.dispose();
@@ -418,7 +478,7 @@ export class MlxBatchExecutionGroup {
418
478
  }
419
479
 
420
480
  get pendingRows(): number {
421
- return this.#pending.length + (this.#prefill ? 1 : 0);
481
+ return this.#pending.length + (this.#prefill?.rows.length ?? 0);
422
482
  }
423
483
 
424
484
  /** Projected KV bytes of one row at its worst case (full prompt + full
@@ -437,14 +497,6 @@ export class MlxBatchExecutionGroup {
437
497
  );
438
498
  }
439
499
 
440
- async #withMemoryGuard<T>(promptTokens: number, run: () => Promise<T>): Promise<T> {
441
- if (!this.#memoryBudget) return run();
442
- const guard = enterMlxMemoryGuard(this.model, this.#memoryBudget,
443
- promptTokens, this.#prefillChunkSize);
444
- try { guard.check(); return await run(); }
445
- finally { guard.close(); }
446
- }
447
-
448
500
  async #forwardHidden(ids: MlxArray, cache: Cache[]): Promise<MlxArray> {
449
501
  const asyncModel = this.model as RuntimeModel & {
450
502
  forwardHiddenAsync?: (ids: MlxArray, cache: Cache[]) => Promise<MlxArray>;
@@ -457,7 +509,7 @@ export class MlxBatchExecutionGroup {
457
509
  /** Projected aggregate KV of everything admitted (running + mid-prefill). */
458
510
  get projectedKvBytes(): number {
459
511
  let total = this.#running.reduce((a, r) => a + this.#rowKvBytes(r), 0);
460
- if (this.#prefill) total += this.#rowKvBytes(this.#prefill.row);
512
+ if (this.#prefill) for (const row of this.#prefill.rows) total += this.#rowKvBytes(row);
461
513
  return total;
462
514
  }
463
515
 
@@ -503,6 +555,7 @@ export class MlxBatchExecutionGroup {
503
555
  if (this.#closed) return Promise.reject(new Error("scheduler closed"));
504
556
  if (req.signal?.aborted) return Promise.reject(req.signal.reason);
505
557
  if (this.#pending.length >= this.#maxQueued) return Promise.reject(new AdmissionRejected());
558
+ req = { ...req, prefillChunkSize: req.prefillChunkSize ?? this.#prefillPolicy.chunkSize(req.promptIds.length) };
506
559
  return new Promise<BatchStats>((resolve, reject) => {
507
560
  let abortListener: (() => void) | null = null;
508
561
  const cleanup = () => {
@@ -530,6 +583,21 @@ export class MlxBatchExecutionGroup {
530
583
  this.#ensureLoop();
531
584
  }
532
585
 
586
+ /** Read-only native preparation borrows a boundary of the live execution
587
+ * group. Model mutation still requires the gateway's exclusive drain. */
588
+ runPreparation<T>(work: () => Promise<T>, signal?: AbortSignal): Promise<T> {
589
+ if (this.#closed) return Promise.reject(new Error("scheduler closed"));
590
+ if (signal?.aborted) return Promise.reject(signal.reason);
591
+ const cancellation = signal ? new CancellationSource() : undefined;
592
+ const abort = () => cancellation?.cancel("requested");
593
+ signal?.addEventListener("abort", abort, { once: true });
594
+ const result = this.#tasks.enqueue(work, cancellation).catch(error => {
595
+ throw error instanceof GenerationCancelled && signal?.aborted ? signal.reason : error;
596
+ }).finally(() => signal?.removeEventListener("abort", abort));
597
+ this.#ensureLoop();
598
+ return result;
599
+ }
600
+
533
601
  #ensureLoop(): void {
534
602
  if (this.#wake) { this.#wake(); return; }
535
603
  if (this.#looping || this.#closed) return;
@@ -548,9 +616,19 @@ export class MlxBatchExecutionGroup {
548
616
  async #drive(): Promise<void> {
549
617
  const scheduler = this;
550
618
  const group: ExecutionGroup = {
619
+ get pendingTasks() { return scheduler.#tasks.pending; },
620
+ advanceTask: () => this.#tasks.advance(),
551
621
  get active() { return scheduler.#running.length; },
552
622
  get queued() { return scheduler.#pending.length; },
553
623
  get preparing() { return scheduler.#prefill !== null; },
624
+ get preparingRows() { return scheduler.#prefill?.rows.length ?? 0; },
625
+ get canPrepareMore() { return scheduler.#prefill?.canAdmit === true; },
626
+ get preparingTokens() { return scheduler.#prefill?.tokenWeight ?? 0; },
627
+ // Cache restoration belongs to preparation. A queued request's prompt
628
+ // length is an upper bound until its cache owner resolves the prefix.
629
+ get nextPreparationTokens() { return scheduler.#pending[0]?.promptTokens ?? 0; },
630
+ get maxPreparationTokens() { return scheduler.#prefillBatchTokenLimit; },
631
+ get preparationPublishedOutput() { return scheduler.#preparationPublishedOutput; },
554
632
  get maxActive() { return scheduler.#maxBatch; },
555
633
  get admissionHeld() { return scheduler.#admissionHeld?.() === true; },
556
634
  get closed() { return scheduler.#closed; },
@@ -563,18 +641,60 @@ export class MlxBatchExecutionGroup {
563
641
  }
564
642
  },
565
643
  admitNext: () => this.#admitNext(),
566
- canBurst: () => this.#kvBudgetBytes === undefined ||
567
- this.projectedKvBytes + this.#rowKvBytes(this.#pending[0]!) <= this.#kvBudgetBytes,
568
- advancePreparation: () => this.#advancePreparation(),
569
- advance: () => this.#withMemoryGuard(
570
- Math.max(0, ...this.#running.map((row) => row.promptTokens + row.sampled)),
571
- () => this.#step()),
644
+ canBurst: () => this.#contextCompatible(this.#pending[0]!) &&
645
+ (this.#kvBudgetBytes === undefined ||
646
+ this.projectedKvBytes + this.#rowKvBytes(this.#pending[0]!) <= this.#kvBudgetBytes),
647
+ get mixedPreparation() {
648
+ return scheduler.#decodeStateKey === undefined && scheduler.#prefill?.supportsMixedWork !== false && scheduler.#runtime.flag("MLX_BUN_MIXED_PREFILL", false) &&
649
+ (!scheduler.#method || scheduler.#method.runningTokens !== undefined) &&
650
+ typeof (scheduler.model as RuntimeModel & Partial<MixedTokenModel>).forwardHiddenMixed === "function"
651
+ ? { runningTokens: scheduler.#method?.runningTokens ?? scheduler.#running.length,
652
+ minimumPreparationTokens: scheduler.#prefill?.rows.length ?? 0 }
653
+ : undefined;
654
+ },
655
+ get maxIterationTokens() { return scheduler.#runtime.number("MLX_BUN_MIXED_TOKEN_BUDGET", 256); },
656
+ advanceMixed: async tokenBudget => {
657
+ this.#promptCache?.reclaim?.();
658
+ const advanced = await runMixedTokenIteration({
659
+ prepare: forward => this.#advancePreparation({ forward,
660
+ maxTokens: tokenBudget - (this.#method?.runningTokens ?? this.#running.length) }),
661
+ decode: forward => this.#method ? this.#method.advance(forward) : this.#step(forward),
662
+ forward: async (ids, cache, options) => options?.captureLayer
663
+ ? (this.model as RuntimeModel & MixedTokenModel).forwardHiddenMixed([{ ids, cache, ...options }])[0]!
664
+ : this.#forwardHidden(ids, cache),
665
+ mixed: groups => {
666
+ const workId = `mixed:${++nextMixedWorkId}`;
667
+ const tokens = groups.map(group => group.ids.shape[0]! * group.ids.shape[1]!);
668
+ const closes = [...this.#running, ...(this.#prefill?.rows ?? [])].map(row =>
669
+ row.req.trace?.begin("engine.mixed_forward", { workId,
670
+ decodeTokens: tokens[0]!, prefillTokens: tokens[1]!, packedTokens: tokens[0]! + tokens[1]! }));
671
+ try { return (this.model as RuntimeModel & MixedTokenModel).forwardHiddenMixed(groups); }
672
+ finally { for (const close of closes) close?.(); }
673
+ },
674
+ });
675
+ if (!advanced && this.#running.length) {
676
+ if (this.#method) await this.#method.advance(); else await this.#step();
677
+ }
678
+ },
679
+ advancePreparation: () => {
680
+ this.#promptCache?.reclaim?.();
681
+ return this.#advancePreparation();
682
+ },
683
+ advance: () => {
684
+ if (this.#cacheMaintenanceSteps++ % 256 === 0) this.#promptCache?.reclaim?.();
685
+ return this.#method ? this.#method.advance() : this.#step();
686
+ },
572
687
  failActive: (error) => {
573
688
  for (const row of this.#running) row.reject(error);
574
689
  this.#applyFilter([], true); // failed state never enters the prefix store
575
690
  },
576
691
  failAll: (error) => this.#failAll(error),
577
- reserveResidency: () => acquireModelWiredLimit(this.model),
692
+ reserveResidency: () => {
693
+ const release = acquireModelWiredLimit(this.model);
694
+ return () => disposeResources([
695
+ { dispose: () => this.#leaveContext() }, { dispose: release },
696
+ ]);
697
+ },
578
698
  ...(this.#lock ? { acquireExecution: () => this.#lock!.acquire() } : {}),
579
699
  waitForWork: async () => {
580
700
  await new Promise<void>((resolve) => { this.#wake = resolve; });
@@ -585,70 +705,112 @@ export class MlxBatchExecutionGroup {
585
705
  await driveExecutionGroup(group, {
586
706
  now: () => performance.now(),
587
707
  yield: () => new Promise<void>((resolve) => setImmediate(resolve)),
588
- }, this.#runtime.value("MLX_BUN_EARLY_FIRST_TOKEN") === "1");
708
+ }, this.#runtime.flag("MLX_BUN_EARLY_FIRST_TOKEN", true));
589
709
  } finally { this.#looping = false; }
590
710
  }
591
711
 
712
+ #contextCompatible(row: Row): boolean {
713
+ return (!this.#running.length && !this.#prefill) || ((row.req.context?.key ?? "") === this.#contextKey && row.req.method?.key === this.#methodKey && row.req.statePolicy?.key === this.#statePolicy?.key && row.req.promptInput?.decodeState?.key === this.#decodeStateKey);
714
+ }
715
+
716
+ #leaveContext(): void {
717
+ const release = this.#releaseContext;
718
+ this.#releaseContext = undefined;
719
+ this.#contextKey = undefined;
720
+ release?.();
721
+ }
722
+
592
723
  #admitNext(): boolean {
593
- if (!this.#kvAdmits(this.#pending[0]!)) return false;
724
+ if (!this.#contextCompatible(this.#pending[0]!) || !this.#kvAdmits(this.#pending[0]!)) return false;
594
725
  const row = this.#pending.shift()!;
595
- let owned: { caches: Cache[]; retain?: () => void } | undefined;
596
- let closePrefill: (() => void) | undefined;
597
726
  try {
727
+ this.#statePolicy = row.req.statePolicy;
728
+ this.#decodeStateKey = row.req.promptInput?.decodeState?.key;
729
+ row.cacheNamespace = typeof row.req.cacheNamespace === "function"
730
+ ? row.req.cacheNamespace() : row.req.cacheNamespace;
731
+ const key = row.req.context?.key ?? "";
732
+ if (key !== this.#contextKey) {
733
+ this.#leaveContext();
734
+ this.#releaseContext = row.req.context?.enter();
735
+ this.#contextKey = key;
736
+ }
598
737
  row.req.onAdmitted?.();
599
738
  row.admittedAt = performance.now();
600
- const closeCache = row.req.trace?.begin("cache.lookup_restore", { mechanism: "continuous" });
601
- const hit = this.#promptCache?.take(row.req.promptIds) ?? null;
602
- if (hit) owned = hit;
603
- closeCache?.();
604
- if (hit) row.cachedTokens = hit.tokens.length;
605
- const len = row.req.promptIds.length;
606
- const boundary = Math.min(row.req.snapshotAt ?? len, len - 1);
607
- const snapAt = this.#promptCache && boundary >= 256 && boundary > (hit?.tokens.length ?? 0)
608
- ? boundary : null;
609
- closePrefill = row.req.trace?.begin("prefill.total", {
610
- mechanism: "continuous", promptTokens: row.promptTokens, cachedTokens: row.cachedTokens,
611
- });
612
- const closeBatchSetup = row.req.trace?.begin("prefill.batch_setup", { mechanism: "continuous" });
613
- owned ??= { caches: this.model.makeCache() };
614
- closeBatchSetup?.();
615
- this.#prefill = { row, solo: owned.caches, pos: hit?.tokens.length ?? 0,
616
- retain: owned.retain, snapAt, closePrefill };
617
- owned = undefined; // transfer to the preparation owner
618
- } catch (error) {
619
- let failure = error;
620
- try { disposeResources([...(owned?.caches ?? []), { dispose: () => owned?.retain?.() },
621
- { dispose: () => closePrefill?.() }]); }
622
- catch (cleanupError) { failure = new AggregateError([error, cleanupError], "batch admission and cleanup failed"); }
623
- row.reject(failure);
624
- }
739
+ if (row.req.method?.key !== this.#methodKey) {
740
+ this.#method?.dispose(); this.#method = undefined; this.#methodKey = undefined;
741
+ if (row.req.method) {
742
+ const executor = this;
743
+ this.#method = row.req.method.open({
744
+ get rows() { return executor.#running; }, runtime: this.#runtime,
745
+ prefillChunkSize: this.#prefillChunkSize, promptCache: this.#promptCache,
746
+ join: row => { this.#running.push(row); },
747
+ filterRows: keep => this.#applyFilter(keep),
748
+ publish: async (row, token, logprobs) => {
749
+ row.req.signal?.throwIfAborted();
750
+ this.#firstOutput(row);
751
+ return row.req.onToken(token, logprobs);
752
+ },
753
+ finish: (row, reason) => this.#finish(row, reason),
754
+ });
755
+ this.#methodKey = row.req.method.key;
756
+ }
757
+ }
758
+ if (this.#prefill) {
759
+ this.#prefill.admit!(row);
760
+ } else if (this.#method) {
761
+ this.#prefill = this.#method.prepare(row);
762
+ } else {
763
+ const preparation = new MlxPrefillCohort({
764
+ model: this.model, chunkSize: this.#prefillChunkSize, tailSplit: this.#prefillTailSplit,
765
+ promptCache: this.#promptCache, stateCodecs: this.#stateCodecs, maintain: this.#maintainKv ?? undefined,
766
+ forward: (ids, caches) => this.#forwardHidden(ids, caches),
767
+ project: (hidden, caches, completed) => this.#projectPrefill(hidden, caches, completed),
768
+ complete: (state, logits) => this.#completePrefill(state, logits),
769
+ resume: state => this.#completePrefill(state, null),
770
+ reject: (failed, error) => this.#rejectPreparingRows([failed], error),
771
+ });
772
+ preparation.admit(row); this.#prefill = preparation;
773
+ }
774
+ } catch (error) { row.reject(error); }
625
775
  return true;
626
776
  }
627
777
 
628
- async #advancePreparation(): Promise<void> {
629
- const p = this.#prefill!;
778
+ #rejectPreparingRows(rows: readonly Row[], error: unknown): void {
779
+ const retiring = new Set(rows);
780
+ const keep = this.#running.flatMap((row, index) => retiring.has(row) ? [] : [index]);
781
+ if (keep.length !== this.#running.length) this.#applyFilter(keep, true);
782
+ for (const row of rows) row.reject(error);
783
+ }
784
+
785
+ async #advancePreparation(work?: MlxPreparationWork): Promise<void> {
786
+ const p = this.#prefill!, rows = p.rows;
787
+ this.#preparationPublishedOutput = false;
630
788
  try {
631
- if (await this.#withMemoryGuard(p.row.promptTokens, () => this.#prefillChunk(p))) {
632
- p.closePrefill?.();
633
- this.#prefill = null;
634
- }
789
+ if (await p.advance(work)) this.#prefill = null;
635
790
  } catch (error) {
636
791
  this.#prefill = null;
637
792
  let failure = error;
638
- try { disposeResources([...p.solo, { dispose: () => p.retain?.() }, { dispose: () => p.closePrefill?.() }]); }
793
+ try { p.dispose(); }
639
794
  catch (cleanupError) { failure = new AggregateError([error, cleanupError], "batch prefill and cleanup failed"); }
640
- p.row.reject(failure);
795
+ try { this.#rejectPreparingRows(rows, failure); }
796
+ catch (cleanupError) {
797
+ failure = new AggregateError([failure, cleanupError], "batch admission retirement failed");
798
+ for (const row of rows) row.reject(failure);
799
+ this.#failAll(failure);
800
+ }
641
801
  }
642
802
  }
643
803
 
644
804
  #failAll(error: unknown): void {
805
+ this.#tasks.rejectAll(error);
645
806
  const p = this.#prefill;
646
- const rows = new Set([...this.#running, ...this.#pending, ...(p ? [p.row] : [])]);
807
+ const rows = new Set([...this.#running, ...this.#pending, ...(p?.rows ?? [])]);
647
808
  const retain = this.#adoptedRetain;
648
- const resources = [...(p?.solo ?? []), ...(this.#inners ?? []), this.#pendingToks,
649
- { dispose: () => p?.retain?.() }, { dispose: () => p?.closePrefill?.() }, { dispose: () => retain?.() }]
809
+ const resources = [p, this.#method, ...(this.#inners ?? []), this.#pendingToks,
810
+ { dispose: () => retain?.() }]
650
811
  .filter((resource): resource is DisposableResource => resource != null);
651
812
  this.#pending = []; this.#running = []; this.#prefill = null;
813
+ this.#method = undefined; this.#methodKey = undefined;
652
814
  this.#inners = null; this.#pendingToks = null; this.#pendingReal = null;
653
815
  this.#fullLeftPad = []; this.#adoptedRetain = null;
654
816
  for (const row of rows) row.reject(error);
@@ -665,110 +827,105 @@ export class MlxBatchExecutionGroup {
665
827
  * bytes bit-exact vs serial `--kv-quant config`. Gateway placement and the
666
828
  * constructor both guarantee every named cache can convert. */
667
829
  #quantizeSolo(solo: Cache[], trace?: PromptResponseTrace): void {
668
- if (!this.#kvByLayer) return;
830
+ if (!this.#maintainKv) return;
669
831
  const close = trace?.begin("prefill.kv_maintenance", {
670
832
  mechanism: "continuous",
671
833
  });
672
834
  try {
673
- for (let i = 0; i < solo.length; i++) {
674
- const e = this.#kvByLayer.get(i);
675
- const c = solo[i]!;
676
- if (!e || c.offset === 0) continue;
677
- // Milestone 2: rotating layers convert too — RotatingKVCache.
678
- // toQuantized → RotatingQuantizedKVCache, exactly maybeQuantizeKv's
679
- // dispatch (generate.ts). Converted caches don't match either class
680
- // (the four cache classes are siblings), so re-conversion never fires.
681
- if (!(isPlainKvCache(c) || isRotatingPlainCache(c))) continue;
682
- const q = c.toQuantized(e.groupSize, e.bits);
683
- solo[i] = q;
684
- ops.evalAll(q.state());
685
- clearCache();
686
- }
835
+ this.#maintainKv(solo);
687
836
  } finally {
688
837
  close?.();
689
838
  }
690
839
  }
691
840
 
692
- /** Advance a joiner's solo prefill by one chunk. Non-final chunks forward +
693
- * eval the cache and return false (the caller interleaves a decode step).
694
- * The final chunk samples token 0, emits it, and — if the row survives —
695
- * merges it into the running batch; returns true (admission complete). */
696
- async #prefillChunk(p: PrefillState): Promise<boolean> {
697
- p.row.req.signal?.throwIfAborted();
698
- const prompt = p.row.req.promptIds;
699
- const chunkStart = p.pos;
700
- const closeChunk = p.row.req.trace?.begin("prefill.chunk", {
701
- mechanism: "continuous",
702
- startToken: chunkStart,
703
- });
704
- try {
705
- let h: MlxArray;
706
- while (true) {
707
- p.row.req.signal?.throwIfAborted();
708
- const step = nextPrefillStep({ length: prompt.length, position: p.pos,
709
- chunkSize: this.#prefillChunkSize, tailSplit: this.#prefillTailSplit, snapshotAt: p.snapAt });
710
- const hidden = await executeMlxPrefillStep(
711
- (ids, caches) => this.#forwardHidden(ids, caches), p.solo, prompt, step,
712
- () => this.#quantizeSolo(p.solo, p.row.req.trace),
713
- );
714
- p.pos = step.end;
715
- if (hidden) { h = hidden; break; }
716
- if (step.snapshot) {
717
- try { this.#promptCache!.put(prompt.slice(0, step.end), cloneKvCaches(p.solo, this.#stateCodecs)); }
718
- catch (error) { console.warn(`batch-lane boundary snapshot skipped: ${(error as Error).message}`); }
719
- p.snapAt = null;
720
- }
721
- if (step.batchYield) return false;
841
+ /** The target owns projection geometry; samplers receive individual rows. */
842
+ #projectPrefill(hidden: MlxArray, caches: Cache[], completed: readonly PrefillState[]): MlxArray {
843
+ for (const p of completed) {
844
+ p.closePrefill?.(); p.closePrefill = undefined;
845
+ p.row.closeTokenZero = p.row.req.trace?.begin("token_zero.total", { mechanism: "continuous" });
722
846
  }
723
- closeChunk?.();
724
- p.closePrefill?.();
725
- p.closePrefill = undefined;
726
- const closeTokenZero = p.row.req.trace?.begin("token_zero.total", {
727
- mechanism: "continuous",
728
- });
729
- // MLX is lazy: synchronize(stream) only waits for already-submitted work;
730
- // it does not submit `h`'s graph. Attribution mode must evaluate the final
731
- // L=1 forward explicitly or its work is silently charged to the head/read.
732
- const forceAttribution = !!p.row.req.trace &&
733
- this.#runtime.value("MLX_BUN_P2R_SYNC") === "1";
734
- if (forceAttribution) {
735
- const closeForward = p.row.req.trace!.begin("token_zero.forward", {
736
- mechanism: "continuous",
737
- activeBytes: activeMemory(),
738
- cacheBytes: cacheMemory(),
739
- peakBytes: peakMemory(),
740
- });
741
- ops.evalAll([h, ...p.solo.flatMap((c) => c.state())]);
742
- closeForward();
847
+ const attributed = this.#runtime.value("MLX_BUN_P2R_SYNC") === "1"
848
+ ? completed.flatMap(p => p.row.req.trace ? [p.row.req.trace] : []) : [];
849
+ const begin = (phase: "token_zero.forward" | "token_zero.head") => attributed.map(trace => trace.begin(phase, {
850
+ mechanism: "continuous", activeBytes: activeMemory(), cacheBytes: cacheMemory(), peakBytes: peakMemory(),
851
+ }));
852
+ if (attributed.length) {
853
+ const closes = begin("token_zero.forward");
854
+ try { withResource(leaseCacheStates(caches), state => ops.evalAll([hidden, ...state])); }
855
+ finally { for (const close of closes) close(); }
743
856
  }
744
- const [, Lc, H] = h.shape as [number, number, number];
745
- const hLast = h.slice([0, Lc - 1, 0], [1, Lc, H]);
746
- h.dispose();
747
- const lg = this.model.logitsFromHidden(hLast); // [1,1,V]
748
- hLast.dispose();
749
- if (forceAttribution) {
750
- const closeHead = p.row.req.trace!.begin("token_zero.head", {
751
- mechanism: "continuous",
752
- activeBytes: activeMemory(),
753
- cacheBytes: cacheMemory(),
754
- peakBytes: peakMemory(),
755
- });
756
- ops.evalAll([lg]);
757
- closeHead();
857
+ // Project before selecting rows: M4 quantized projection can use different
858
+ // arithmetic at M=1. Tail-split preparation reaches this with N=1.
859
+ const logits = this.model.logitsFromHidden(hidden);
860
+ try {
861
+ if (attributed.length) {
862
+ const closes = begin("token_zero.head");
863
+ try { ops.evalAll([logits]); } finally { for (const close of closes) close(); }
864
+ }
865
+ return logits;
866
+ } catch (error) { logits.dispose(); throw error; }
867
+ }
868
+
869
+ /** Sample borrowed logits from the shared target projection. */
870
+ async #completePrefill(p: PrefillState, logits: MlxArray | null): Promise<void> {
871
+ if (p.continuation) {
872
+ const saved = p.continuation, row = p.row;
873
+ row.req.continuation!.resumeSampling(saved);
874
+ const interval = row.req.continuation!.interval;
875
+ this.#checkpointAt.set(row, (Math.floor(saved.generatedTokens / interval) + 1) * interval);
876
+ row.firstTokenAt = performance.now();
877
+ row.closeTokenZero?.(); row.closeTokenZero = undefined;
878
+ this.#preparationPublishedOutput = true;
879
+ for (const token of saved.cacheTokens.slice(row.req.promptIds.length)) {
880
+ row.req.signal?.throwIfAborted();
881
+ if (await row.req.onToken(token) === false)
882
+ throw new Error("saved generation prefix triggered a terminal stop while replaying");
883
+ }
884
+ row.fed = saved.cacheTokens.slice(row.req.promptIds.length);
885
+ row.generated = saved.generatedTokens + 1;
886
+ row.sampled = saved.generatedTokens + 1;
887
+ row.cachedTokens = Math.min(saved.cacheTokens.length, row.promptTokens);
888
+ const stop = await this.#emit(row, saved.pendingToken);
889
+ if (stop !== "continue") {
890
+ this.#putOrDispose(p.solo, saved.cacheTokens, p.retain, row.cacheNamespace, row.req.cacheSessionId);
891
+ p.solo = [];
892
+ this.#finish(row, stop);
893
+ return;
894
+ }
895
+ await this.#mergeJoiner(p);
896
+ return;
758
897
  }
759
- const V = lg.shape[2]!;
760
- const last2 = ops.reshape(lg, [1, V]);
761
- lg.dispose();
898
+ if (!logits) throw new Error("ordinary prefill completed without logits");
899
+ const forceAttribution = !!p.row.req.trace && this.#runtime.value("MLX_BUN_P2R_SYNC") === "1";
900
+ const V = logits.shape[2]!;
901
+ const last2 = ops.reshape(logits, [1, V]);
762
902
  const closeSample = forceAttribution
763
903
  ? p.row.req.trace!.begin("token_zero.sample", { mechanism: "continuous" })
764
904
  : undefined;
765
- const tok = this.#readToken(p.row.req.sample(last2, 0));
905
+ let sampled: MlxArray;
906
+ try { sampled = p.row.req.sample!(last2, 0); } finally { last2.dispose(); }
907
+ // The first admission can seed the existing device-token register. Its
908
+ // first decode forward then overlaps token-zero readback, just like every
909
+ // later pipeline step. Grammar and explicit eager-output modes retain
910
+ // their read-before-advance order.
911
+ if (!this.#inners && !p.row.req.grammar && !this.#noPipeline &&
912
+ !this.#runtime.flag("MLX_BUN_EARLY_FIRST_TOKEN", true) && !forceAttribution) {
913
+ let owned: MlxArray | null = sampled;
914
+ try {
915
+ ops.asyncEvalAll([sampled]);
916
+ p.row.sampled = 1;
917
+ this.#quantizeSolo(p.solo, p.row.req.trace);
918
+ await this.#mergeJoiner(p);
919
+ this.#pendingToks = sampled;
920
+ this.#pendingReal = [true];
921
+ owned = null;
922
+ return;
923
+ } finally { owned?.dispose(); }
924
+ }
925
+ const tok = this.#readToken(sampled);
766
926
  closeSample?.();
767
- closeTokenZero?.();
768
- last2.dispose();
769
927
  p.row.sampled = 1;
770
928
  p.row.generated = 1;
771
- clearCache(); // drop the prefill transients (serial's token-0 clear)
772
929
 
773
930
  // Grammar (B1): the mask0 was applied inside the sample closure (the
774
931
  // controller is primed at compile). After reading token 0, advance the
@@ -782,17 +939,17 @@ export class MlxBatchExecutionGroup {
782
939
  const stop = await this.#emit(p.row, tok);
783
940
  // Token 0 was sampled but never fed — the caches cover exactly the
784
941
  // prompt, a clean prompt-only entry (put-or-dispose).
785
- this.#putOrDispose(p.solo, p.row.req.promptIds, p.retain);
942
+ this.#putOrDispose(p.solo, p.row.req.promptIds, p.retain, p.row.cacheNamespace, p.row.req.cacheSessionId);
786
943
  this.#finish(p.row, stop === "continue" ? "stop" : stop);
787
- return true;
944
+ return;
788
945
  }
789
946
  }
790
947
 
791
948
  const stop = await this.#emit(p.row, tok);
792
949
  if (stop !== "continue") {
793
- this.#putOrDispose(p.solo, p.row.req.promptIds, p.retain);
950
+ this.#putOrDispose(p.solo, p.row.req.promptIds, p.retain, p.row.cacheNamespace, p.row.req.cacheSessionId);
794
951
  this.#finish(p.row, stop);
795
- return true;
952
+ return;
796
953
  }
797
954
  // Default tail-split path: the caches were already converted at the head
798
955
  // boundary above (oracle composition: prefill ids[:-1] → convert → L=1
@@ -803,10 +960,7 @@ export class MlxBatchExecutionGroup {
803
960
  // (before decode step 1 == before the merge).
804
961
  this.#quantizeSolo(p.solo, p.row.req.trace);
805
962
  await this.#mergeJoiner(p);
806
- return true;
807
- } finally {
808
- closeChunk?.();
809
- }
963
+ return;
810
964
  }
811
965
 
812
966
  /** Merge a fully-prefilled joiner with the running batch, layer by layer
@@ -814,6 +968,7 @@ export class MlxBatchExecutionGroup {
814
968
  * first so the row set is settled and the next step starts cold. */
815
969
  async #mergeJoiner(p: PrefillState): Promise<void> {
816
970
  await this.#flushPipeline();
971
+ this.#maintainKv?.prepareBatch?.(p.solo);
817
972
 
818
973
  // ADOPT, don't copy (unified-engine plan Phase 3.2): a row joining an
819
974
  // EMPTY batch keeps its solo caches as the batch inners — a pointer
@@ -826,9 +981,9 @@ export class MlxBatchExecutionGroup {
826
981
  // take/put become possible for it. The rot branch below knows how to
827
982
  // treat an adopted RotatingKVCache as the merge's first row.
828
983
  if (!this.#inners) {
829
- this.#inners = p.solo as LayerInner[];
984
+ this.#inners = p.solo as LayerInner[]; p.solo = [];
830
985
  this.#fullLeftPad = [0];
831
- this.#adoptedRetain = p.retain ?? null;
986
+ this.#adoptedRetain = p.retain ?? null; p.retain = undefined;
832
987
  this.#running.push(p.row);
833
988
  return;
834
989
  }
@@ -839,17 +994,17 @@ export class MlxBatchExecutionGroup {
839
994
  const newInners: LayerInner[] = [];
840
995
  let newFullPad = this.#fullLeftPad;
841
996
  for (let layer = 0; layer < this.#kinds.length; layer++) {
842
- if (this.#kinds[layer] === "owned-batch") {
843
- const solo = p.solo[layer]!;
844
- if (!isBatchableCache(solo))
845
- throw new Error(`batch-capable layer ${layer} lost its cache capability`);
846
- const merged = solo.makeEmptyBatch();
997
+ const solo = p.solo[layer]!;
998
+ const merged = ownedCacheLayout(solo);
999
+ if (merged) {
847
1000
  const previous = prev?.[layer];
848
1001
  merged.mergeRows(previous ? [previous, solo] : [solo]);
849
1002
  newFullPad = [...merged.leftPad];
850
1003
  newInners.push(merged);
851
1004
  continue;
852
1005
  }
1006
+ if (this.#kinds[layer] === "owned-batch")
1007
+ throw new Error(`batch-capable layer ${layer} lost its cache capability`);
853
1008
  if (this.#kinds[layer] === "ssm") {
854
1009
  // No temporal axis, no left-pad: B-axis concat of the state slots.
855
1010
  // mergeRows steals the solo arrays when the batch starts cold, so the
@@ -1049,8 +1204,9 @@ export class MlxBatchExecutionGroup {
1049
1204
  // run its retain now. The joiner's likewise after its solo dispose.
1050
1205
  this.#adoptedRetain?.();
1051
1206
  this.#adoptedRetain = null;
1052
- for (const c of p.solo) c.dispose();
1053
- p.retain?.();
1207
+ const completed = p.solo; p.solo = [];
1208
+ const release = p.retain; p.retain = undefined;
1209
+ disposeResources([...completed, { dispose: () => release?.() }]);
1054
1210
  // Every row in a REAL merge has its KV interleaved in batched buffers —
1055
1211
  // no longer prompt-cache put() candidates.
1056
1212
  for (const r of this.#running) r.merged = true;
@@ -1069,7 +1225,7 @@ export class MlxBatchExecutionGroup {
1069
1225
  * Rows that finish get one extra harmless KV write from the already-built
1070
1226
  * step; filter drops the row (mlx-lm behaves identically). Length-finished
1071
1227
  * rows are known in advance and are NOT sampled (placeholder slot). */
1072
- async #step(): Promise<void> {
1228
+ async #step(forward?: MlxForwardWork): Promise<void> {
1073
1229
  if (this.#stepTrace) {
1074
1230
  const now = performance.now();
1075
1231
  if (STEP_T.lastEnd) STEP_T.gap += now - STEP_T.lastEnd;
@@ -1078,6 +1234,11 @@ export class MlxBatchExecutionGroup {
1078
1234
  const rows = this.#running;
1079
1235
  const B = rows.length;
1080
1236
  const inners = this.#inners!;
1237
+ const decodeState = rows[0]?.req.promptInput?.decodeState;
1238
+ if (decodeState) {
1239
+ const states = rows.map(row => row.req.promptInput!.decodeState!);
1240
+ forward = async (ids, caches) => decodeState.forward(ids, caches, states);
1241
+ }
1081
1242
 
1082
1243
  // A row is live if it still needs tokens sampled; a row whose pending
1083
1244
  // unread token is its last (sampled == maxTokens) only awaits emission.
@@ -1097,7 +1258,7 @@ export class MlxBatchExecutionGroup {
1097
1258
  const hasLiveGrammar = anyLive && rows.some(
1098
1259
  (r) => r.req.grammar && !r.req.grammar.isTerminated,
1099
1260
  );
1100
- if (hasLiveGrammar) return this.#stepGrammar();
1261
+ if (hasLiveGrammar) return this.#stepGrammar(forward);
1101
1262
 
1102
1263
  let nextToks: MlxArray | null = null;
1103
1264
  let nextReal: boolean[] | null = null;
@@ -1131,7 +1292,7 @@ export class MlxBatchExecutionGroup {
1131
1292
  let lg: MlxArray | null = null;
1132
1293
  let evalWith: MlxArray[] = [];
1133
1294
  if (
1134
- this.#compiled && B === 1 && unpadded && this.#running[0]!.req.compiledDecode !== false &&
1295
+ !forward && this.#compiled && B === 1 && unpadded && this.#running[0]!.req.compiledDecode !== false &&
1135
1296
  (!this.#pendingToks || this.#pendingToks.dtype === Dtype.uint32) &&
1136
1297
  // A filtered-to-one BATCHED rot-quant cache subclasses the serial
1137
1298
  // class (so supports() passes) but carries batched ring state —
@@ -1173,7 +1334,7 @@ export class MlxBatchExecutionGroup {
1173
1334
  const ids = this.#pendingToks
1174
1335
  ? ops.reshape(this.#pendingToks, [B, 1]) // feed the unread tokens
1175
1336
  : ops.fromInt32(rows.map((r) => r.current), [B, 1]); // pipeline cold
1176
- const h = await this.#forwardHidden(ids, fwd);
1337
+ const h = await (forward ? forward(ids, fwd) : this.#forwardHidden(ids, fwd));
1177
1338
  ids.dispose();
1178
1339
  lg = this.model.logitsFromHidden(h); // [B,1,V]
1179
1340
  h.dispose();
@@ -1197,10 +1358,8 @@ export class MlxBatchExecutionGroup {
1197
1358
  if (vecOk) {
1198
1359
  const flat = ops.reshape(lg, [B, V]);
1199
1360
  lg.dispose();
1200
- const lp = toLogprobs(flat);
1201
- flat.dispose();
1202
- nextToks = ops.argmaxAxis(lp, -1); // [B]
1203
- lp.dispose();
1361
+ try { nextToks = independentGreedySampling.sample(flat); }
1362
+ finally { flat.dispose(); }
1204
1363
  for (const row of rows) if (row.sampled < row.req.maxTokens) row.sampled++;
1205
1364
  } else {
1206
1365
  const sampled: MlxArray[] = [];
@@ -1215,7 +1374,7 @@ export class MlxBatchExecutionGroup {
1215
1374
  const rl = lg.slice([b, 0, 0], [b + 1, 1, V]);
1216
1375
  const rl2 = ops.reshape(rl, [1, V]);
1217
1376
  rl.dispose();
1218
- sampled.push(row.req.sample(rl2, row.sampled));
1377
+ sampled.push(row.req.sample!(rl2, row.sampled));
1219
1378
  row.sampled++;
1220
1379
  rl2.dispose();
1221
1380
  }
@@ -1246,6 +1405,7 @@ export class MlxBatchExecutionGroup {
1246
1405
  // set from process start `prev` is always null, so the flush below IS the
1247
1406
  // whole phase 2. Same math either way (pipelining is scheduling).
1248
1407
  if (this.#noPipeline) {
1408
+ if (this.#running.some(row => row.req.continuation)) this.#captureContinuations();
1249
1409
  await this.#flushPipeline();
1250
1410
  return;
1251
1411
  }
@@ -1266,9 +1426,37 @@ export class MlxBatchExecutionGroup {
1266
1426
  await this.#emitRows(toks); // also filters #pendingToks on eviction
1267
1427
  if (this.#stepTrace) { STEP_T.emit += performance.now() - tEmit; STEP_T.n++; }
1268
1428
  }
1429
+ if (this.#running.some(row => row.req.continuation)) this.#captureContinuations();
1269
1430
  if (this.#stepTrace) STEP_T.lastEnd = performance.now();
1270
1431
  }
1271
1432
 
1433
+ readonly #checkpointAt = new WeakMap<Row, number>();
1434
+ /** Cache extraction belongs to this existing ordinary numerical driver;
1435
+ * the optional request port owns checkpoint eligibility and persistence. */
1436
+ #captureContinuations(): void {
1437
+ if (!this.#pendingToks || !this.#running.some(row => row.req.continuation)) return;
1438
+ let pending: number[] | undefined;
1439
+ const failed = new Set<number>();
1440
+ for (let index = 0; index < this.#running.length; index++) {
1441
+ const row = this.#running[index]!, policy = row.req.continuation;
1442
+ if (!policy || row.fedTainted || row.fed.length !== row.generated ||
1443
+ row.generated < (this.#checkpointAt.get(row) ?? policy.interval) ||
1444
+ this.#pendingReal?.[index] === false) continue;
1445
+ pending ??= this.#pendingToks.toIntTokens();
1446
+ const tokens = [...row.req.promptIds, ...row.fed];
1447
+ const caches = this.#running.length === 1 && !row.merged
1448
+ ? cloneSingleRowState(this.#inners! as Cache[], this.#stateCodecs)
1449
+ : this.#extractRowCaches(index, tokens.length);
1450
+ if (!caches) continue;
1451
+ try {
1452
+ policy.captureOwned({ caches, cacheTokens: tokens, generatedTokens: row.generated,
1453
+ pendingToken: pending[index]! });
1454
+ this.#checkpointAt.set(row, (Math.floor(row.generated / policy.interval) + 1) * policy.interval);
1455
+ } catch (error) { row.reject(error); failed.add(index); }
1456
+ }
1457
+ if (failed.size) this.#applyFilter(this.#running.flatMap((_, index) => failed.has(index) ? [] : [index]), true);
1458
+ }
1459
+
1272
1460
  /** Read out the pipeline register (if any): emit its tokens and evict
1273
1461
  * finished rows, leaving the pipeline cold. Called before a join merges. */
1274
1462
  async #flushPipeline(): Promise<void> {
@@ -1302,7 +1490,7 @@ export class MlxBatchExecutionGroup {
1302
1490
  * finish("stop") in #emitRows. On a cold start (first step after prefill)
1303
1491
  * there is no pending array to read; the prefill already accepted token 0
1304
1492
  * and fired the fill, so we just await ready() + sample. */
1305
- async #stepGrammar(): Promise<void> {
1493
+ async #stepGrammar(forward?: MlxForwardWork): Promise<void> {
1306
1494
  const rows = this.#running;
1307
1495
  const B = rows.length;
1308
1496
  const inners = this.#inners!;
@@ -1349,7 +1537,7 @@ export class MlxBatchExecutionGroup {
1349
1537
  const ids = prev
1350
1538
  ? ops.reshape(prev, [B, 1]) // feed the unread tokens (device array)
1351
1539
  : ops.fromInt32(rows.map((r) => r.current), [B, 1]); // pipeline cold
1352
- const h = await this.#forwardHidden(ids, fwd);
1540
+ const h = await (forward ? forward(ids, fwd) : this.#forwardHidden(ids, fwd));
1353
1541
  ids.dispose();
1354
1542
  const lg = this.model.logitsFromHidden(h); // [B,1,V]
1355
1543
  h.dispose();
@@ -1382,7 +1570,7 @@ export class MlxBatchExecutionGroup {
1382
1570
  const rl = lg.slice([b, 0, 0], [b + 1, 1, V]);
1383
1571
  const rl2 = ops.reshape(rl, [1, V]);
1384
1572
  rl.dispose();
1385
- sampled.push(row.req.sample(rl2, row.sampled));
1573
+ sampled.push(row.req.sample!(rl2, row.sampled));
1386
1574
  row.sampled++;
1387
1575
  rl2.dispose();
1388
1576
  }
@@ -1408,6 +1596,7 @@ export class MlxBatchExecutionGroup {
1408
1596
  // rows finish("stop") via #emit's isTerminated check; the filter evicts.
1409
1597
  // Cold start: prevVals empty (prefill emitted tok0) → nothing to emit.
1410
1598
  if (prevVals.length) await this.#emitRows(prevVals);
1599
+ if (this.#running.some(row => row.req.continuation)) this.#captureContinuations();
1411
1600
  }
1412
1601
 
1413
1602
  /** Emit one read-back token per running row; evict finished rows. A row's
@@ -1420,7 +1609,6 @@ export class MlxBatchExecutionGroup {
1420
1609
  for (let b = 0; b < B; b++) {
1421
1610
  const row = rows[b]!;
1422
1611
  row.generated++;
1423
- if (row.generated === 1) row.firstTokenAt = performance.now();
1424
1612
  let disp: "continue" | "stop" | "length";
1425
1613
  try {
1426
1614
  disp = await this.#emit(row, toks[b]!);
@@ -1452,10 +1640,12 @@ export class MlxBatchExecutionGroup {
1452
1640
  /** Account one sampled token for a row. Mirrors generate(): EOS terminates
1453
1641
  * WITHOUT an onToken call; otherwise onToken(token) runs and `false` halts;
1454
1642
  * reaching maxTokens ends with "length". Advances row.current on continue. */
1455
- async #emit(row: Row, token: number): Promise<"continue" | "stop" | "length"> {
1643
+ async #emit(row: Row, token: number, logprobs?: import("../../generate").TokenLogprobs): Promise<"continue" | "stop" | "length"> {
1456
1644
  row.req.signal?.throwIfAborted();
1645
+ this.#firstOutput(row);
1646
+ if (row.generated === 1) clearCache();
1457
1647
  if (row.req.eosTokenIds.includes(token)) return "stop";
1458
- const cont = await row.req.onToken(token);
1648
+ const cont = await row.req.onToken(token, logprobs);
1459
1649
  if (cont === false) return "stop";
1460
1650
  // Grammar termination: the matcher is satisfied (e.g. closing `}` accepted).
1461
1651
  // The final token has been delivered via onToken above; halt with "stop" so
@@ -1466,10 +1656,22 @@ export class MlxBatchExecutionGroup {
1466
1656
  return "continue";
1467
1657
  }
1468
1658
 
1659
+ #firstOutput(row: Row): void {
1660
+ if (row.generated === 1) {
1661
+ if (this.#prefill?.rows.includes(row)) this.#preparationPublishedOutput = true;
1662
+ row.firstTokenAt = performance.now();
1663
+ row.closeTokenZero?.();
1664
+ row.closeTokenZero = undefined;
1665
+ }
1666
+ }
1667
+
1469
1668
  #finish(row: Row, reason: "stop" | "length"): void {
1669
+ row.req.continuation?.complete();
1470
1670
  const now = performance.now();
1471
1671
  const first = row.firstTokenAt || now;
1472
1672
  row.resolve({
1673
+ ...(row.spec ? { spec: row.spec } : {}),
1674
+ ...(row.fill ? { fill: row.fill } : {}),
1473
1675
  promptTokens: row.promptTokens,
1474
1676
  generatedTokens: row.generated,
1475
1677
  cachedTokens: row.cachedTokens,
@@ -1484,13 +1686,28 @@ export class MlxBatchExecutionGroup {
1484
1686
  * offset lines up (defensive — a mismatch means the accounting is wrong
1485
1687
  * and the entry would corrupt future hits), else dispose. `retain` rides
1486
1688
  * along per the PromptCacheEntry contract (runs after dispose). */
1487
- #putOrDispose(caches: Cache[], tokens: number[], retain?: () => void): void {
1689
+ #putOrDispose(caches: Cache[], tokens: number[], retain?: () => void, namespace = "", sessionId?: string): void {
1488
1690
  if (this.#promptCache) {
1691
+ // A never-merged row can still own a row layout. Persistence receives
1692
+ // its existing serial representation, just like merged-row retirement.
1693
+ const extracted: Cache[] = [], replaced: Cache[] = [];
1694
+ let persistent: Cache[];
1695
+ try {
1696
+ persistent = caches.map(cache => {
1697
+ if (!isBatchableCache(cache) || cache.batchSize !== 1) return cache;
1698
+ const row = cache.extractRow(0);
1699
+ extracted.push(row); replaced.push(cache);
1700
+ return row;
1701
+ });
1702
+ } catch (error) { disposeResources(extracted); throw error; }
1703
+ try { disposeResources(replaced); }
1704
+ catch (error) { disposeResources(extracted); throw error; }
1705
+ caches = persistent;
1489
1706
  const withOff = caches.find(
1490
1707
  (c) => typeof (c as { offset?: unknown }).offset === "number",
1491
1708
  ) as { offset: number } | undefined;
1492
1709
  if (withOff && withOff.offset === tokens.length) {
1493
- this.#promptCache.put(tokens, caches, "", retain);
1710
+ this.#promptCache.put(tokens, caches, namespace, retain, undefined, sessionId);
1494
1711
  return;
1495
1712
  }
1496
1713
  }
@@ -1501,13 +1718,13 @@ export class MlxBatchExecutionGroup {
1501
1718
  /** Extract a finishing MERGED row's KV into fresh serial caches and put()
1502
1719
  * them keyed by [promptIds + fed] — the batch-lane mirror of mlx-lm
1503
1720
  * server.py:872 (extract_cache → prompt_cache.insert_cache). Gates:
1504
- * promptTokens >= 256 (the boundary-snapshot substantiality gate,
1505
- * server.ts) and an exact coverage key (!fedTainted). Refusal
1721
+ * promptTokens >= 256 or an explicit cache session, plus an exact
1722
+ * coverage key (!fedTainted). Refusal
1506
1723
  * (#extractRowCaches null) just disposes-by-omission — the row's KV dies
1507
1724
  * with the filter, exactly the pre-extraction behavior. */
1508
1725
  #extractAndPut(b: number, row: Row): void {
1509
1726
  if (!this.#promptCache || !row.merged || row.fedTainted) return;
1510
- if (row.promptTokens < 256) return;
1727
+ if (row.promptTokens < 256 && !row.req.cacheSessionId) return;
1511
1728
  const tokens = [...row.req.promptIds, ...row.fed];
1512
1729
  const caches = this.#extractRowCaches(b, tokens.length);
1513
1730
  if (!caches) return;
@@ -1515,8 +1732,8 @@ export class MlxBatchExecutionGroup {
1515
1732
  // slices depend on this step's KV writes): async — the batched source
1516
1733
  // buffers free once the copies land, instead of being pinned by a lazy
1517
1734
  // graph inside an idle cache entry.
1518
- ops.asyncEvalAll(caches.flatMap((c) => c.state()));
1519
- this.#putOrDispose(caches, tokens);
1735
+ withResource(leaseCacheStates(caches), state => ops.asyncEvalAll([...state]));
1736
+ this.#putOrDispose(caches, tokens, undefined, row.cacheNamespace, row.req.cacheSessionId);
1520
1737
  }
1521
1738
 
1522
1739
  /** Row `b` of every layer as OWNED serial-class caches, or null when a
@@ -1562,6 +1779,11 @@ export class MlxBatchExecutionGroup {
1562
1779
  /** Evict rows not in `keep` (sorted ascending) from the batched KV and the
1563
1780
  * pipeline register. */
1564
1781
  #applyFilter(keep: number[], dropOnly = false): void {
1782
+ if (this.#method) {
1783
+ this.#method.filterRows(keep, dropOnly);
1784
+ this.#running = keep.map(row => this.#running[row]!);
1785
+ return;
1786
+ }
1565
1787
  const inners = this.#inners!;
1566
1788
  if (keep.length === 0) {
1567
1789
  // Prompt-cache put (Phase 3.2): a lone NEVER-MERGED row's inners are
@@ -1579,6 +1801,7 @@ export class MlxBatchExecutionGroup {
1579
1801
  inners as Cache[],
1580
1802
  [...solo.req.promptIds, ...solo.fed],
1581
1803
  this.#adoptedRetain ?? undefined,
1804
+ solo.cacheNamespace, solo.req.cacheSessionId,
1582
1805
  );
1583
1806
  } else {
1584
1807
  for (const c of inners) c.dispose();
@@ -1593,6 +1816,11 @@ export class MlxBatchExecutionGroup {
1593
1816
  this.#pendingReal = null;
1594
1817
  return;
1595
1818
  }
1819
+ const keptFullPad = keep.map((i) => this.#fullLeftPad[i]!);
1820
+ // mlx-lm BatchKVCache.filter removes padding shared by all survivors.
1821
+ // Full-attention width and padding move together; each row's absolute
1822
+ // RoPE position stays unchanged. Rotating caches keep their own layout.
1823
+ const trimFull = this.#kinds.includes("full") ? Math.min(...keptFullPad) : 0;
1596
1824
  const out: LayerInner[] = [];
1597
1825
  for (const inner of inners) {
1598
1826
  if (isBatchableCache(inner)) {
@@ -1607,18 +1835,18 @@ export class MlxBatchExecutionGroup {
1607
1835
  throw new Error("applyFilter: adopted serial rotating cache cannot be row-filtered");
1608
1836
  } else if (isQuantizedKvCache(inner)) {
1609
1837
  const [k0, v0] = inner.temporalView();
1610
- const f = filterQuantRows(k0, v0, keep);
1838
+ const f = filterQuantRows(k0, v0, keep, trimFull);
1611
1839
  for (const t of [k0, v0]) { t.packed.dispose(); t.scales.dispose(); t.biases.dispose(); }
1612
1840
  const c = new QuantizedKVCache(inner.groupSize, inner.bits);
1613
- c.restoreState(f.keys, f.values, inner.offset);
1841
+ c.restoreState(f.keys, f.values, inner.offset - trimFull);
1614
1842
  out.push(c);
1615
1843
  inner.dispose();
1616
1844
  } else if (isPlainKvCache(inner)) {
1617
1845
  const [k0, v0] = inner.temporalView();
1618
- const f = filterKVRows(k0, v0, keep);
1846
+ const f = filterKVRows(k0, v0, keep, trimFull);
1619
1847
  k0.dispose(); v0.dispose();
1620
1848
  const c = new KVCache();
1621
- c.restoreState(f.keys, f.values, inner.offset);
1849
+ c.restoreState(f.keys, f.values, inner.offset - trimFull);
1622
1850
  out.push(c);
1623
1851
  inner.dispose();
1624
1852
  } else {
@@ -1626,7 +1854,7 @@ export class MlxBatchExecutionGroup {
1626
1854
  }
1627
1855
  }
1628
1856
  this.#inners = out;
1629
- this.#fullLeftPad = keep.map((i) => this.#fullLeftPad[i]!);
1857
+ this.#fullLeftPad = keptFullPad.map((padding) => padding - trimFull);
1630
1858
  this.#running = keep.map((i) => this.#running[i]!);
1631
1859
  if (this.#pendingToks) {
1632
1860
  const idx = ops.fromInt32(keep, [keep.length]);