mlx-bun 0.3.0 → 0.4.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 (150) hide show
  1. package/docs/README.md +4 -1
  2. package/docs/archive/investigations/openjarvis-fit.md +198 -0
  3. package/docs/archive/investigations/qwen38-27b-campaign-log.md +1092 -0
  4. package/docs/design/batching.md +1321 -49
  5. package/docs/design/decode-speed-program.md +331 -1163
  6. package/docs/design/generic-model-support.md +79 -3
  7. package/docs/design/kv-cache.md +198 -54
  8. package/docs/design/speculative-decoding.md +387 -24
  9. package/docs/design/turboquant.md +123 -13
  10. package/docs/design/unified-engine-frontier-plan.md +144 -142
  11. package/docs/planning/PRODUCT_ROADMAP.md +16 -0
  12. package/docs/planning/release-notes-v0.4.0.md +45 -0
  13. package/docs/reference/benchmarks.md +2457 -6
  14. package/docs/reference/cli.md +41 -2
  15. package/docs/reference/distribution.md +16 -9
  16. package/docs/reference/environment.md +55 -5
  17. package/docs/reference/models.md +13 -4
  18. package/docs/reference/server-api.md +79 -25
  19. package/docs/reference/server-config.md +178 -84
  20. package/docs/reference/troubleshooting.md +13 -21
  21. package/package.json +1 -1
  22. package/src/backends/mlx/assistant-target.ts +46 -0
  23. package/src/backends/mlx/autoregressive-draft-rows.ts +120 -0
  24. package/src/backends/mlx/batch-group.ts +368 -224
  25. package/src/backends/mlx/cache-attachment.ts +67 -0
  26. package/src/backends/mlx/cache-layout.ts +31 -0
  27. package/src/backends/mlx/checkpoint-state.ts +53 -0
  28. package/src/backends/mlx/continuation-persistence.ts +106 -0
  29. package/src/backends/mlx/continuation-request.ts +43 -0
  30. package/src/backends/mlx/continuation.ts +77 -0
  31. package/src/backends/mlx/draft-target.ts +7 -22
  32. package/src/backends/mlx/full-kv-row-append.ts +32 -0
  33. package/src/backends/mlx/full-kv-row-donor.ts +54 -0
  34. package/src/backends/mlx/full-transitioning-kv-rows.ts +65 -0
  35. package/src/backends/mlx/gateway-binding.ts +77 -18
  36. package/src/backends/mlx/kv-codec.ts +9 -0
  37. package/src/backends/mlx/kv-maintenance.ts +133 -0
  38. package/src/backends/mlx/kv-tensor-rows.ts +212 -0
  39. package/src/backends/mlx/model-serving.ts +1 -1
  40. package/src/backends/mlx/prefill-cohort.ts +89 -0
  41. package/src/backends/mlx/prefill-rows.ts +200 -0
  42. package/src/backends/mlx/prefill.ts +1 -1
  43. package/src/backends/mlx/projected-context-rows.ts +72 -0
  44. package/src/backends/mlx/request-state-policy.ts +41 -0
  45. package/src/backends/mlx/rollback.ts +41 -3
  46. package/src/backends/mlx/row-sampling.ts +49 -0
  47. package/src/backends/mlx/serial-executor.ts +35 -66
  48. package/src/backends/mlx/speculative-group.ts +359 -0
  49. package/src/backends/mlx/speculative-round.ts +161 -0
  50. package/src/backends/mlx/state-rows.ts +104 -0
  51. package/src/backends/mlx/state-views.ts +21 -1
  52. package/src/backends/mlx/target-layout-capability.ts +35 -0
  53. package/src/backends/mlx/transitioning-kv-rows.ts +165 -0
  54. package/src/cli.ts +11 -36
  55. package/src/contracts/execution.ts +11 -0
  56. package/src/contracts/prefix-cache.ts +23 -0
  57. package/src/contracts/resources.ts +7 -0
  58. package/src/contracts/sampling.ts +23 -0
  59. package/src/contracts/scheduling.ts +18 -0
  60. package/src/engine/execution-plan.ts +18 -16
  61. package/src/engine/resources.ts +12 -1
  62. package/src/engine/scheduler.ts +10 -5
  63. package/src/fit.ts +29 -15
  64. package/src/generate.ts +77 -173
  65. package/src/inference/draft-acceptance.ts +41 -0
  66. package/src/inference/draft-output.ts +49 -0
  67. package/src/inference/rollback.ts +2 -2
  68. package/src/kv-scheme.ts +70 -12
  69. package/src/kv-store.ts +70 -16
  70. package/src/lab/paged-kv/paged-kv-rows.ts +83 -0
  71. package/src/lab/paged-kv/paged-kv.ts +25 -11
  72. package/src/mlx/array.ts +2 -1
  73. package/src/model/artifact-identity.ts +73 -0
  74. package/src/model/batched-kv.ts +235 -0
  75. package/src/model/batched-mask.ts +12 -4
  76. package/src/model/batched-quant.ts +8 -2
  77. package/src/model/batched-quantized-kv.ts +144 -0
  78. package/src/model/batched-rotating-quant.ts +76 -15
  79. package/src/model/batched-rotating-state.ts +76 -0
  80. package/src/model/batched-rotating.ts +64 -22
  81. package/src/model/batched-row-storage.ts +52 -0
  82. package/src/model/batched-ssm.ts +52 -0
  83. package/src/model/batched-turboquant-kv.ts +93 -0
  84. package/src/model/compiled-decode.ts +7 -1
  85. package/src/model/decoded-kv-donor.ts +12 -0
  86. package/src/model/delayed-quantized-kv.ts +72 -0
  87. package/src/model/delayed-rotating-quantized-kv.ts +93 -0
  88. package/src/model/delayed-turboquant-kv.ts +55 -0
  89. package/src/model/full-prefill-padding.ts +56 -0
  90. package/src/model/full-prefill-row.ts +105 -0
  91. package/src/model/gemma4-base.ts +162 -80
  92. package/src/model/gemma4.ts +34 -26
  93. package/src/model/generated/gemma4-12b.ts +4 -1
  94. package/src/model/generated/gemma4-26b.ts +4 -1
  95. package/src/model/generated/gemma4-e4b.ts +4 -1
  96. package/src/model/glm52-cache.ts +80 -4
  97. package/src/model/glm52-mla.ts +12 -6
  98. package/src/model/glm52.ts +19 -40
  99. package/src/model/kv-attention-view.ts +67 -0
  100. package/src/model/minicpm5.ts +11 -7
  101. package/src/model/qwen3-delta-state.ts +74 -0
  102. package/src/model/qwen3-delta.ts +59 -36
  103. package/src/model/qwen3_5.ts +82 -42
  104. package/src/model/rotating-kv-donor.ts +35 -0
  105. package/src/model/rotating-kv-layout.ts +262 -0
  106. package/src/model/rotating-row-transaction.ts +35 -0
  107. package/src/model/speculative-rotating-kv.ts +101 -0
  108. package/src/model/ssm-prefill-padding.ts +39 -0
  109. package/src/model/trellis-linear.ts +2 -2
  110. package/src/model/turboquant-codec.ts +83 -0
  111. package/src/model/universal/dense.ts +14 -13
  112. package/src/preflight.ts +31 -15
  113. package/src/prompt-cache.ts +42 -44
  114. package/src/sampler.ts +36 -25
  115. package/src/serve/chat-stage.ts +2 -2
  116. package/src/serve/generation-gateway.ts +39 -30
  117. package/src/serve/model-host.ts +3 -4
  118. package/src/serve/request-plan.ts +4 -3
  119. package/src/serve/request-prep.ts +2 -4
  120. package/src/serve/text-completion-stage.ts +2 -2
  121. package/src/server.ts +64 -108
  122. package/src/spec/assistant-rows.ts +92 -0
  123. package/src/spec/assistant-source.ts +28 -18
  124. package/src/spec/cache-identity.ts +22 -0
  125. package/src/spec/deepspec-rows.ts +29 -0
  126. package/src/spec/deepspec-source.ts +12 -10
  127. package/src/spec/dflash-source.ts +28 -4
  128. package/src/spec/drafter.ts +153 -142
  129. package/src/spec/dspark/deepspec-module.ts +95 -120
  130. package/src/spec/dspark/module-dflash.ts +126 -170
  131. package/src/spec/glm52-mtp-graph.ts +29 -0
  132. package/src/spec/glm52-mtp-rows.ts +132 -0
  133. package/src/spec/glm52-mtp-source.ts +53 -136
  134. package/src/spec/glm52-mtp-state.ts +33 -0
  135. package/src/spec/ngram-source.ts +92 -20
  136. package/src/spec/projected-draft-rows.ts +73 -0
  137. package/src/spec/qwen-mtp-module.ts +171 -0
  138. package/src/spec/qwen-mtp-rows.ts +219 -0
  139. package/src/spec/qwen-mtp-source.ts +81 -226
  140. package/src/spec/qwen-mtp-state.ts +30 -0
  141. package/src/spec/serve-loop.ts +70 -68
  142. package/src/spec/source.ts +80 -15
  143. package/src/spec/two-model-rows.ts +61 -0
  144. package/src/spec/two-model.ts +43 -101
  145. package/src/ssd-cache.ts +33 -21
  146. package/src/ssd-durability.ts +11 -7
  147. package/src/web/app.js +57 -57
  148. package/src/backends/mlx/memory-guard.ts +0 -40
  149. package/src/spec/prefix-state.ts +0 -45
  150. /package/docs/{planning → archive/planning}/release-notes-v0.3.0.md +0 -0
