mlx-bun 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +57 -32
  2. package/docs/README.md +55 -42
  3. package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
  4. package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
  5. package/docs/design/batching.md +220 -7
  6. package/docs/design/decode-speed-program.md +121 -66
  7. package/docs/design/generic-model-support.md +116 -0
  8. package/docs/design/kv-cache.md +380 -211
  9. package/docs/design/speculative-decoding.md +165 -26
  10. package/docs/design/turboquant.md +20 -7
  11. package/docs/design/unified-engine-frontier-plan.md +120 -13
  12. package/docs/planning/PRODUCT_ROADMAP.md +5 -6
  13. package/docs/planning/public-readiness-handoff.md +244 -0
  14. package/docs/planning/public-readiness.md +149 -0
  15. package/docs/reference/benchmarks.md +3463 -3489
  16. package/docs/reference/cli.md +62 -1
  17. package/docs/reference/distribution.md +29 -4
  18. package/docs/reference/environment.md +104 -8
  19. package/docs/reference/library-api.md +18 -4
  20. package/docs/reference/memory.md +1 -1
  21. package/docs/reference/models.md +17 -5
  22. package/docs/reference/server-api.md +181 -11
  23. package/docs/reference/server-config.md +232 -105
  24. package/package.json +2 -2
  25. package/src/audio/audiotoolbox.ts +112 -0
  26. package/src/audio/conformer.ts +8 -0
  27. package/src/audio/mic-capture.ts +98 -0
  28. package/src/audio/silero-vad.ts +379 -0
  29. package/src/audio/whisper-decode.ts +774 -0
  30. package/src/audio/whisper-fast.ts +732 -0
  31. package/src/audio/whisper-format.ts +73 -0
  32. package/src/audio/whisper-mel.ts +185 -0
  33. package/src/audio/whisper-timing.ts +274 -0
  34. package/src/audio/whisper-tokenizer.ts +239 -0
  35. package/src/audio/whisper-transcribe.ts +410 -0
  36. package/src/backends/mlx/autoregressive.ts +22 -1
  37. package/src/backends/mlx/batch-group.ts +111 -27
  38. package/src/backends/mlx/cache-layout.ts +3 -3
  39. package/src/backends/mlx/continuation-persistence.ts +5 -4
  40. package/src/backends/mlx/encoder-cache.ts +25 -0
  41. package/src/backends/mlx/fill-append.ts +21 -8
  42. package/src/backends/mlx/fill-group.ts +497 -0
  43. package/src/backends/mlx/gateway-binding.ts +56 -12
  44. package/src/backends/mlx/kv-maintenance.ts +30 -2
  45. package/src/backends/mlx/mixed-iteration.ts +62 -0
  46. package/src/backends/mlx/model-prompt.ts +46 -32
  47. package/src/backends/mlx/model-serving.ts +4 -3
  48. package/src/backends/mlx/paged-cache-codec.ts +27 -0
  49. package/src/backends/mlx/prefill-cohort.ts +15 -4
  50. package/src/backends/mlx/prefill-policy.ts +26 -0
  51. package/src/backends/mlx/prefill-rows.ts +57 -13
  52. package/src/backends/mlx/prompt-input.ts +29 -0
  53. package/src/backends/mlx/qwen-prompt-input.ts +40 -0
  54. package/src/backends/mlx/request-state-policy.ts +18 -5
  55. package/src/backends/mlx/rollback.ts +6 -3
  56. package/src/backends/mlx/serial-executor.ts +16 -7
  57. package/src/backends/mlx/speculative-group.ts +105 -26
  58. package/src/backends/mlx/speculative.ts +24 -7
  59. package/src/backends/mlx/target-layout-capability.ts +1 -1
  60. package/src/backends/mlx/transitioning-kv-rows.ts +8 -0
  61. package/src/cli.ts +322 -0
  62. package/src/contracts/execution.ts +10 -0
  63. package/src/contracts/object-cache.ts +8 -0
  64. package/src/contracts/pi-provider.ts +1 -1
  65. package/src/contracts/pi.ts +3 -1
  66. package/src/contracts/prefix-cache.ts +13 -3
  67. package/src/contracts/scheduling.ts +9 -0
  68. package/src/engine/execution-plan.ts +23 -12
  69. package/src/engine/execution-tasks.ts +44 -0
  70. package/src/engine/namespaced-cache.ts +13 -0
  71. package/src/engine/scheduler.ts +22 -2
  72. package/src/fill/fill-session.ts +14 -3
  73. package/src/generate.ts +28 -22
  74. package/src/grammar.ts +13 -0
  75. package/src/inference/prefill.ts +6 -0
  76. package/src/kv-scheme.ts +22 -0
  77. package/src/kv-store.ts +115 -79
  78. package/src/lab/README.md +5 -1
  79. package/src/lab/paged-kv/paged-attention.ts +117 -0
  80. package/src/lab/paged-kv/paged-kv-rows.ts +33 -5
  81. package/src/lab/paged-kv/paged-kv.ts +108 -42
  82. package/src/md-modules.d.ts +5 -0
  83. package/src/mlx/array.ts +37 -0
  84. package/src/mlx/ffi.ts +19 -0
  85. package/src/mlx/normalized-argmax.ts +116 -0
  86. package/src/mlx/ops.ts +97 -10
  87. package/src/model/batched-turboquant-kv.ts +6 -5
  88. package/src/model/delayed-quantized-kv.ts +3 -3
  89. package/src/model/delayed-rotating-quantized-kv.ts +1 -1
  90. package/src/model/delayed-turboquant-kv.ts +6 -4
  91. package/src/model/factory.ts +26 -0
  92. package/src/model/fingerprint.ts +12 -0
  93. package/src/model/full-prefill-row.ts +1 -1
  94. package/src/model/gemma4-base.ts +25 -6
  95. package/src/model/gemma4.ts +82 -2
  96. package/src/model/kv-attention-view.ts +4 -1
  97. package/src/model/profile.ts +29 -4
  98. package/src/model/quantized-append-attention.ts +31 -0
  99. package/src/model/qwen3-mrope.ts +7 -9
  100. package/src/model/qwen3_5.ts +99 -37
  101. package/src/model/support.ts +8 -0
  102. package/src/model/token-groups.ts +66 -0
  103. package/src/model/trellis-linear.ts +2 -2
  104. package/src/model/turboquant-codec.ts +6 -0
  105. package/src/model/whisper.ts +435 -0
  106. package/src/native/mic_capture.swift +92 -0
  107. package/src/native-pack.ts +6 -5
  108. package/src/pi-web.ts +9 -1
  109. package/src/prompt-cache.ts +265 -43
  110. package/src/runtime-config.ts +1 -5
  111. package/src/safetensors.ts +9 -2
  112. package/src/sampler.ts +5 -4
  113. package/src/serve/audio-routes.ts +323 -0
  114. package/src/serve/chat-request.ts +2 -0
  115. package/src/serve/chat-stage.ts +17 -12
  116. package/src/serve/completion-executor.ts +9 -1
  117. package/src/serve/discovery-routes.ts +17 -1
  118. package/src/serve/generated-token-history.ts +57 -0
  119. package/src/serve/generation-gateway.ts +52 -11
  120. package/src/serve/model-binding.ts +7 -1
  121. package/src/serve/preparation.ts +3 -3
  122. package/src/serve/prompt-response-trace.ts +14 -0
  123. package/src/serve/request-plan.ts +2 -0
  124. package/src/serve/request-prep.ts +38 -21
  125. package/src/serve/transcription-server.ts +64 -0
  126. package/src/serve/transcription-service.ts +502 -0
  127. package/src/server.ts +132 -164
  128. package/src/spec/assistant-rows.ts +1 -0
  129. package/src/spec/deepspec-rows.ts +1 -1
  130. package/src/spec/dflash-source.ts +1 -1
  131. package/src/spec/glm52-mtp-source.ts +1 -0
  132. package/src/spec/ngram-source.ts +27 -5
  133. package/src/spec/projected-draft-rows.ts +2 -2
  134. package/src/spec/qwen-mtp-rows.ts +11 -0
  135. package/src/spec/qwen-mtp-source.ts +3 -0
  136. package/src/spec/serve-loop.ts +5 -3
  137. package/src/spec/source.ts +14 -1
  138. package/src/spec/two-model-rows.ts +1 -0
  139. package/src/spec/two-model.ts +2 -2
  140. package/src/ssd-cache.ts +76 -17
  141. package/src/ssd-durability.ts +20 -22
  142. package/src/storage/host-buffer.ts +24 -0
  143. package/src/storage/kv-writer.ts +83 -0
  144. package/src/storage/kv-writer.worker.js +264 -0
  145. package/src/storage/retention-policy.ts +37 -0
  146. package/src/tiered-prompt-cache.ts +45 -0
  147. package/src/vision/embedder.ts +8 -0
  148. package/src/vision/prompt.ts +101 -65
  149. package/src/vision/qwen3vl-prompt.ts +101 -64
  150. package/src/vision/qwen3vl-tower.ts +11 -0
  151. package/src/vision/siglip.ts +8 -0
  152. package/src/web/app.html +12 -0
  153. package/src/web/app.js +160 -0
  154. package/src/web/src/chat.ts +4 -1
  155. package/src/web/src/composer.ts +3 -0
  156. package/src/web/src/voice.ts +141 -0
  157. package/src/web/sw.js +1 -1
  158. /package/docs/{planning → archive/planning}/release-notes-v0.4.0.md +0 -0