@@ -1,11 +1,11 @@
1
+ import type { OrdinaryContinuation } from "./continuation";
2
+ import type { MlxRequestStatePolicy } from "./request-state-policy";
1
3
  import { AdmissionRejected } from "../../engine/admission";
2
- import type { ExecutionGroup } from "../../contracts/scheduling";
4
+ import type { ExecutionContext, ExecutionGroup } from "../../contracts/scheduling";
3
5
  import { driveExecutionGroup } from "../../engine/scheduler";
4
- import { disposeResources } from "../../engine/resources";
6
+ import { disposeResources, withResource } from "../../engine/resources";
5
7
  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";
8
+ import { MlxPrefillCohort, type PrefillState } from "./prefill-cohort";
9
9
  // Continuous-batching scheduler for `--batch N` serving (phase S2, the engine
10
10
  // loop). Owns ONE running batch and drives it forward one decode step at a
11
11
  // time, admitting waiting requests and evicting finished ones between steps —
@@ -87,14 +87,15 @@ import {
87
87
  type QuantRow,
88
88
  } from "../../model/batched-quant";
89
89
  import { BatchedRotatingQuantCache } from "../../model/batched-rotating-quant";
90
- import type { KvQuantSpec } from "../../config";
90
+ import { cloneSingleRowState, leaseCacheStates } from "./state-views";
91
+ import { ownedCacheLayout } from "./cache-layout";
92
+ import { createKvMaintenance, type KvMaintenance } from "./kv-maintenance";
91
93
  import type { KvScheme } from "../../kv-scheme";
92
94
  import { BatchedRotatingCache } from "../../model/batched-rotating";
93
- import { cloneKvCaches } from "../../kv-store";
94
95
  import { SSMCache } from "../../model/qwen3-delta";
95
96
  import type { RuntimeModel } from "../../model/factory";
96
97
  import type { GrammarController } from "../../grammar";
97
- import { toLogprobs } from "../../sampler";
98
+ import { independentGreedySampling } from "../../sampler";
98
99
  import { acquireModelWiredLimit } from "../../generate";
99
100
  import { batchRowKvBytes } from "../../serve/kv-budget";
100
101
  import type { PromptResponseTrace } from "../../serve/prompt-response-trace";
@@ -132,19 +133,64 @@ export function stepTraceReport(): string {
132
133
  * step 0..maxTokens-1 for this row (never beyond maxTokens). */
133
134
  export type RowSampler = (logits1V: MlxArray, step: number) => MlxArray;
134
135
 
135
- export interface BatchRequest {
136
+ /** A method prepares and advances rows; the shared executor owns queueing. */
137
+ export interface MlxGroupPreparation {
138
+ readonly rows: readonly Row[];
139
+ readonly tokenWeight?: number;
140
+ readonly canAdmit?: boolean;
141
+ admit?(row: Row): void;
142
+ advance(): Promise<boolean>;
143
+ dispose(): void;
144
+ }
145
+ export interface MlxGroupMethodHost {
146
+ readonly rows: readonly Row[];
147
+ readonly runtime: RuntimeConfig;
148
+ readonly prefillChunkSize: number;
149
+ readonly promptCache: RowPromptCache | undefined;
150
+ join(row: Row): void;
151
+ filterRows(keep: number[]): void;
152
+ publish(row: Row, token: number, logprobs?: import("../../generate").TokenLogprobs): Promise<void | boolean>;
153
+ finish(row: Row, reason: "stop" | "length"): void;
154
+ }
155
+ export interface MlxGroupedMethod {
156
+ prepare(row: Row): MlxGroupPreparation;
157
+ advance(): Promise<void>;
158
+ filterRows(keep: readonly number[], discard: boolean): void;
159
+ dispose(): void;
160
+ }
161
+ export interface MlxGroupMethodRequest {
162
+ /** Equal keys declare compatible method state, independent of queue size. */
163
+ readonly key: string;
164
+ readonly data: unknown;
165
+ open(host: MlxGroupMethodHost): MlxGroupedMethod;
166
+ }
167
+
168
+ export type BatchRequest = BatchRequestFields & (
169
+ | { method: MlxGroupMethodRequest; sample?: RowSampler }
170
+ | { method?: undefined; sample: RowSampler }
171
+ );
172
+
173
+ interface BatchRequestFields {
174
+ /** Optional ordinary continuation policy; owns persistence and sampler recovery. */
175
+ continuation?: OrdinaryContinuation;
176
+ statePolicy?: MlxRequestStatePolicy;
177
+ context?: ExecutionContext;
178
+ /** Resolve loaded-state identity under the execution lease, after any
179
+ * queued adapter replacement has finished. */
180
+ cacheNamespace?: string | (() => string);
136
181
  /** Resolved replay permission; standalone group callers retain auto mode. */
137
182
  compiledDecode?: boolean;
138
183
  promptIds: number[];
184
+ /** Request policy overrides the execution group's captured prefill default. */
185
+ prefillChunkSize?: number;
139
186
  maxTokens: number;
140
187
  eosTokenIds: number[];
141
- sample: RowSampler;
142
188
  /** Called per emitted (non-EOS) token, in order. Returning `false` halts this
143
189
  * row (a decoded-text stop sequence fired) — matches generate()'s onToken
144
190
  * contract. EOS terminates the row WITHOUT an onToken call. Throwing evicts
145
191
  * THIS row only (its submit promise rejects; siblings continue). May be
146
192
  * async; keep it cheap — it runs inline in the step loop. */
147
- onToken: (token: number) => void | boolean | Promise<void | boolean>;
193
+ onToken: (token: number, logprobs?: import("../../generate").TokenLogprobs) => void | boolean | Promise<void | boolean>;
148
194
  /** Diagnostic-only request-local trace. */
149
195
  trace?: PromptResponseTrace;
150
196
  /** Closes the gateway's queue/admission span when this row leaves pending. */
@@ -178,6 +224,7 @@ export interface BatchRequest {
178
224
  }
179
225
 
180
226
  export interface BatchStats {
227
+ spec?: import("../../generate").GenerateStats["spec"];
181
228
  promptTokens: number;
182
229
  generatedTokens: number;
183
230
  /** Prompt tokens served from the prompt cache (Phase 3.2): a joiner's solo
@@ -200,14 +247,10 @@ export interface BatchStats {
200
247
  * finish INSIDE a multi-row batch get their KV EXTRACTED per row into
201
248
  * fresh serial caches first (mlx-lm server.py:872 extract_cache — the
202
249
  * 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
- }
250
+ export type RowPromptCache = import("./checkpoint-state").MlxPrefixCache;
209
251
 
210
- interface Row {
252
+ export interface Row {
253
+ spec?: import("../../generate").GenerateStats["spec"];
211
254
  req: BatchRequest;
212
255
  resolve: (s: BatchStats) => void;
213
256
  reject: (e: unknown) => void;
@@ -220,6 +263,8 @@ interface Row {
220
263
  /** performance.now() marks for BatchStats timing (0 = not reached). */
221
264
  admittedAt: number;
222
265
  firstTokenAt: number;
266
+ closeTokenZero?: () => void;
267
+ cacheNamespace?: string;
223
268
  /** Generated tokens whose KV actually entered the cache (fed as a step
224
269
  * input) — serial generate()'s `forwarded` list. promptIds+fed is the
225
270
  * exact token coverage of the row's caches, the put() entry key.
@@ -242,30 +287,14 @@ interface Row {
242
287
  merged: boolean;
243
288
  }
244
289
 
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
290
  /** Held while the batch is active; the serial fallback acquires the same lock. */
262
291
  export interface ExclusiveLock {
263
292
  acquire(): Promise<() => void>;
264
293
  }
265
294
 
266
295
  export interface MlxBatchExecutionGroupOptions {
267
- /** Shared with serial execution; checked under the GPU lease after cache take. */
268
- memoryBudget?: MlxMemoryBudget;
296
+ /** Scheduling work budget for extending an existing preparation cohort. */
297
+ prefillBatchTokenLimit?: number;
269
298
  runtime?: RuntimeConfig;
270
299
  maxQueued?: number;
271
300
  stateCodecs?: import("../../kv-store").CacheCodecProvider;
@@ -290,6 +319,8 @@ export interface MlxBatchExecutionGroupOptions {
290
319
  * the same chunk boundaries as the serial path, so a row's quantized bytes
291
320
  * preserve the L2 composition. Unsupported schemes fail at construction. */
292
321
  kvScheme?: KvScheme;
322
+ /** Model binding owns support for precision transitions. */
323
+ kvBatchCapabilities?: { delayedAffine?: boolean };
293
324
  /** Prompt-cache hook (Phase 3.2): admission take()s the longest usable
294
325
  * prefix into the joiner's solo caches (suffix-only prefill — the
295
326
  * multi-turn chat TTFT path); rows that finish never-merged put() their
@@ -320,7 +351,8 @@ export class MlxBatchExecutionGroup {
320
351
  #inners: LayerInner[] | null = null; // per-layer batched KV; null when empty
321
352
  #fullLeftPad: number[] = []; // per-row padding for FULL layers (rot self-tracks)
322
353
  #pending: Row[] = [];
323
- #prefill: PrefillState | null = null; // the (single) joiner mid-prefill
354
+ #prefill: MlxGroupPreparation | null = null; // the (single) joiner mid-prefill
355
+ #preparationPublishedOutput = false;
324
356
  /** Sampled-but-unread token array [B], aligned with #running — the decode
325
357
  * pipeline register. Filtered/disposed alongside the batched KV. */
326
358
  #pendingToks: MlxArray | null = null;
@@ -329,6 +361,8 @@ export class MlxBatchExecutionGroup {
329
361
  * accounting must skip it — see Row.fed/fedTainted). Filtered/cleared in
330
362
  * lockstep with the register. */
331
363
  #pendingReal: boolean[] | null = null;
364
+ #method: MlxGroupedMethod | undefined;
365
+ #methodKey: string | undefined;
332
366
  #steps = 0; // decode-step counter (clearCache cadence)
333
367
  #looping = false;
334
368
  #closed = false;
@@ -338,24 +372,29 @@ export class MlxBatchExecutionGroup {
338
372
  readonly #lock: ExclusiveLock | undefined;
339
373
  readonly #admissionHeld: (() => boolean) | undefined;
340
374
  readonly #prefillChunkSize: number;
375
+ readonly #prefillBatchTokenLimit: number;
341
376
  readonly #prefillTailSplit: boolean;
342
377
  readonly #kvBudgetBytes: number | undefined;
343
- readonly #memoryBudget: MlxMemoryBudget | undefined;
344
- readonly #promptCache: RowPromptCache | undefined;
378
+ readonly #defaultPromptCache: RowPromptCache | undefined;
379
+ #statePolicy?: MlxRequestStatePolicy;
380
+ get #promptCache(): RowPromptCache | undefined {
381
+ return this.#statePolicy ? this.#statePolicy.promptCache : this.#defaultPromptCache;
382
+ }
345
383
  /** Retain hook of the currently-ADOPTED row's cache entry (at most one:
346
384
  * only a lone adopted row holds un-copied entry caches). Runs after the
347
385
  * adopted caches are disposed, or transfers back on put(). */
348
386
  #adoptedRetain: (() => void) | null = null;
387
+ #contextKey: string | undefined;
388
+ #releaseContext: (() => void) | undefined;
349
389
  readonly #kinds: ("full" | "rot" | "ssm" | "owned-batch")[];
350
390
  readonly #rotMaxSize: number[]; // per-layer sliding window (rot layers only)
351
391
  readonly #compressedProjectors: Array<(tokens: number) => number> | null;
352
392
  readonly #batchCacheMaxTokens: number | null;
353
393
  readonly #kvScheme: KvScheme | undefined;
354
- /** layerIdx → mixed-precision spec (Phase 3.1); null = bf16 batch (v1). */
355
- readonly #kvByLayer: Map<number, KvQuantSpec> | null;
394
+ readonly #maintainKv: KvMaintenance | null;
356
395
  /** Compiled decode runner for the B=1 serial-class case (Phase 3.2) —
357
396
  * same eligibility gate as generate.ts (gemma dense, kill switch
358
- * MLX_BUN_COMPILED_DECODE); adapters never reach the batch lane. Set
397
+ * MLX_BUN_COMPILED_DECODE); adapter requests disable replay in their plan. Set
359
398
  * to null permanently on a failed step (serial disables per
360
399
  * generation; the scheduler is one long-lived "generation"). */
361
400
  #compiled: CompiledDecode | null;
@@ -370,17 +409,19 @@ export class MlxBatchExecutionGroup {
370
409
  this.#maxBatch = Math.max(1, Math.floor(opts.maxBatch));
371
410
  this.#lock = opts.lock;
372
411
  this.#admissionHeld = opts.admissionHeld;
373
- this.#prefillChunkSize = Math.max(1, Math.floor(opts.prefillChunkSize ?? 2048));
412
+ this.#prefillChunkSize = Math.max(1, Math.floor(opts.prefillChunkSize ??
413
+ this.#runtime.number("MLX_BUN_RD_PREFILL_CHUNK", 2048)));
414
+ this.#prefillBatchTokenLimit = opts.prefillBatchTokenLimit ?? 2048;
374
415
  this.#prefillTailSplit = this.#runtime.flag("MLX_BUN_PREFILL_TAIL_SPLIT", true);
375
416
  this.#kvBudgetBytes = opts.kvBudgetBytes;
376
- this.#memoryBudget = opts.memoryBudget;
377
- this.#promptCache = opts.promptCache;
417
+ this.#defaultPromptCache = opts.promptCache;
378
418
  this.#kvScheme = opts.kvScheme;
379
419
  const proto = withRuntimeConfig(this.#runtime, () => model.makeCache()); // fresh caches hold no buffers
380
420
  if (this.#kvScheme && !this.#kvScheme.batchable(
381
421
  model.config,
382
422
  (layerIdx) =>
383
423
  isPlainKvCache(proto[layerIdx]) || isRotatingPlainCache(proto[layerIdx]),
424
+ proto.length, opts.kvBatchCapabilities,
384
425
  )) {
385
426
  for (const cache of proto) cache.dispose();
386
427
  throw new Error(`unsupported KV scheme for batch scheduler: ${this.#kvScheme.kind}`);
@@ -398,11 +439,10 @@ export class MlxBatchExecutionGroup {
398
439
  ? proto.map((cache) => (tokens: number) => cache.projectedBytes(tokens))
399
440
  : null;
400
441
  this.#batchCacheMaxTokens = proto.every(isBatchableCache)
401
- ? Math.min(...proto.map((cache) => cache.maxTokens ?? Number.MAX_SAFE_INTEGER))
442
+ ? Math.min(...proto.map((cache) => cache.maxTokens ?? Infinity))
402
443
  : null;
403
- const kvConfig = this.#kvScheme?.options.kvConfig;
404
- this.#kvByLayer = kvConfig?.length
405
- ? new Map(kvConfig.map((entry) => [entry.layerIdx, entry]))
444
+ this.#maintainKv = this.#kvScheme?.quantized
445
+ ? createKvMaintenance(this.#kvScheme.options)
406
446
  : null;
407
447
  this.#rotMaxSize = proto.map((c) => (isRotatingPlainCache(c) ? c.maxSize : 0));
408
448
  for (const c of proto) c.dispose();
@@ -418,7 +458,7 @@ export class MlxBatchExecutionGroup {
418
458
  }
419
459
 
420
460
  get pendingRows(): number {
421
- return this.#pending.length + (this.#prefill ? 1 : 0);
461
+ return this.#pending.length + (this.#prefill?.rows.length ?? 0);
422
462
  }
423
463
 
424
464
  /** Projected KV bytes of one row at its worst case (full prompt + full
@@ -437,14 +477,6 @@ export class MlxBatchExecutionGroup {
437
477
  );
438
478
  }
439
479
 
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
480
  async #forwardHidden(ids: MlxArray, cache: Cache[]): Promise<MlxArray> {
449
481
  const asyncModel = this.model as RuntimeModel & {
450
482
  forwardHiddenAsync?: (ids: MlxArray, cache: Cache[]) => Promise<MlxArray>;
@@ -457,7 +489,7 @@ export class MlxBatchExecutionGroup {
457
489
  /** Projected aggregate KV of everything admitted (running + mid-prefill). */
458
490
  get projectedKvBytes(): number {
459
491
  let total = this.#running.reduce((a, r) => a + this.#rowKvBytes(r), 0);
460
- if (this.#prefill) total += this.#rowKvBytes(this.#prefill.row);
492
+ if (this.#prefill) for (const row of this.#prefill.rows) total += this.#rowKvBytes(row);
461
493
  return total;
462
494
  }
463
495
 
@@ -551,6 +583,14 @@ export class MlxBatchExecutionGroup {
551
583
  get active() { return scheduler.#running.length; },
552
584
  get queued() { return scheduler.#pending.length; },
553
585
  get preparing() { return scheduler.#prefill !== null; },
586
+ get preparingRows() { return scheduler.#prefill?.rows.length ?? 0; },
587
+ get canPrepareMore() { return scheduler.#prefill?.canAdmit === true; },
588
+ get preparingTokens() { return scheduler.#prefill?.tokenWeight ?? 0; },
589
+ // Cache restoration belongs to preparation. A queued request's prompt
590
+ // length is an upper bound until its cache owner resolves the prefix.
591
+ get nextPreparationTokens() { return scheduler.#pending[0]?.promptTokens ?? 0; },
592
+ get maxPreparationTokens() { return scheduler.#prefillBatchTokenLimit; },
593
+ get preparationPublishedOutput() { return scheduler.#preparationPublishedOutput; },
554
594
  get maxActive() { return scheduler.#maxBatch; },
555
595
  get admissionHeld() { return scheduler.#admissionHeld?.() === true; },
556
596
  get closed() { return scheduler.#closed; },
@@ -563,18 +603,22 @@ export class MlxBatchExecutionGroup {
563
603
  }
564
604
  },
565
605
  admitNext: () => this.#admitNext(),
566
- canBurst: () => this.#kvBudgetBytes === undefined ||
567
- this.projectedKvBytes + this.#rowKvBytes(this.#pending[0]!) <= this.#kvBudgetBytes,
606
+ canBurst: () => this.#contextCompatible(this.#pending[0]!) &&
607
+ (this.#kvBudgetBytes === undefined ||
608
+ this.projectedKvBytes + this.#rowKvBytes(this.#pending[0]!) <= this.#kvBudgetBytes),
568
609
  advancePreparation: () => this.#advancePreparation(),
569
- advance: () => this.#withMemoryGuard(
570
- Math.max(0, ...this.#running.map((row) => row.promptTokens + row.sampled)),
571
- () => this.#step()),
610
+ advance: () => this.#method ? this.#method.advance() : this.#step(),
572
611
  failActive: (error) => {
573
612
  for (const row of this.#running) row.reject(error);
574
613
  this.#applyFilter([], true); // failed state never enters the prefix store
575
614
  },
576
615
  failAll: (error) => this.#failAll(error),
577
- reserveResidency: () => acquireModelWiredLimit(this.model),
616
+ reserveResidency: () => {
617
+ const release = acquireModelWiredLimit(this.model);
618
+ return () => disposeResources([
619
+ { dispose: () => this.#leaveContext() }, { dispose: release },
620
+ ]);
621
+ },
578
622
  ...(this.#lock ? { acquireExecution: () => this.#lock!.acquire() } : {}),
579
623
  waitForWork: async () => {
580
624
  await new Promise<void>((resolve) => { this.#wake = resolve; });
@@ -589,66 +633,106 @@ export class MlxBatchExecutionGroup {
589
633
  } finally { this.#looping = false; }
590
634
  }
591
635
 
636
+ #contextCompatible(row: Row): boolean {
637
+ 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);
638
+ }
639
+
640
+ #leaveContext(): void {
641
+ const release = this.#releaseContext;
642
+ this.#releaseContext = undefined;
643
+ this.#contextKey = undefined;
644
+ release?.();
645
+ }
646
+
592
647
  #admitNext(): boolean {
593
- if (!this.#kvAdmits(this.#pending[0]!)) return false;
648
+ if (!this.#contextCompatible(this.#pending[0]!) || !this.#kvAdmits(this.#pending[0]!)) return false;
594
649
  const row = this.#pending.shift()!;
595
- let owned: { caches: Cache[]; retain?: () => void } | undefined;
596
- let closePrefill: (() => void) | undefined;
597
650
  try {
651
+ this.#statePolicy = row.req.statePolicy;
652
+ row.cacheNamespace = typeof row.req.cacheNamespace === "function"
653
+ ? row.req.cacheNamespace() : row.req.cacheNamespace;
654
+ const key = row.req.context?.key ?? "";
655
+ if (key !== this.#contextKey) {
656
+ this.#leaveContext();
657
+ this.#releaseContext = row.req.context?.enter();
658
+ this.#contextKey = key;
659
+ }
598
660
  row.req.onAdmitted?.();
599
661
  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
- }
662
+ if (row.req.method?.key !== this.#methodKey) {
663
+ this.#method?.dispose(); this.#method = undefined; this.#methodKey = undefined;
664
+ if (row.req.method) {
665
+ const executor = this;
666
+ this.#method = row.req.method.open({
667
+ get rows() { return executor.#running; }, runtime: this.#runtime,
668
+ prefillChunkSize: this.#prefillChunkSize, promptCache: this.#promptCache,
669
+ join: row => { this.#running.push(row); },
670
+ filterRows: keep => this.#applyFilter(keep),
671
+ publish: async (row, token, logprobs) => {
672
+ row.req.signal?.throwIfAborted();
673
+ this.#firstOutput(row);
674
+ return row.req.onToken(token, logprobs);
675
+ },
676
+ finish: (row, reason) => this.#finish(row, reason),
677
+ });
678
+ this.#methodKey = row.req.method.key;
679
+ }
680
+ }
681
+ if (this.#prefill) {
682
+ this.#prefill.admit!(row);
683
+ } else if (this.#method) {
684
+ this.#prefill = this.#method.prepare(row);
685
+ } else {
686
+ const preparation = new MlxPrefillCohort({
687
+ model: this.model, chunkSize: this.#prefillChunkSize, tailSplit: this.#prefillTailSplit,
688
+ promptCache: this.#promptCache, stateCodecs: this.#stateCodecs, maintain: this.#maintainKv ?? undefined,
689
+ forward: (ids, caches) => this.#forwardHidden(ids, caches),
690
+ project: (hidden, caches, completed) => this.#projectPrefill(hidden, caches, completed),
691
+ complete: (state, logits) => this.#completePrefill(state, logits),
692
+ resume: state => this.#completePrefill(state, null),
693
+ reject: (failed, error) => this.#rejectPreparingRows([failed], error),
694
+ });
695
+ preparation.admit(row); this.#prefill = preparation;
696
+ }
697
+ } catch (error) { row.reject(error); }
625
698
  return true;
626
699
  }
627
700
 
701
+ #rejectPreparingRows(rows: readonly Row[], error: unknown): void {
702
+ const retiring = new Set(rows);
703
+ const keep = this.#running.flatMap((row, index) => retiring.has(row) ? [] : [index]);
704
+ if (keep.length !== this.#running.length) this.#applyFilter(keep, true);
705
+ for (const row of rows) row.reject(error);
706
+ }
707
+
628
708
  async #advancePreparation(): Promise<void> {
629
- const p = this.#prefill!;
709
+ const p = this.#prefill!, rows = p.rows;
710
+ this.#preparationPublishedOutput = false;
630
711
  try {
631
- if (await this.#withMemoryGuard(p.row.promptTokens, () => this.#prefillChunk(p))) {
632
- p.closePrefill?.();
633
- this.#prefill = null;
634
- }
712
+ if (await p.advance()) this.#prefill = null;
635
713
  } catch (error) {
636
714
  this.#prefill = null;
637
715
  let failure = error;
638
- try { disposeResources([...p.solo, { dispose: () => p.retain?.() }, { dispose: () => p.closePrefill?.() }]); }
716
+ try { p.dispose(); }
639
717
  catch (cleanupError) { failure = new AggregateError([error, cleanupError], "batch prefill and cleanup failed"); }
640
- p.row.reject(failure);
718
+ try { this.#rejectPreparingRows(rows, failure); }
719
+ catch (cleanupError) {
720
+ failure = new AggregateError([failure, cleanupError], "batch admission retirement failed");
721
+ for (const row of rows) row.reject(failure);
722
+ this.#failAll(failure);
723
+ }
641
724
  }
642
725
  }
643
726
 
644
727
  #failAll(error: unknown): void {
645
728
  const p = this.#prefill;
646
- const rows = new Set([...this.#running, ...this.#pending, ...(p ? [p.row] : [])]);
729
+ const rows = new Set([...this.#running, ...this.#pending, ...(p?.rows ?? [])]);
647
730
  const retain = this.#adoptedRetain;
648
- const resources = [...(p?.solo ?? []), ...(this.#inners ?? []), this.#pendingToks,
649
- { dispose: () => p?.retain?.() }, { dispose: () => p?.closePrefill?.() }, { dispose: () => retain?.() }]
731
+ const resources = [p, this.#method, ...(this.#inners ?? []), this.#pendingToks,
732
+ { dispose: () => retain?.() }]
650
733
  .filter((resource): resource is DisposableResource => resource != null);
651
734
  this.#pending = []; this.#running = []; this.#prefill = null;
735
+ this.#method = undefined; this.#methodKey = undefined;
652
736
  this.#inners = null; this.#pendingToks = null; this.#pendingReal = null;
653
737
  this.#fullLeftPad = []; this.#adoptedRetain = null;
654
738
  for (const row of rows) row.reject(error);
@@ -665,110 +749,105 @@ export class MlxBatchExecutionGroup {
665
749
  * bytes bit-exact vs serial `--kv-quant config`. Gateway placement and the
666
750
  * constructor both guarantee every named cache can convert. */
667
751
  #quantizeSolo(solo: Cache[], trace?: PromptResponseTrace): void {
668
- if (!this.#kvByLayer) return;
752
+ if (!this.#maintainKv) return;
669
753
  const close = trace?.begin("prefill.kv_maintenance", {
670
754
  mechanism: "continuous",
671
755
  });
672
756
  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
- }
757
+ this.#maintainKv(solo);
687
758
  } finally {
688
759
  close?.();
689
760
  }
690
761
  }
691
762
 
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;
763
+ /** The target owns projection geometry; samplers receive individual rows. */
764
+ #projectPrefill(hidden: MlxArray, caches: Cache[], completed: readonly PrefillState[]): MlxArray {
765
+ for (const p of completed) {
766
+ p.closePrefill?.(); p.closePrefill = undefined;
767
+ p.row.closeTokenZero = p.row.req.trace?.begin("token_zero.total", { mechanism: "continuous" });
722
768
  }
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();
769
+ const attributed = this.#runtime.value("MLX_BUN_P2R_SYNC") === "1"
770
+ ? completed.flatMap(p => p.row.req.trace ? [p.row.req.trace] : []) : [];
771
+ const begin = (phase: "token_zero.forward" | "token_zero.head") => attributed.map(trace => trace.begin(phase, {
772
+ mechanism: "continuous", activeBytes: activeMemory(), cacheBytes: cacheMemory(), peakBytes: peakMemory(),
773
+ }));
774
+ if (attributed.length) {
775
+ const closes = begin("token_zero.forward");
776
+ try { withResource(leaseCacheStates(caches), state => ops.evalAll([hidden, ...state])); }
777
+ finally { for (const close of closes) close(); }
743
778
  }
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();
779
+ // Project before selecting rows: M4 quantized projection can use different
780
+ // arithmetic at M=1. Tail-split preparation reaches this with N=1.
781
+ const logits = this.model.logitsFromHidden(hidden);
782
+ try {
783
+ if (attributed.length) {
784
+ const closes = begin("token_zero.head");
785
+ try { ops.evalAll([logits]); } finally { for (const close of closes) close(); }
786
+ }
787
+ return logits;
788
+ } catch (error) { logits.dispose(); throw error; }
789
+ }
790
+
791
+ /** Sample borrowed logits from the shared target projection. */
792
+ async #completePrefill(p: PrefillState, logits: MlxArray | null): Promise<void> {
793
+ if (p.continuation) {
794
+ const saved = p.continuation, row = p.row;
795
+ row.req.continuation!.resumeSampling(saved);
796
+ const interval = row.req.continuation!.interval;
797
+ this.#checkpointAt.set(row, (Math.floor(saved.generatedTokens / interval) + 1) * interval);
798
+ row.firstTokenAt = performance.now();
799
+ row.closeTokenZero?.(); row.closeTokenZero = undefined;
800
+ this.#preparationPublishedOutput = true;
801
+ for (const token of saved.cacheTokens.slice(row.req.promptIds.length)) {
802
+ row.req.signal?.throwIfAborted();
803
+ if (await row.req.onToken(token) === false)
804
+ throw new Error("saved generation prefix triggered a terminal stop while replaying");
805
+ }
806
+ row.fed = saved.cacheTokens.slice(row.req.promptIds.length);
807
+ row.generated = saved.generatedTokens + 1;
808
+ row.sampled = saved.generatedTokens + 1;
809
+ row.cachedTokens = Math.min(saved.cacheTokens.length, row.promptTokens);
810
+ const stop = await this.#emit(row, saved.pendingToken);
811
+ if (stop !== "continue") {
812
+ this.#putOrDispose(p.solo, saved.cacheTokens, p.retain, row.cacheNamespace);
813
+ p.solo = [];
814
+ this.#finish(row, stop);
815
+ return;
816
+ }
817
+ await this.#mergeJoiner(p);
818
+ return;
758
819
  }
759
- const V = lg.shape[2]!;
760
- const last2 = ops.reshape(lg, [1, V]);
761
- lg.dispose();
820
+ if (!logits) throw new Error("ordinary prefill completed without logits");
821
+ const forceAttribution = !!p.row.req.trace && this.#runtime.value("MLX_BUN_P2R_SYNC") === "1";
822
+ const V = logits.shape[2]!;
823
+ const last2 = ops.reshape(logits, [1, V]);
762
824
  const closeSample = forceAttribution
763
825
  ? p.row.req.trace!.begin("token_zero.sample", { mechanism: "continuous" })
764
826
  : undefined;
765
- const tok = this.#readToken(p.row.req.sample(last2, 0));
827
+ let sampled: MlxArray;
828
+ try { sampled = p.row.req.sample!(last2, 0); } finally { last2.dispose(); }
829
+ // The first admission can seed the existing device-token register. Its
830
+ // first decode forward then overlaps token-zero readback, just like every
831
+ // later pipeline step. Grammar and explicit eager-output modes retain
832
+ // their read-before-advance order.
833
+ if (!this.#inners && !p.row.req.grammar && !this.#noPipeline &&
834
+ this.#runtime.value("MLX_BUN_EARLY_FIRST_TOKEN") !== "1" && !forceAttribution) {
835
+ let owned: MlxArray | null = sampled;
836
+ try {
837
+ ops.asyncEvalAll([sampled]);
838
+ p.row.sampled = 1;
839
+ this.#quantizeSolo(p.solo, p.row.req.trace);
840
+ await this.#mergeJoiner(p);
841
+ this.#pendingToks = sampled;
842
+ this.#pendingReal = [true];
843
+ owned = null;
844
+ return;
845
+ } finally { owned?.dispose(); }
846
+ }
847
+ const tok = this.#readToken(sampled);
766
848
  closeSample?.();
767
- closeTokenZero?.();
768
- last2.dispose();
769
849
  p.row.sampled = 1;
770
850
  p.row.generated = 1;
771
- clearCache(); // drop the prefill transients (serial's token-0 clear)
772
851
 
773
852
  // Grammar (B1): the mask0 was applied inside the sample closure (the
774
853
  // controller is primed at compile). After reading token 0, advance the
@@ -782,17 +861,17 @@ export class MlxBatchExecutionGroup {
782
861
  const stop = await this.#emit(p.row, tok);
783
862
  // Token 0 was sampled but never fed — the caches cover exactly the
784
863
  // prompt, a clean prompt-only entry (put-or-dispose).
785
- this.#putOrDispose(p.solo, p.row.req.promptIds, p.retain);
864
+ this.#putOrDispose(p.solo, p.row.req.promptIds, p.retain, p.row.cacheNamespace);
786
865
  this.#finish(p.row, stop === "continue" ? "stop" : stop);
787
- return true;
866
+ return;
788
867
  }
789
868
  }
790
869
 
791
870
  const stop = await this.#emit(p.row, tok);
792
871
  if (stop !== "continue") {
793
- this.#putOrDispose(p.solo, p.row.req.promptIds, p.retain);
872
+ this.#putOrDispose(p.solo, p.row.req.promptIds, p.retain, p.row.cacheNamespace);
794
873
  this.#finish(p.row, stop);
795
- return true;
874
+ return;
796
875
  }
797
876
  // Default tail-split path: the caches were already converted at the head
798
877
  // boundary above (oracle composition: prefill ids[:-1] → convert → L=1
@@ -803,10 +882,7 @@ export class MlxBatchExecutionGroup {
803
882
  // (before decode step 1 == before the merge).
804
883
  this.#quantizeSolo(p.solo, p.row.req.trace);
805
884
  await this.#mergeJoiner(p);
806
- return true;
807
- } finally {
808
- closeChunk?.();
809
- }
885
+ return;
810
886
  }
811
887
 
812
888
  /** Merge a fully-prefilled joiner with the running batch, layer by layer
@@ -814,6 +890,7 @@ export class MlxBatchExecutionGroup {
814
890
  * first so the row set is settled and the next step starts cold. */
815
891
  async #mergeJoiner(p: PrefillState): Promise<void> {
816
892
  await this.#flushPipeline();
893
+ this.#maintainKv?.prepareBatch?.(p.solo);
817
894
 
818
895
  // ADOPT, don't copy (unified-engine plan Phase 3.2): a row joining an
819
896
  // EMPTY batch keeps its solo caches as the batch inners — a pointer
@@ -826,9 +903,9 @@ export class MlxBatchExecutionGroup {
826
903
  // take/put become possible for it. The rot branch below knows how to
827
904
  // treat an adopted RotatingKVCache as the merge's first row.
828
905
  if (!this.#inners) {
829
- this.#inners = p.solo as LayerInner[];
906
+ this.#inners = p.solo as LayerInner[]; p.solo = [];
830
907
  this.#fullLeftPad = [0];
831
- this.#adoptedRetain = p.retain ?? null;
908
+ this.#adoptedRetain = p.retain ?? null; p.retain = undefined;
832
909
  this.#running.push(p.row);
833
910
  return;
834
911
  }
@@ -839,17 +916,17 @@ export class MlxBatchExecutionGroup {
839
916
  const newInners: LayerInner[] = [];
840
917
  let newFullPad = this.#fullLeftPad;
841
918
  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();
919
+ const solo = p.solo[layer]!;
920
+ const merged = ownedCacheLayout(solo);
921
+ if (merged) {
847
922
  const previous = prev?.[layer];
848
923
  merged.mergeRows(previous ? [previous, solo] : [solo]);
849
924
  newFullPad = [...merged.leftPad];
850
925
  newInners.push(merged);
851
926
  continue;
852
927
  }
928
+ if (this.#kinds[layer] === "owned-batch")
929
+ throw new Error(`batch-capable layer ${layer} lost its cache capability`);
853
930
  if (this.#kinds[layer] === "ssm") {
854
931
  // No temporal axis, no left-pad: B-axis concat of the state slots.
855
932
  // mergeRows steals the solo arrays when the batch starts cold, so the
@@ -1049,8 +1126,9 @@ export class MlxBatchExecutionGroup {
1049
1126
  // run its retain now. The joiner's likewise after its solo dispose.
1050
1127
  this.#adoptedRetain?.();
1051
1128
  this.#adoptedRetain = null;
1052
- for (const c of p.solo) c.dispose();
1053
- p.retain?.();
1129
+ const completed = p.solo; p.solo = [];
1130
+ const release = p.retain; p.retain = undefined;
1131
+ disposeResources([...completed, { dispose: () => release?.() }]);
1054
1132
  // Every row in a REAL merge has its KV interleaved in batched buffers —
1055
1133
  // no longer prompt-cache put() candidates.
1056
1134
  for (const r of this.#running) r.merged = true;
@@ -1197,10 +1275,8 @@ export class MlxBatchExecutionGroup {
1197
1275
  if (vecOk) {
1198
1276
  const flat = ops.reshape(lg, [B, V]);
1199
1277
  lg.dispose();
1200
- const lp = toLogprobs(flat);
1201
- flat.dispose();
1202
- nextToks = ops.argmaxAxis(lp, -1); // [B]
1203
- lp.dispose();
1278
+ try { nextToks = independentGreedySampling.sample(flat); }
1279
+ finally { flat.dispose(); }
1204
1280
  for (const row of rows) if (row.sampled < row.req.maxTokens) row.sampled++;
1205
1281
  } else {
1206
1282
  const sampled: MlxArray[] = [];
@@ -1215,7 +1291,7 @@ export class MlxBatchExecutionGroup {
1215
1291
  const rl = lg.slice([b, 0, 0], [b + 1, 1, V]);
1216
1292
  const rl2 = ops.reshape(rl, [1, V]);
1217
1293
  rl.dispose();
1218
- sampled.push(row.req.sample(rl2, row.sampled));
1294
+ sampled.push(row.req.sample!(rl2, row.sampled));
1219
1295
  row.sampled++;
1220
1296
  rl2.dispose();
1221
1297
  }
@@ -1246,6 +1322,7 @@ export class MlxBatchExecutionGroup {
1246
1322
  // set from process start `prev` is always null, so the flush below IS the
1247
1323
  // whole phase 2. Same math either way (pipelining is scheduling).
1248
1324
  if (this.#noPipeline) {
1325
+ if (this.#running.some(row => row.req.continuation)) this.#captureContinuations();
1249
1326
  await this.#flushPipeline();
1250
1327
  return;
1251
1328
  }
@@ -1266,9 +1343,37 @@ export class MlxBatchExecutionGroup {
1266
1343
  await this.#emitRows(toks); // also filters #pendingToks on eviction
1267
1344
  if (this.#stepTrace) { STEP_T.emit += performance.now() - tEmit; STEP_T.n++; }
1268
1345
  }
1346
+ if (this.#running.some(row => row.req.continuation)) this.#captureContinuations();
1269
1347
  if (this.#stepTrace) STEP_T.lastEnd = performance.now();
1270
1348
  }
1271
1349
 
1350
+ readonly #checkpointAt = new WeakMap<Row, number>();
1351
+ /** Cache extraction belongs to this existing ordinary numerical driver;
1352
+ * the optional request port owns checkpoint eligibility and persistence. */
1353
+ #captureContinuations(): void {
1354
+ if (!this.#pendingToks || !this.#running.some(row => row.req.continuation)) return;
1355
+ let pending: number[] | undefined;
1356
+ const failed = new Set<number>();
1357
+ for (let index = 0; index < this.#running.length; index++) {
1358
+ const row = this.#running[index]!, policy = row.req.continuation;
1359
+ if (!policy || row.fedTainted || row.fed.length !== row.generated ||
1360
+ row.generated < (this.#checkpointAt.get(row) ?? policy.interval) ||
1361
+ this.#pendingReal?.[index] === false) continue;
1362
+ pending ??= this.#pendingToks.toIntTokens();
1363
+ const tokens = [...row.req.promptIds, ...row.fed];
1364
+ const caches = this.#running.length === 1 && !row.merged
1365
+ ? cloneSingleRowState(this.#inners! as Cache[], this.#stateCodecs)
1366
+ : this.#extractRowCaches(index, tokens.length);
1367
+ if (!caches) continue;
1368
+ try {
1369
+ policy.captureOwned({ caches, cacheTokens: tokens, generatedTokens: row.generated,
1370
+ pendingToken: pending[index]! });
1371
+ this.#checkpointAt.set(row, (Math.floor(row.generated / policy.interval) + 1) * policy.interval);
1372
+ } catch (error) { row.reject(error); failed.add(index); }
1373
+ }
1374
+ if (failed.size) this.#applyFilter(this.#running.flatMap((_, index) => failed.has(index) ? [] : [index]), true);
1375
+ }
1376
+
1272
1377
  /** Read out the pipeline register (if any): emit its tokens and evict
1273
1378
  * finished rows, leaving the pipeline cold. Called before a join merges. */
1274
1379
  async #flushPipeline(): Promise<void> {
@@ -1382,7 +1487,7 @@ export class MlxBatchExecutionGroup {
1382
1487
  const rl = lg.slice([b, 0, 0], [b + 1, 1, V]);
1383
1488
  const rl2 = ops.reshape(rl, [1, V]);
1384
1489
  rl.dispose();
1385
- sampled.push(row.req.sample(rl2, row.sampled));
1490
+ sampled.push(row.req.sample!(rl2, row.sampled));
1386
1491
  row.sampled++;
1387
1492
  rl2.dispose();
1388
1493
  }
@@ -1408,6 +1513,7 @@ export class MlxBatchExecutionGroup {
1408
1513
  // rows finish("stop") via #emit's isTerminated check; the filter evicts.
1409
1514
  // Cold start: prevVals empty (prefill emitted tok0) → nothing to emit.
1410
1515
  if (prevVals.length) await this.#emitRows(prevVals);
1516
+ if (this.#running.some(row => row.req.continuation)) this.#captureContinuations();
1411
1517
  }
1412
1518
 
1413
1519
  /** Emit one read-back token per running row; evict finished rows. A row's
@@ -1420,7 +1526,6 @@ export class MlxBatchExecutionGroup {
1420
1526
  for (let b = 0; b < B; b++) {
1421
1527
  const row = rows[b]!;
1422
1528
  row.generated++;
1423
- if (row.generated === 1) row.firstTokenAt = performance.now();
1424
1529
  let disp: "continue" | "stop" | "length";
1425
1530
  try {
1426
1531
  disp = await this.#emit(row, toks[b]!);
@@ -1452,10 +1557,12 @@ export class MlxBatchExecutionGroup {
1452
1557
  /** Account one sampled token for a row. Mirrors generate(): EOS terminates
1453
1558
  * WITHOUT an onToken call; otherwise onToken(token) runs and `false` halts;
1454
1559
  * reaching maxTokens ends with "length". Advances row.current on continue. */
1455
- async #emit(row: Row, token: number): Promise<"continue" | "stop" | "length"> {
1560
+ async #emit(row: Row, token: number, logprobs?: import("../../generate").TokenLogprobs): Promise<"continue" | "stop" | "length"> {
1456
1561
  row.req.signal?.throwIfAborted();
1562
+ this.#firstOutput(row);
1563
+ if (row.generated === 1) clearCache();
1457
1564
  if (row.req.eosTokenIds.includes(token)) return "stop";
1458
- const cont = await row.req.onToken(token);
1565
+ const cont = await row.req.onToken(token, logprobs);
1459
1566
  if (cont === false) return "stop";
1460
1567
  // Grammar termination: the matcher is satisfied (e.g. closing `}` accepted).
1461
1568
  // The final token has been delivered via onToken above; halt with "stop" so
@@ -1466,10 +1573,21 @@ export class MlxBatchExecutionGroup {
1466
1573
  return "continue";
1467
1574
  }
1468
1575
 
1576
+ #firstOutput(row: Row): void {
1577
+ if (row.generated === 1) {
1578
+ if (this.#prefill?.rows.includes(row)) this.#preparationPublishedOutput = true;
1579
+ row.firstTokenAt = performance.now();
1580
+ row.closeTokenZero?.();
1581
+ row.closeTokenZero = undefined;
1582
+ }
1583
+ }
1584
+
1469
1585
  #finish(row: Row, reason: "stop" | "length"): void {
1586
+ row.req.continuation?.complete();
1470
1587
  const now = performance.now();
1471
1588
  const first = row.firstTokenAt || now;
1472
1589
  row.resolve({
1590
+ ...(row.spec ? { spec: row.spec } : {}),
1473
1591
  promptTokens: row.promptTokens,
1474
1592
  generatedTokens: row.generated,
1475
1593
  cachedTokens: row.cachedTokens,
@@ -1484,13 +1602,28 @@ export class MlxBatchExecutionGroup {
1484
1602
  * offset lines up (defensive — a mismatch means the accounting is wrong
1485
1603
  * and the entry would corrupt future hits), else dispose. `retain` rides
1486
1604
  * along per the PromptCacheEntry contract (runs after dispose). */
1487
- #putOrDispose(caches: Cache[], tokens: number[], retain?: () => void): void {
1605
+ #putOrDispose(caches: Cache[], tokens: number[], retain?: () => void, namespace = ""): void {
1488
1606
  if (this.#promptCache) {
1607
+ // A never-merged row can still own a row layout. Persistence receives
1608
+ // its existing serial representation, just like merged-row retirement.
1609
+ const extracted: Cache[] = [], replaced: Cache[] = [];
1610
+ let persistent: Cache[];
1611
+ try {
1612
+ persistent = caches.map(cache => {
1613
+ if (!isBatchableCache(cache) || cache.batchSize !== 1) return cache;
1614
+ const row = cache.extractRow(0);
1615
+ extracted.push(row); replaced.push(cache);
1616
+ return row;
1617
+ });
1618
+ } catch (error) { disposeResources(extracted); throw error; }
1619
+ try { disposeResources(replaced); }
1620
+ catch (error) { disposeResources(extracted); throw error; }
1621
+ caches = persistent;
1489
1622
  const withOff = caches.find(
1490
1623
  (c) => typeof (c as { offset?: unknown }).offset === "number",
1491
1624
  ) as { offset: number } | undefined;
1492
1625
  if (withOff && withOff.offset === tokens.length) {
1493
- this.#promptCache.put(tokens, caches, "", retain);
1626
+ this.#promptCache.put(tokens, caches, namespace, retain);
1494
1627
  return;
1495
1628
  }
1496
1629
  }
@@ -1515,8 +1648,8 @@ export class MlxBatchExecutionGroup {
1515
1648
  // slices depend on this step's KV writes): async — the batched source
1516
1649
  // buffers free once the copies land, instead of being pinned by a lazy
1517
1650
  // graph inside an idle cache entry.
1518
- ops.asyncEvalAll(caches.flatMap((c) => c.state()));
1519
- this.#putOrDispose(caches, tokens);
1651
+ withResource(leaseCacheStates(caches), state => ops.asyncEvalAll([...state]));
1652
+ this.#putOrDispose(caches, tokens, undefined, row.cacheNamespace);
1520
1653
  }
1521
1654
 
1522
1655
  /** Row `b` of every layer as OWNED serial-class caches, or null when a
@@ -1562,6 +1695,11 @@ export class MlxBatchExecutionGroup {
1562
1695
  /** Evict rows not in `keep` (sorted ascending) from the batched KV and the
1563
1696
  * pipeline register. */
1564
1697
  #applyFilter(keep: number[], dropOnly = false): void {
1698
+ if (this.#method) {
1699
+ this.#method.filterRows(keep, dropOnly);
1700
+ this.#running = keep.map(row => this.#running[row]!);
1701
+ return;
1702
+ }
1565
1703
  const inners = this.#inners!;
1566
1704
  if (keep.length === 0) {
1567
1705
  // Prompt-cache put (Phase 3.2): a lone NEVER-MERGED row's inners are
@@ -1579,6 +1717,7 @@ export class MlxBatchExecutionGroup {
1579
1717
  inners as Cache[],
1580
1718
  [...solo.req.promptIds, ...solo.fed],
1581
1719
  this.#adoptedRetain ?? undefined,
1720
+ solo.cacheNamespace,
1582
1721
  );
1583
1722
  } else {
1584
1723
  for (const c of inners) c.dispose();
@@ -1593,6 +1732,11 @@ export class MlxBatchExecutionGroup {
1593
1732
  this.#pendingReal = null;
1594
1733
  return;
1595
1734
  }
1735
+ const keptFullPad = keep.map((i) => this.#fullLeftPad[i]!);
1736
+ // mlx-lm BatchKVCache.filter removes padding shared by all survivors.
1737
+ // Full-attention width and padding move together; each row's absolute
1738
+ // RoPE position stays unchanged. Rotating caches keep their own layout.
1739
+ const trimFull = this.#kinds.includes("full") ? Math.min(...keptFullPad) : 0;
1596
1740
  const out: LayerInner[] = [];
1597
1741
  for (const inner of inners) {
1598
1742
  if (isBatchableCache(inner)) {
@@ -1607,18 +1751,18 @@ export class MlxBatchExecutionGroup {
1607
1751
  throw new Error("applyFilter: adopted serial rotating cache cannot be row-filtered");
1608
1752
  } else if (isQuantizedKvCache(inner)) {
1609
1753
  const [k0, v0] = inner.temporalView();
1610
- const f = filterQuantRows(k0, v0, keep);
1754
+ const f = filterQuantRows(k0, v0, keep, trimFull);
1611
1755
  for (const t of [k0, v0]) { t.packed.dispose(); t.scales.dispose(); t.biases.dispose(); }
1612
1756
  const c = new QuantizedKVCache(inner.groupSize, inner.bits);
1613
- c.restoreState(f.keys, f.values, inner.offset);
1757
+ c.restoreState(f.keys, f.values, inner.offset - trimFull);
1614
1758
  out.push(c);
1615
1759
  inner.dispose();
1616
1760
  } else if (isPlainKvCache(inner)) {
1617
1761
  const [k0, v0] = inner.temporalView();
1618
- const f = filterKVRows(k0, v0, keep);
1762
+ const f = filterKVRows(k0, v0, keep, trimFull);
1619
1763
  k0.dispose(); v0.dispose();
1620
1764
  const c = new KVCache();
1621
- c.restoreState(f.keys, f.values, inner.offset);
1765
+ c.restoreState(f.keys, f.values, inner.offset - trimFull);
1622
1766
  out.push(c);
1623
1767
  inner.dispose();
1624
1768
  } else {
@@ -1626,7 +1770,7 @@ export class MlxBatchExecutionGroup {
1626
1770
  }
1627
1771
  }
1628
1772
  this.#inners = out;
1629
- this.#fullLeftPad = keep.map((i) => this.#fullLeftPad[i]!);
1773
+ this.#fullLeftPad = keptFullPad.map((padding) => padding - trimFull);
1630
1774
  this.#running = keep.map((i) => this.#running[i]!);
1631
1775
  if (this.#pendingToks) {
1632
1776
  const idx = ops.fromInt32(keep, [keep.length]);