@@ -5,6 +5,9 @@ import type { RuntimeModel } from "../../model/factory";
5
5
  import { CompiledDecode } from "../../model/compiled-decode";
6
6
  import { runtimeConfig, type RuntimeConfig } from "../../runtime-config";
7
7
  import { bindMlxGraph } from "./graph";
8
+ import type { PrefillPolicy } from "../../inference/prefill";
9
+ import { resolveMlxPrefillPolicy } from "./prefill-policy";
10
+ import type { KvSchemeOptions } from "../../kv-scheme";
8
11
 
9
12
  /** One declaration shared by planning and both native execution lanes. */
10
13
  export function legacyCompiledDecodeAvailable(model: RuntimeModel): boolean {
@@ -33,16 +36,33 @@ export interface MlxDecodeStep {
33
36
  /** Model-owned execution for committed tokens. Recheck the chunk limit after
34
37
  * each forward: native arithmetic can change at a cache-length boundary. */
35
38
  export interface MlxTokenAppend {
36
- maxChunkSize(state: readonly Cache[]): number;
39
+ /** Affine formats whose committed append retains one-token arithmetic. */
40
+ readonly affineKvBits?: readonly number[];
41
+ readonly turboQuantFormats?: readonly { readonly kBits: number; readonly vBits: number }[];
42
+ /** Maximum positions per row at this cohort size; omitted rows means B=1. */
43
+ maxChunkSize(state: readonly Cache[], rows?: number): number;
37
44
  forwardHidden(ids: MlxArray, state: Cache[]): MlxArray | Promise<MlxArray>;
38
45
  }
39
46
 
47
+ /** The model declares which codecs retain its committed-span arithmetic. */
48
+ export function supportsCommittedAppendCache(
49
+ append: Pick<MlxTokenAppend, "affineKvBits" | "turboQuantFormats"> | null | undefined,
50
+ options: Pick<KvSchemeOptions, "kvBits" | "kvConfig" | "turboQuant">,
51
+ ): boolean {
52
+ if (options.turboQuant) return !!append?.turboQuantFormats?.some(format =>
53
+ format.kBits === options.turboQuant!.kBits && format.vBits === options.turboQuant!.vBits);
54
+ return options.kvConfig?.length
55
+ ? options.kvConfig.every(layer => append?.affineKvBits?.includes(layer.bits))
56
+ : !options.kvBits || !!append?.affineKvBits?.includes(options.kvBits);
57
+ }
58
+
40
59
  /** One replaceable binding owns all graph-specific operations used by the AR
41
60
  * loop. The current method requires MLX tensors and the legacy Cache[] ABI.
42
61
  * Weights are borrowed for the binding's lifetime; generated caches are owned
43
62
  * by the run. Caller-provided caches and media remain borrowed. */
44
63
  export interface MlxAutoregressiveBinding {
45
64
  readonly runtime?: RuntimeConfig;
65
+ readonly prefillPolicy?: PrefillPolicy;
46
66
  readonly graph: AutoregressiveGraph<MlxArray, Cache[], MlxArray>;
47
67
  readonly eosTokenIds: readonly number[];
48
68
  readonly memory: MlxModelMemory;
@@ -63,6 +83,7 @@ export function bindLegacyAutoregressiveModel(model: RuntimeModel): MlxAutoregre
63
83
  const runtime = runtimeConfig();
64
84
  return {
65
85
  runtime,
86
+ prefillPolicy: resolveMlxPrefillPolicy(model.config, runtime),
66
87
  graph: bindMlxGraph<Cache[]>(model, {
67
88
  id: `legacy:${model.config.modelType}`, artifact: "legacy-resident-model",
68
89
  stateAbi: "legacy-cache-array-v1", // not a persistence identity
@@ -1,3 +1,5 @@
1
+ import { runMixedTokenIteration, type MlxForwardWork, type MlxPreparationWork } from "./mixed-iteration";
2
+ import type { MixedTokenModel } from "../../model/token-groups";
1
3
  import type { OrdinaryContinuation } from "./continuation";
2
4
  import type { MlxRequestStatePolicy } from "./request-state-policy";
3
5
  import { AdmissionRejected } from "../../engine/admission";
@@ -63,6 +65,8 @@ import { MlxPrefillCohort, type PrefillState } from "./prefill-cohort";
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";
@@ -96,6 +100,8 @@ import { SSMCache } from "../../model/qwen3-delta";
96
100
  import type { RuntimeModel } from "../../model/factory";
97
101
  import type { GrammarController } from "../../grammar";
98
102
  import { independentGreedySampling } from "../../sampler";
103
+ import { ExecutionTasks } from "../../engine/execution-tasks";
104
+ import { CancellationSource, GenerationCancelled } from "../../engine/cancellation";
99
105
  import { acquireModelWiredLimit } from "../../generate";
100
106
  import { batchRowKvBytes } from "../../serve/kv-budget";
101
107
  import type { PromptResponseTrace } from "../../serve/prompt-response-trace";
@@ -138,8 +144,9 @@ export interface MlxGroupPreparation {
138
144
  readonly rows: readonly Row[];
139
145
  readonly tokenWeight?: number;
140
146
  readonly canAdmit?: boolean;
147
+ readonly supportsMixedWork?: boolean;
141
148
  admit?(row: Row): void;
142
- advance(): Promise<boolean>;
149
+ advance(work?: MlxPreparationWork): Promise<boolean>;
143
150
  dispose(): void;
144
151
  }
145
152
  export interface MlxGroupMethodHost {
@@ -153,8 +160,10 @@ export interface MlxGroupMethodHost {
153
160
  finish(row: Row, reason: "stop" | "length"): void;
154
161
  }
155
162
  export interface MlxGroupedMethod {
163
+ /** Maximum target tokens in the next iteration, including candidates. */
164
+ readonly runningTokens?: number;
156
165
  prepare(row: Row): MlxGroupPreparation;
157
- advance(): Promise<void>;
166
+ advance(work?: MlxForwardWork): Promise<void>;
158
167
  filterRows(keep: readonly number[], discard: boolean): void;
159
168
  dispose(): void;
160
169
  }
@@ -171,6 +180,9 @@ export type BatchRequest = BatchRequestFields & (
171
180
  );
172
181
 
173
182
  interface BatchRequestFields {
183
+ /** Borrowed prepared input; its owner retains it through preparation. */
184
+ promptInput?: import("./prompt-input").MlxPromptInput;
185
+ cacheSessionId?: string;
174
186
  /** Optional ordinary continuation policy; owns persistence and sampler recovery. */
175
187
  continuation?: OrdinaryContinuation;
176
188
  statePolicy?: MlxRequestStatePolicy;
@@ -225,6 +237,7 @@ interface BatchRequestFields {
225
237
 
226
238
  export interface BatchStats {
227
239
  spec?: import("../../generate").GenerateStats["spec"];
240
+ fill?: import("../../generate").GenerateStats["fill"];
228
241
  promptTokens: number;
229
242
  generatedTokens: number;
230
243
  /** Prompt tokens served from the prompt cache (Phase 3.2): a joiner's solo
@@ -239,6 +252,8 @@ export interface BatchStats {
239
252
  decodeMs: number;
240
253
  }
241
254
 
255
+ let nextMixedWorkId = 0;
256
+
242
257
  /** The slice of PromptCache the scheduler drives (structural — the server's
243
258
  * PromptCache satisfies it). take() on admission (any joiner: a restored
244
259
  * prefix + suffix prefill is byte-safe whether the row later merges or
@@ -251,6 +266,7 @@ export type RowPromptCache = import("./checkpoint-state").MlxPrefixCache;
251
266
 
252
267
  export interface Row {
253
268
  spec?: import("../../generate").GenerateStats["spec"];
269
+ fill?: import("../../generate").GenerateStats["fill"];
254
270
  req: BatchRequest;
255
271
  resolve: (s: BatchStats) => void;
256
272
  reject: (e: unknown) => void;
@@ -342,6 +358,7 @@ type LayerInner =
342
358
  type Row1 = { keys: MlxArray; values: MlxArray };
343
359
 
344
360
  export class MlxBatchExecutionGroup {
361
+ readonly #tasks = new ExecutionTasks();
345
362
  readonly #runtime: RuntimeConfig;
346
363
  readonly #noPipeline: boolean;
347
364
  readonly #stepTrace: boolean;
@@ -363,7 +380,9 @@ export class MlxBatchExecutionGroup {
363
380
  #pendingReal: boolean[] | null = null;
364
381
  #method: MlxGroupedMethod | undefined;
365
382
  #methodKey: string | undefined;
383
+ #decodeStateKey: string | undefined;
366
384
  #steps = 0; // decode-step counter (clearCache cadence)
385
+ #cacheMaintenanceSteps = 0;
367
386
  #looping = false;
368
387
  #closed = false;
369
388
  #driver: Promise<void> | null = null;
@@ -372,6 +391,7 @@ export class MlxBatchExecutionGroup {
372
391
  readonly #lock: ExclusiveLock | undefined;
373
392
  readonly #admissionHeld: (() => boolean) | undefined;
374
393
  readonly #prefillChunkSize: number;
394
+ readonly #prefillPolicy: PrefillPolicy;
375
395
  readonly #prefillBatchTokenLimit: number;
376
396
  readonly #prefillTailSplit: boolean;
377
397
  readonly #kvBudgetBytes: number | undefined;
@@ -409,8 +429,8 @@ export class MlxBatchExecutionGroup {
409
429
  this.#maxBatch = Math.max(1, Math.floor(opts.maxBatch));
410
430
  this.#lock = opts.lock;
411
431
  this.#admissionHeld = opts.admissionHeld;
412
- this.#prefillChunkSize = Math.max(1, Math.floor(opts.prefillChunkSize ??
413
- this.#runtime.number("MLX_BUN_RD_PREFILL_CHUNK", 2048)));
432
+ this.#prefillPolicy = resolveMlxPrefillPolicy(model.config, this.#runtime, opts.prefillChunkSize);
433
+ this.#prefillChunkSize = this.#prefillPolicy.chunkSize(0);
414
434
  this.#prefillBatchTokenLimit = opts.prefillBatchTokenLimit ?? 2048;
415
435
  this.#prefillTailSplit = this.#runtime.flag("MLX_BUN_PREFILL_TAIL_SPLIT", true);
416
436
  this.#kvBudgetBytes = opts.kvBudgetBytes;
@@ -535,6 +555,7 @@ export class MlxBatchExecutionGroup {
535
555
  if (this.#closed) return Promise.reject(new Error("scheduler closed"));
536
556
  if (req.signal?.aborted) return Promise.reject(req.signal.reason);
537
557
  if (this.#pending.length >= this.#maxQueued) return Promise.reject(new AdmissionRejected());
558
+ req = { ...req, prefillChunkSize: req.prefillChunkSize ?? this.#prefillPolicy.chunkSize(req.promptIds.length) };
538
559
  return new Promise<BatchStats>((resolve, reject) => {
539
560
  let abortListener: (() => void) | null = null;
540
561
  const cleanup = () => {
@@ -562,6 +583,21 @@ export class MlxBatchExecutionGroup {
562
583
  this.#ensureLoop();
563
584
  }
564
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
+
565
601
  #ensureLoop(): void {
566
602
  if (this.#wake) { this.#wake(); return; }
567
603
  if (this.#looping || this.#closed) return;
@@ -580,6 +616,8 @@ export class MlxBatchExecutionGroup {
580
616
  async #drive(): Promise<void> {
581
617
  const scheduler = this;
582
618
  const group: ExecutionGroup = {
619
+ get pendingTasks() { return scheduler.#tasks.pending; },
620
+ advanceTask: () => this.#tasks.advance(),
583
621
  get active() { return scheduler.#running.length; },
584
622
  get queued() { return scheduler.#pending.length; },
585
623
  get preparing() { return scheduler.#prefill !== null; },
@@ -606,8 +644,46 @@ export class MlxBatchExecutionGroup {
606
644
  canBurst: () => this.#contextCompatible(this.#pending[0]!) &&
607
645
  (this.#kvBudgetBytes === undefined ||
608
646
  this.projectedKvBytes + this.#rowKvBytes(this.#pending[0]!) <= this.#kvBudgetBytes),
609
- advancePreparation: () => this.#advancePreparation(),
610
- advance: () => this.#method ? this.#method.advance() : this.#step(),
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
+ },
611
687
  failActive: (error) => {
612
688
  for (const row of this.#running) row.reject(error);
613
689
  this.#applyFilter([], true); // failed state never enters the prefix store
@@ -629,12 +705,12 @@ export class MlxBatchExecutionGroup {
629
705
  await driveExecutionGroup(group, {
630
706
  now: () => performance.now(),
631
707
  yield: () => new Promise<void>((resolve) => setImmediate(resolve)),
632
- }, this.#runtime.value("MLX_BUN_EARLY_FIRST_TOKEN") === "1");
708
+ }, this.#runtime.flag("MLX_BUN_EARLY_FIRST_TOKEN", true));
633
709
  } finally { this.#looping = false; }
634
710
  }
635
711
 
636
712
  #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);
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);
638
714
  }
639
715
 
640
716
  #leaveContext(): void {
@@ -649,6 +725,7 @@ export class MlxBatchExecutionGroup {
649
725
  const row = this.#pending.shift()!;
650
726
  try {
651
727
  this.#statePolicy = row.req.statePolicy;
728
+ this.#decodeStateKey = row.req.promptInput?.decodeState?.key;
652
729
  row.cacheNamespace = typeof row.req.cacheNamespace === "function"
653
730
  ? row.req.cacheNamespace() : row.req.cacheNamespace;
654
731
  const key = row.req.context?.key ?? "";
@@ -705,11 +782,11 @@ export class MlxBatchExecutionGroup {
705
782
  for (const row of rows) row.reject(error);
706
783
  }
707
784
 
708
- async #advancePreparation(): Promise<void> {
785
+ async #advancePreparation(work?: MlxPreparationWork): Promise<void> {
709
786
  const p = this.#prefill!, rows = p.rows;
710
787
  this.#preparationPublishedOutput = false;
711
788
  try {
712
- if (await p.advance()) this.#prefill = null;
789
+ if (await p.advance(work)) this.#prefill = null;
713
790
  } catch (error) {
714
791
  this.#prefill = null;
715
792
  let failure = error;
@@ -725,6 +802,7 @@ export class MlxBatchExecutionGroup {
725
802
  }
726
803
 
727
804
  #failAll(error: unknown): void {
805
+ this.#tasks.rejectAll(error);
728
806
  const p = this.#prefill;
729
807
  const rows = new Set([...this.#running, ...this.#pending, ...(p?.rows ?? [])]);
730
808
  const retain = this.#adoptedRetain;
@@ -809,7 +887,7 @@ export class MlxBatchExecutionGroup {
809
887
  row.cachedTokens = Math.min(saved.cacheTokens.length, row.promptTokens);
810
888
  const stop = await this.#emit(row, saved.pendingToken);
811
889
  if (stop !== "continue") {
812
- this.#putOrDispose(p.solo, saved.cacheTokens, p.retain, row.cacheNamespace);
890
+ this.#putOrDispose(p.solo, saved.cacheTokens, p.retain, row.cacheNamespace, row.req.cacheSessionId);
813
891
  p.solo = [];
814
892
  this.#finish(row, stop);
815
893
  return;
@@ -831,7 +909,7 @@ export class MlxBatchExecutionGroup {
831
909
  // later pipeline step. Grammar and explicit eager-output modes retain
832
910
  // their read-before-advance order.
833
911
  if (!this.#inners && !p.row.req.grammar && !this.#noPipeline &&
834
- this.#runtime.value("MLX_BUN_EARLY_FIRST_TOKEN") !== "1" && !forceAttribution) {
912
+ !this.#runtime.flag("MLX_BUN_EARLY_FIRST_TOKEN", true) && !forceAttribution) {
835
913
  let owned: MlxArray | null = sampled;
836
914
  try {
837
915
  ops.asyncEvalAll([sampled]);
@@ -861,7 +939,7 @@ export class MlxBatchExecutionGroup {
861
939
  const stop = await this.#emit(p.row, tok);
862
940
  // Token 0 was sampled but never fed — the caches cover exactly the
863
941
  // prompt, a clean prompt-only entry (put-or-dispose).
864
- this.#putOrDispose(p.solo, p.row.req.promptIds, p.retain, p.row.cacheNamespace);
942
+ this.#putOrDispose(p.solo, p.row.req.promptIds, p.retain, p.row.cacheNamespace, p.row.req.cacheSessionId);
865
943
  this.#finish(p.row, stop === "continue" ? "stop" : stop);
866
944
  return;
867
945
  }
@@ -869,7 +947,7 @@ export class MlxBatchExecutionGroup {
869
947
 
870
948
  const stop = await this.#emit(p.row, tok);
871
949
  if (stop !== "continue") {
872
- this.#putOrDispose(p.solo, p.row.req.promptIds, p.retain, p.row.cacheNamespace);
950
+ this.#putOrDispose(p.solo, p.row.req.promptIds, p.retain, p.row.cacheNamespace, p.row.req.cacheSessionId);
873
951
  this.#finish(p.row, stop);
874
952
  return;
875
953
  }
@@ -1147,7 +1225,7 @@ export class MlxBatchExecutionGroup {
1147
1225
  * Rows that finish get one extra harmless KV write from the already-built
1148
1226
  * step; filter drops the row (mlx-lm behaves identically). Length-finished
1149
1227
  * rows are known in advance and are NOT sampled (placeholder slot). */
1150
- async #step(): Promise<void> {
1228
+ async #step(forward?: MlxForwardWork): Promise<void> {
1151
1229
  if (this.#stepTrace) {
1152
1230
  const now = performance.now();
1153
1231
  if (STEP_T.lastEnd) STEP_T.gap += now - STEP_T.lastEnd;
@@ -1156,6 +1234,11 @@ export class MlxBatchExecutionGroup {
1156
1234
  const rows = this.#running;
1157
1235
  const B = rows.length;
1158
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
+ }
1159
1242
 
1160
1243
  // A row is live if it still needs tokens sampled; a row whose pending
1161
1244
  // unread token is its last (sampled == maxTokens) only awaits emission.
@@ -1175,7 +1258,7 @@ export class MlxBatchExecutionGroup {
1175
1258
  const hasLiveGrammar = anyLive && rows.some(
1176
1259
  (r) => r.req.grammar && !r.req.grammar.isTerminated,
1177
1260
  );
1178
- if (hasLiveGrammar) return this.#stepGrammar();
1261
+ if (hasLiveGrammar) return this.#stepGrammar(forward);
1179
1262
 
1180
1263
  let nextToks: MlxArray | null = null;
1181
1264
  let nextReal: boolean[] | null = null;
@@ -1209,7 +1292,7 @@ export class MlxBatchExecutionGroup {
1209
1292
  let lg: MlxArray | null = null;
1210
1293
  let evalWith: MlxArray[] = [];
1211
1294
  if (
1212
- this.#compiled && B === 1 && unpadded && this.#running[0]!.req.compiledDecode !== false &&
1295
+ !forward && this.#compiled && B === 1 && unpadded && this.#running[0]!.req.compiledDecode !== false &&
1213
1296
  (!this.#pendingToks || this.#pendingToks.dtype === Dtype.uint32) &&
1214
1297
  // A filtered-to-one BATCHED rot-quant cache subclasses the serial
1215
1298
  // class (so supports() passes) but carries batched ring state —
@@ -1251,7 +1334,7 @@ export class MlxBatchExecutionGroup {
1251
1334
  const ids = this.#pendingToks
1252
1335
  ? ops.reshape(this.#pendingToks, [B, 1]) // feed the unread tokens
1253
1336
  : ops.fromInt32(rows.map((r) => r.current), [B, 1]); // pipeline cold
1254
- const h = await this.#forwardHidden(ids, fwd);
1337
+ const h = await (forward ? forward(ids, fwd) : this.#forwardHidden(ids, fwd));
1255
1338
  ids.dispose();
1256
1339
  lg = this.model.logitsFromHidden(h); // [B,1,V]
1257
1340
  h.dispose();
@@ -1407,7 +1490,7 @@ export class MlxBatchExecutionGroup {
1407
1490
  * finish("stop") in #emitRows. On a cold start (first step after prefill)
1408
1491
  * there is no pending array to read; the prefill already accepted token 0
1409
1492
  * and fired the fill, so we just await ready() + sample. */
1410
- async #stepGrammar(): Promise<void> {
1493
+ async #stepGrammar(forward?: MlxForwardWork): Promise<void> {
1411
1494
  const rows = this.#running;
1412
1495
  const B = rows.length;
1413
1496
  const inners = this.#inners!;
@@ -1454,7 +1537,7 @@ export class MlxBatchExecutionGroup {
1454
1537
  const ids = prev
1455
1538
  ? ops.reshape(prev, [B, 1]) // feed the unread tokens (device array)
1456
1539
  : ops.fromInt32(rows.map((r) => r.current), [B, 1]); // pipeline cold
1457
- const h = await this.#forwardHidden(ids, fwd);
1540
+ const h = await (forward ? forward(ids, fwd) : this.#forwardHidden(ids, fwd));
1458
1541
  ids.dispose();
1459
1542
  const lg = this.model.logitsFromHidden(h); // [B,1,V]
1460
1543
  h.dispose();
@@ -1588,6 +1671,7 @@ export class MlxBatchExecutionGroup {
1588
1671
  const first = row.firstTokenAt || now;
1589
1672
  row.resolve({
1590
1673
  ...(row.spec ? { spec: row.spec } : {}),
1674
+ ...(row.fill ? { fill: row.fill } : {}),
1591
1675
  promptTokens: row.promptTokens,
1592
1676
  generatedTokens: row.generated,
1593
1677
  cachedTokens: row.cachedTokens,
@@ -1602,7 +1686,7 @@ export class MlxBatchExecutionGroup {
1602
1686
  * offset lines up (defensive — a mismatch means the accounting is wrong
1603
1687
  * and the entry would corrupt future hits), else dispose. `retain` rides
1604
1688
  * along per the PromptCacheEntry contract (runs after dispose). */
1605
- #putOrDispose(caches: Cache[], tokens: number[], retain?: () => void, namespace = ""): void {
1689
+ #putOrDispose(caches: Cache[], tokens: number[], retain?: () => void, namespace = "", sessionId?: string): void {
1606
1690
  if (this.#promptCache) {
1607
1691
  // A never-merged row can still own a row layout. Persistence receives
1608
1692
  // its existing serial representation, just like merged-row retirement.
@@ -1623,7 +1707,7 @@ export class MlxBatchExecutionGroup {
1623
1707
  (c) => typeof (c as { offset?: unknown }).offset === "number",
1624
1708
  ) as { offset: number } | undefined;
1625
1709
  if (withOff && withOff.offset === tokens.length) {
1626
- this.#promptCache.put(tokens, caches, namespace, retain);
1710
+ this.#promptCache.put(tokens, caches, namespace, retain, undefined, sessionId);
1627
1711
  return;
1628
1712
  }
1629
1713
  }
@@ -1634,13 +1718,13 @@ export class MlxBatchExecutionGroup {
1634
1718
  /** Extract a finishing MERGED row's KV into fresh serial caches and put()
1635
1719
  * them keyed by [promptIds + fed] — the batch-lane mirror of mlx-lm
1636
1720
  * server.py:872 (extract_cache → prompt_cache.insert_cache). Gates:
1637
- * promptTokens >= 256 (the boundary-snapshot substantiality gate,
1638
- * 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
1639
1723
  * (#extractRowCaches null) just disposes-by-omission — the row's KV dies
1640
1724
  * with the filter, exactly the pre-extraction behavior. */
1641
1725
  #extractAndPut(b: number, row: Row): void {
1642
1726
  if (!this.#promptCache || !row.merged || row.fedTainted) return;
1643
- if (row.promptTokens < 256) return;
1727
+ if (row.promptTokens < 256 && !row.req.cacheSessionId) return;
1644
1728
  const tokens = [...row.req.promptIds, ...row.fed];
1645
1729
  const caches = this.#extractRowCaches(b, tokens.length);
1646
1730
  if (!caches) return;
@@ -1649,7 +1733,7 @@ export class MlxBatchExecutionGroup {
1649
1733
  // buffers free once the copies land, instead of being pinned by a lazy
1650
1734
  // graph inside an idle cache entry.
1651
1735
  withResource(leaseCacheStates(caches), state => ops.asyncEvalAll([...state]));
1652
- this.#putOrDispose(caches, tokens, undefined, row.cacheNamespace);
1736
+ this.#putOrDispose(caches, tokens, undefined, row.cacheNamespace, row.req.cacheSessionId);
1653
1737
  }
1654
1738
 
1655
1739
  /** Row `b` of every layer as OWNED serial-class caches, or null when a
@@ -1717,7 +1801,7 @@ export class MlxBatchExecutionGroup {
1717
1801
  inners as Cache[],
1718
1802
  [...solo.req.promptIds, ...solo.fed],
1719
1803
  this.#adoptedRetain ?? undefined,
1720
- solo.cacheNamespace,
1804
+ solo.cacheNamespace, solo.req.cacheSessionId,
1721
1805
  );
1722
1806
  } else {
1723
1807
  for (const c of inners) c.dispose();
@@ -8,15 +8,15 @@ import { PagedKvRows } from "../../lab/paged-kv/paged-kv-rows";
8
8
 
9
9
  /** Bind encoded state to its row layout. Execution owners only move rows. */
10
10
  export function ownedCacheLayout(cache: Cache): BatchableCache | undefined {
11
- if (cache instanceof PagedKVCache) return new PagedKvRows(cache.capacityTokens, cache.blockSize);
11
+ if (cache instanceof PagedKVCache) return new PagedKvRows(cache.capacityTokens, cache.blockSize, cache.direct, cache.quantization);
12
12
  if (isBatchableCache(cache)) return cache.makeEmptyBatch();
13
- if (cache instanceof TurboQuantKVCache) return new BatchedTurboQuantKVCache(cache.kBits, cache.vBits);
13
+ if (cache instanceof TurboQuantKVCache) return new BatchedTurboQuantKVCache(cache.kBits, cache.vBits, cache.fusedDecode);
14
14
  return undefined;
15
15
  }
16
16
 
17
17
  /** Prefill reuses decode's layouts; shape selection belongs to storage. */
18
18
  export function prefillCacheLayout(cache: Cache): BatchableCache {
19
- if (cache instanceof PagedKVCache) return new PagedKvRows(cache.capacityTokens, cache.blockSize);
19
+ if (cache instanceof PagedKVCache) return new PagedKvRows(cache.capacityTokens, cache.blockSize, cache.direct, cache.quantization);
20
20
  if (isBatchableCache(cache)) return cache.makeEmptyBatch();
21
21
  if (cache instanceof RotatingKVCache) return new DelayedRotatingQuantizedKVCache(cache.maxSize, 64, 4, Infinity, () => {});
22
22
  if (cache instanceof RotatingQuantizedKVCache) return new RotatingAffineLayout(cache.maxSize, cache.groupSize, cache.bits);
@@ -19,13 +19,14 @@ export class ContinuationPersistence {
19
19
  #nextBarrier = 0;
20
20
  constructor(readonly store: ContinuationStore, options: {
21
21
  maxBytes: number;
22
- /** All blocking tensor serialization runs through the owner's idle gate. */
23
- runStep: <T>(step: () => T) => Promise<T>;
22
+ /** Optional owner-thread preparation hook; disk writes use the CPU worker. */
23
+ runStep?: <T>(step: () => T) => Promise<T>;
24
24
  }) {
25
+ const runStep = options.runStep ?? (async <T>(step: () => T): Promise<T> => step());
25
26
  this.#queue = new SpillQueue(options.maxBytes, cacheBytes, async item => {
26
27
  const job = this.#jobs.get(item.caches)!;
27
28
  if (job.cleanup) {
28
- await options.runStep(() => store.removeGenerationCheckpoints(job.attempt.key));
29
+ await runStep(() => store.removeGenerationCheckpoints(job.attempt.key));
29
30
  return true;
30
31
  }
31
32
  const current = () => this.#attempts.get(job.attempt.key) === job.attempt && !job.attempt.completed;
@@ -33,7 +34,7 @@ export class ContinuationPersistence {
33
34
  const stored = await store.storeGenerationCheckpoint(item.tokens, item.caches, job.metadata!, options.runStep);
34
35
  // No newer queued write can run until this callback returns. An old
35
36
  // in-flight rename can therefore be removed without deleting new work.
36
- if (!current()) await options.runStep(() => store.removeGenerationCheckpoints(job.attempt.key));
37
+ if (!current()) await runStep(() => store.removeGenerationCheckpoints(job.attempt.key));
37
38
  return stored;
38
39
  }, disposeResources, item => !this.#jobs.get(item.caches)?.cleanup);
39
40
  }
@@ -0,0 +1,25 @@
1
+ import type { ObjectCache } from "../../contracts/object-cache";
2
+ import { cloneAttachments, disposeAttachments, type CheckpointAttachment } from "./checkpoint-state";
3
+ import type { MlxArray } from "../../mlx/array";
4
+
5
+ /** Encoder-owned tensor schema over the cache's exact-object interface. */
6
+ export class EncoderCache {
7
+ constructor(private readonly objects: ObjectCache<CheckpointAttachment[]>,
8
+ readonly identity: string) {}
9
+
10
+ async take(key: string): Promise<MlxArray | null> {
11
+ const lease = await this.objects.take(`encoder-v1:${this.identity}:${key}`);
12
+ if (!lease) return null;
13
+ try {
14
+ const tensor = lease.value[0]!.tensors[0]!;
15
+ return tensor.slice(tensor.shape.map(() => 0), [...tensor.shape]);
16
+ } finally { lease.dispose(); }
17
+ }
18
+
19
+ /** Borrow the result; storage retains its own immutable native view. */
20
+ put(key: string, features: MlxArray): void {
21
+ const owned = cloneAttachments([{ schema: "encoder-features-v1", metadata: {}, tensors: [features] }]);
22
+ try { this.objects.put(`encoder-v1:${this.identity}:${key}`, owned); }
23
+ catch (error) { disposeAttachments(owned); throw error; }
24
+ }
25
+ }
@@ -6,8 +6,8 @@ import { leaseCacheStates } from "./state-views";
6
6
 
7
7
  /** Append an already accepted span without intermediate vocabulary heads.
8
8
  * Chunking changes the graph shape, so callers must qualify its numerics for
9
- * their model. A verify round must use chunkSize=0: recurrent rollback records
10
- * one forward per round. The complete append precedes emission/cancellation
9
+ * their model. Verification uses its separate round operation because
10
+ * recurrent rollback records one forward per round. Append precedes emission/cancellation
11
11
  * boundaries, preserving the cache-covered prefix on a consumer break.
12
12
  */
13
13
  export async function appendFillHidden(
@@ -17,15 +17,29 @@ export async function appendFillHidden(
17
17
  chunkSize: (cache: readonly Cache[]) => number,
18
18
  ): Promise<MlxArray> {
19
19
  const input = ops.fromInt32(ids, [1, ids.length]);
20
+ try { return await appendHiddenRows(forward, cache, input, chunkSize); }
21
+ finally { input.dispose(); }
22
+ }
23
+
24
+ /** Append equal-length committed token rows [B,L]. The caller owns the input;
25
+ * the returned hidden rows belong to the caller. Model policy receives B so
26
+ * a larger cohort cannot silently cross its qualified projection geometry. */
27
+ export async function appendHiddenRows(
28
+ forward: (ids: MlxArray, cache: Cache[]) => MlxArray | Promise<MlxArray>,
29
+ cache: Cache[],
30
+ input: MlxArray,
31
+ chunkSize: (cache: readonly Cache[], rows: number) => number,
32
+ ): Promise<MlxArray> {
33
+ const [rows, length] = input.shape as [number, number];
20
34
  const hidden: MlxArray[] = [];
21
35
  try {
22
- for (let start = 0; start < ids.length;) {
23
- const limit = chunkSize(cache);
36
+ for (let start = 0; start < length;) {
37
+ const limit = chunkSize(cache, rows);
24
38
  if (!Number.isSafeInteger(limit) || limit < 1)
25
39
  throw new Error("Model append chunk limit must be a positive integer");
26
- const end = Math.min(ids.length, start + limit);
27
- if (start === 0 && end === ids.length) return await forward(input, cache);
28
- const part = input.slice([0, start], [1, end]);
40
+ const end = Math.min(length, start + limit);
41
+ if (start === 0 && end === length) return await forward(input, cache);
42
+ const part = input.slice([0, start], [rows, end]);
29
43
  try {
30
44
  const h = await forward(part, cache);
31
45
  hidden.push(h);
@@ -40,6 +54,5 @@ export async function appendFillHidden(
40
54
  return ops.concatAxis(hidden, 1);
41
55
  } finally {
42
56
  for (const h of hidden) h.dispose();
43
- input.dispose();
44
57
  }
45
58
  }