mlx-bun 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +57 -32
  2. package/docs/README.md +55 -42
  3. package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
  4. package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
  5. package/docs/design/batching.md +220 -7
  6. package/docs/design/decode-speed-program.md +121 -66
  7. package/docs/design/generic-model-support.md +116 -0
  8. package/docs/design/kv-cache.md +380 -211
  9. package/docs/design/speculative-decoding.md +165 -26
  10. package/docs/design/turboquant.md +20 -7
  11. package/docs/design/unified-engine-frontier-plan.md +120 -13
  12. package/docs/planning/PRODUCT_ROADMAP.md +5 -6
  13. package/docs/planning/public-readiness-handoff.md +244 -0
  14. package/docs/planning/public-readiness.md +149 -0
  15. package/docs/reference/benchmarks.md +3463 -3489
  16. package/docs/reference/cli.md +62 -1
  17. package/docs/reference/distribution.md +29 -4
  18. package/docs/reference/environment.md +104 -8
  19. package/docs/reference/library-api.md +18 -4
  20. package/docs/reference/memory.md +1 -1
  21. package/docs/reference/models.md +17 -5
  22. package/docs/reference/server-api.md +181 -11
  23. package/docs/reference/server-config.md +232 -105
  24. package/package.json +2 -2
  25. package/src/audio/audiotoolbox.ts +112 -0
  26. package/src/audio/conformer.ts +8 -0
  27. package/src/audio/mic-capture.ts +98 -0
  28. package/src/audio/silero-vad.ts +379 -0
  29. package/src/audio/whisper-decode.ts +774 -0
  30. package/src/audio/whisper-fast.ts +732 -0
  31. package/src/audio/whisper-format.ts +73 -0
  32. package/src/audio/whisper-mel.ts +185 -0
  33. package/src/audio/whisper-timing.ts +274 -0
  34. package/src/audio/whisper-tokenizer.ts +239 -0
  35. package/src/audio/whisper-transcribe.ts +410 -0
  36. package/src/backends/mlx/autoregressive.ts +22 -1
  37. package/src/backends/mlx/batch-group.ts +111 -27
  38. package/src/backends/mlx/cache-layout.ts +3 -3
  39. package/src/backends/mlx/continuation-persistence.ts +5 -4
  40. package/src/backends/mlx/encoder-cache.ts +25 -0
  41. package/src/backends/mlx/fill-append.ts +21 -8
  42. package/src/backends/mlx/fill-group.ts +497 -0
  43. package/src/backends/mlx/gateway-binding.ts +56 -12
  44. package/src/backends/mlx/kv-maintenance.ts +30 -2
  45. package/src/backends/mlx/mixed-iteration.ts +62 -0
  46. package/src/backends/mlx/model-prompt.ts +46 -32
  47. package/src/backends/mlx/model-serving.ts +4 -3
  48. package/src/backends/mlx/paged-cache-codec.ts +27 -0
  49. package/src/backends/mlx/prefill-cohort.ts +15 -4
  50. package/src/backends/mlx/prefill-policy.ts +26 -0
  51. package/src/backends/mlx/prefill-rows.ts +57 -13
  52. package/src/backends/mlx/prompt-input.ts +29 -0
  53. package/src/backends/mlx/qwen-prompt-input.ts +40 -0
  54. package/src/backends/mlx/request-state-policy.ts +18 -5
  55. package/src/backends/mlx/rollback.ts +6 -3
  56. package/src/backends/mlx/serial-executor.ts +16 -7
  57. package/src/backends/mlx/speculative-group.ts +105 -26
  58. package/src/backends/mlx/speculative.ts +24 -7
  59. package/src/backends/mlx/target-layout-capability.ts +1 -1
  60. package/src/backends/mlx/transitioning-kv-rows.ts +8 -0
  61. package/src/cli.ts +322 -0
  62. package/src/contracts/execution.ts +10 -0
  63. package/src/contracts/object-cache.ts +8 -0
  64. package/src/contracts/pi-provider.ts +1 -1
  65. package/src/contracts/pi.ts +3 -1
  66. package/src/contracts/prefix-cache.ts +13 -3
  67. package/src/contracts/scheduling.ts +9 -0
  68. package/src/engine/execution-plan.ts +23 -12
  69. package/src/engine/execution-tasks.ts +44 -0
  70. package/src/engine/namespaced-cache.ts +13 -0
  71. package/src/engine/scheduler.ts +22 -2
  72. package/src/fill/fill-session.ts +14 -3
  73. package/src/generate.ts +28 -22
  74. package/src/grammar.ts +13 -0
  75. package/src/inference/prefill.ts +6 -0
  76. package/src/kv-scheme.ts +22 -0
  77. package/src/kv-store.ts +115 -79
  78. package/src/lab/README.md +5 -1
  79. package/src/lab/paged-kv/paged-attention.ts +117 -0
  80. package/src/lab/paged-kv/paged-kv-rows.ts +33 -5
  81. package/src/lab/paged-kv/paged-kv.ts +108 -42
  82. package/src/md-modules.d.ts +5 -0
  83. package/src/mlx/array.ts +37 -0
  84. package/src/mlx/ffi.ts +19 -0
  85. package/src/mlx/normalized-argmax.ts +116 -0
  86. package/src/mlx/ops.ts +97 -10
  87. package/src/model/batched-turboquant-kv.ts +6 -5
  88. package/src/model/delayed-quantized-kv.ts +3 -3
  89. package/src/model/delayed-rotating-quantized-kv.ts +1 -1
  90. package/src/model/delayed-turboquant-kv.ts +6 -4
  91. package/src/model/factory.ts +26 -0
  92. package/src/model/fingerprint.ts +12 -0
  93. package/src/model/full-prefill-row.ts +1 -1
  94. package/src/model/gemma4-base.ts +25 -6
  95. package/src/model/gemma4.ts +82 -2
  96. package/src/model/kv-attention-view.ts +4 -1
  97. package/src/model/profile.ts +29 -4
  98. package/src/model/quantized-append-attention.ts +31 -0
  99. package/src/model/qwen3-mrope.ts +7 -9
  100. package/src/model/qwen3_5.ts +99 -37
  101. package/src/model/support.ts +8 -0
  102. package/src/model/token-groups.ts +66 -0
  103. package/src/model/trellis-linear.ts +2 -2
  104. package/src/model/turboquant-codec.ts +6 -0
  105. package/src/model/whisper.ts +435 -0
  106. package/src/native/mic_capture.swift +92 -0
  107. package/src/native-pack.ts +6 -5
  108. package/src/pi-web.ts +9 -1
  109. package/src/prompt-cache.ts +265 -43
  110. package/src/runtime-config.ts +1 -5
  111. package/src/safetensors.ts +9 -2
  112. package/src/sampler.ts +5 -4
  113. package/src/serve/audio-routes.ts +323 -0
  114. package/src/serve/chat-request.ts +2 -0
  115. package/src/serve/chat-stage.ts +17 -12
  116. package/src/serve/completion-executor.ts +9 -1
  117. package/src/serve/discovery-routes.ts +17 -1
  118. package/src/serve/generated-token-history.ts +57 -0
  119. package/src/serve/generation-gateway.ts +52 -11
  120. package/src/serve/model-binding.ts +7 -1
  121. package/src/serve/preparation.ts +3 -3
  122. package/src/serve/prompt-response-trace.ts +14 -0
  123. package/src/serve/request-plan.ts +2 -0
  124. package/src/serve/request-prep.ts +38 -21
  125. package/src/serve/transcription-server.ts +64 -0
  126. package/src/serve/transcription-service.ts +502 -0
  127. package/src/server.ts +132 -164
  128. package/src/spec/assistant-rows.ts +1 -0
  129. package/src/spec/deepspec-rows.ts +1 -1
  130. package/src/spec/dflash-source.ts +1 -1
  131. package/src/spec/glm52-mtp-source.ts +1 -0
  132. package/src/spec/ngram-source.ts +27 -5
  133. package/src/spec/projected-draft-rows.ts +2 -2
  134. package/src/spec/qwen-mtp-rows.ts +11 -0
  135. package/src/spec/qwen-mtp-source.ts +3 -0
  136. package/src/spec/serve-loop.ts +5 -3
  137. package/src/spec/source.ts +14 -1
  138. package/src/spec/two-model-rows.ts +1 -0
  139. package/src/spec/two-model.ts +2 -2
  140. package/src/ssd-cache.ts +76 -17
  141. package/src/ssd-durability.ts +20 -22
  142. package/src/storage/host-buffer.ts +24 -0
  143. package/src/storage/kv-writer.ts +83 -0
  144. package/src/storage/kv-writer.worker.js +264 -0
  145. package/src/storage/retention-policy.ts +37 -0
  146. package/src/tiered-prompt-cache.ts +45 -0
  147. package/src/vision/embedder.ts +8 -0
  148. package/src/vision/prompt.ts +101 -65
  149. package/src/vision/qwen3vl-prompt.ts +101 -64
  150. package/src/vision/qwen3vl-tower.ts +11 -0
  151. package/src/vision/siglip.ts +8 -0
  152. package/src/web/app.html +12 -0
  153. package/src/web/app.js +160 -0
  154. package/src/web/src/chat.ts +4 -1
  155. package/src/web/src/composer.ts +3 -0
  156. package/src/web/src/voice.ts +141 -0
  157. package/src/web/sw.js +1 -1
  158. /package/docs/{planning → archive/planning}/release-notes-v0.4.0.md +0 -0
@@ -1,3 +1,5 @@
1
+ import { turboQuantFusedDecode } from "../../model/turboquant-codec";
2
+ import { runtimeConfig, withRuntimeConfig } from "../../runtime-config";
1
3
  import type { KvSchemeOptions } from "../../kv-scheme";
2
4
  import { KVCache, QuantizedKVCache, RotatingKVCache, RotatingQuantizedKVCache, TurboQuantKVCache, type Cache } from "../../model/gemma4-base";
3
5
  import type { KvQuantSpec, TurboQuantScheme } from "../../config";
@@ -10,6 +12,8 @@ import { DelayedTurboQuantKVCache } from "../../model/delayed-turboquant-kv";
10
12
 
11
13
  export interface KvMaintenance {
12
14
  (cache: Cache[]): void;
15
+ /** Limit committed work at a pending precision transition. */
16
+ maxAppendTokens?(cache: readonly Cache[]): number;
13
17
  /** Bind state requiring row-local maintenance before shared decode. */
14
18
  prepareBatch?(cache: Cache[]): void;
15
19
  /** Bind all precision policies before a prefill cohort owns row boundaries. */
@@ -43,12 +47,24 @@ export function createKvMaintenance(options: Readonly<Omit<KvSchemeOptions, "kvC
43
47
  if (turboQuant) {
44
48
  const start = options.quantizedKvStart ?? 0;
45
49
  const scheme = { ...turboQuant };
46
- const maintain: KvMaintenance = (cache) => maybeTurboQuantizeKv(cache, scheme, start);
50
+ const runtime = runtimeConfig();
51
+ const fusedDecode = turboQuantFusedDecode(runtime);
52
+ const maintain: KvMaintenance = (cache) => withRuntimeConfig(runtime, () => maybeTurboQuantizeKv(cache, scheme, start));
53
+ if (start > 0) maintain.maxAppendTokens = (cache) => {
54
+ let remaining = Number.POSITIVE_INFINITY;
55
+ for (const c of cache) {
56
+ remaining = Math.min(remaining, c.maxAppendTokens?.() ?? Infinity);
57
+ const conversion = c.turboConversion ?? (c instanceof KVCache ? c : undefined);
58
+ if (conversion && conversion.offset < start)
59
+ remaining = Math.min(remaining, start - conversion.offset);
60
+ }
61
+ return remaining;
62
+ };
47
63
  maintain.preparePrefill = (cache) => {
48
64
  for (let layer = 0; layer < cache.length; layer++) {
49
65
  const row = cache[layer]!;
50
66
  if (row instanceof KVCache || row instanceof TurboQuantKVCache)
51
- cache[layer] = new DelayedTurboQuantKVCache(scheme.kBits, scheme.vBits, start, maintain, row);
67
+ cache[layer] = new DelayedTurboQuantKVCache(scheme.kBits, scheme.vBits, start, maintain, row, fusedDecode);
52
68
  }
53
69
  };
54
70
  if (start > 0) maintain.prepareBatch = maintain.preparePrefill;
@@ -83,6 +99,18 @@ export function createKvMaintenance(options: Readonly<Omit<KvSchemeOptions, "kvC
83
99
  clearCache();
84
100
  }
85
101
  };
102
+ if (start > 0) maintain.maxAppendTokens = (cache) => {
103
+ let remaining = Number.POSITIVE_INFINITY;
104
+ for (let layer = 0; layer < cache.length; layer++) {
105
+ if (byLayer && !byLayer.has(layer)) continue;
106
+ const c = cache[layer]!;
107
+ remaining = Math.min(remaining, c.maxAppendTokens?.() ?? Infinity);
108
+ const conversion = c.affineConversion ?? (c instanceof KVCache || c instanceof RotatingKVCache ? c : undefined);
109
+ if (conversion && conversion.offset < start)
110
+ remaining = Math.min(remaining, start - conversion.offset);
111
+ }
112
+ return remaining;
113
+ };
86
114
  maintain.preparePrefill = (cache) => {
87
115
  for (let layer = 0; layer < cache.length; layer++) {
88
116
  const row = cache[layer]!;
@@ -0,0 +1,62 @@
1
+ import type { MlxArray } from "../../mlx/array";
2
+ import type { Cache } from "../../model/gemma4-base";
3
+ import type { TokenGroup, TokenWorkOptions } from "../../model/token-groups";
4
+
5
+ export type MlxForwardWork = (ids: MlxArray, cache: Cache[], options?: TokenWorkOptions) => Promise<MlxArray>;
6
+ export interface MlxPreparationWork {
7
+ /** Real prompt tokens across all rows, not a padded sequence width. */
8
+ readonly maxTokens?: number;
9
+ readonly forward?: MlxForwardWork;
10
+ }
11
+
12
+ /** Join two method-owned work producers at their forward boundary. Decode
13
+ * finishes sampling/publication/retirement before preparation can join rows.
14
+ * The model owns packing; this coordinator never merges caches or samples.
15
+ * A restored/cancelled preparation need not produce a forward at all. */
16
+ export async function runMixedTokenIteration(options: {
17
+ prepare(forward: MlxForwardWork): Promise<void>;
18
+ decode(forward: MlxForwardWork): Promise<void>;
19
+ forward: MlxForwardWork;
20
+ mixed(groups: readonly TokenGroup[]): MlxArray[];
21
+ }): Promise<boolean> {
22
+ let notify!: () => void;
23
+ const offered = new Promise<void>(resolve => { notify = resolve; });
24
+ let pending: { group: TokenGroup; resolve(hidden: MlxArray): void; reject(error: unknown): void } | undefined;
25
+ let captured = false;
26
+ let preparationError: unknown;
27
+ let preparationFailed = false;
28
+ const preparing = options.prepare(async (ids, cache, policy) => {
29
+ if (captured) return options.forward(ids, cache, policy);
30
+ captured = true;
31
+ return new Promise<MlxArray>((resolve, reject) => {
32
+ pending = { group: { ids, cache, ...policy }, resolve, reject }; notify();
33
+ });
34
+ }).catch(error => { preparationFailed = true; preparationError = error; }).finally(notify);
35
+ await offered;
36
+ if (!pending) {
37
+ await preparing;
38
+ if (preparationFailed) throw preparationError;
39
+ return false;
40
+ }
41
+ const held = pending;
42
+ let preparedHidden: MlxArray | undefined;
43
+ let mixed = false;
44
+ try {
45
+ await options.decode(async (ids, cache, policy) => {
46
+ if (mixed) return options.forward(ids, cache, policy);
47
+ const outputs = options.mixed([{ ids, cache, ...policy }, held.group]);
48
+ mixed = true; preparedHidden = outputs[1]!;
49
+ return outputs[0]!;
50
+ });
51
+ // A final unread token can retire decode without another model call.
52
+ preparedHidden ??= await options.forward(held.group.ids, held.group.cache, held.group);
53
+ held.resolve(preparedHidden); preparedHidden = undefined;
54
+ await preparing;
55
+ if (preparationFailed) throw preparationError;
56
+ return true;
57
+ } catch (error) {
58
+ preparedHidden?.dispose(); held.reject(error);
59
+ await preparing;
60
+ throw error;
61
+ }
62
+ }
@@ -4,7 +4,7 @@ import { Gemma4Model } from "../../model/gemma4";
4
4
  import { Qwen35Model } from "../../model/qwen3_5";
5
5
  import { spliceImageTokens } from "../../vision/diffusion-vision";
6
6
  import { buildMultimodalPrompt, buildVisionPrompt, extractAudio, extractImages, extractVideos,
7
- type VisionEncoder, type VisionTokenIds } from "../../vision/prompt";
7
+ type MultimodalTowers } from "../../vision/prompt";
8
8
  import { buildQwen3VLVisionPrompt } from "../../vision/qwen3vl-prompt";
9
9
  import type { Qwen3VLVisionTower } from "../../vision/qwen3vl-tower";
10
10
  import { normalizeMessages, type ChatRequestParams } from "../../serve/chat-request";
@@ -12,7 +12,8 @@ import { getAudioTower, getVisionTower, type ServerContext } from "../../serve/m
12
12
  import { RequestError } from "../../serve/pipeline";
13
13
  import type { RequestOwnership } from "../../serve/request-plan";
14
14
  import type { RequestPrep } from "../../serve/request-prep";
15
- import type { BuiltPrompt } from "../../serve/model-binding";
15
+ import type { BuiltPrompt, PromptNativeWork } from "../../serve/model-binding";
16
+ import { EncoderCache } from "./encoder-cache";
16
17
 
17
18
  export async function buildModelPrompt(
18
19
  ctx: ServerContext,
@@ -20,6 +21,8 @@ export async function buildModelPrompt(
20
21
  body: ChatRequestParams,
21
22
  tools: ChatRequestParams["tools"] | null,
22
23
  ownership: RequestOwnership,
24
+ nativeWork: PromptNativeWork = (work) => work(),
25
+ objects?: import("../../contracts/object-cache").ObjectCache<import("./checkpoint-state").CheckpointAttachment[]>,
23
26
  ): Promise<BuiltPrompt> {
24
27
  const toolList = tools ?? null;
25
28
  const partOf = (types: string[]) => body.messages.some(
@@ -53,19 +56,6 @@ export async function buildModelPrompt(
53
56
  // audio on a model whose tower is unavailable is an explicit 400
54
57
  // — never a silent text-only degrade (that leniency is only for
55
58
  // requests without the media, getVisionTower's contract).
56
- const audioTower = getAudioTower(ctx);
57
- if (!audioTower || !ctx.audioTokenIds) {
58
- throw new RequestError(400,
59
- `model ${ctx.modelId} has no audio tower — audio input needs ` +
60
- `a model whose config.json carries audio_config and whose ` +
61
- `sidecar ships the audio tensors (e.g. gemma-4 e4b OptiQ)`);
62
- }
63
- let visionSide: { tower: VisionEncoder; tokenIds: VisionTokenIds } | undefined;
64
- if (hasImages) {
65
- const tower = getVisionTower(ctx);
66
- if (!tower) throw new RequestError(400, "model has no vision sidecar");
67
- visionSide = { tower, tokenIds: ctx.visionTokenIds };
68
- }
69
59
  const { messages: withAudioParts, images } =
70
60
  await extractImages(normalizeMessages(body.messages));
71
61
  const { messages, audio } = await extractAudio(withAudioParts);
@@ -74,13 +64,30 @@ export async function buildModelPrompt(
74
64
  // Failures throw into the prompt-build 400.
75
65
  const wavs = await Promise.all(audio.map(ensureWav));
76
66
  // The towers are only ever non-null for Gemma4 (loader gates).
77
- const mp = await buildMultimodalPrompt(
78
- ctx.model as Gemma4Model,
79
- {
67
+ const towers = await nativeWork(async () => {
68
+ const audioTower = getAudioTower(ctx);
69
+ if (!audioTower || !ctx.audioTokenIds) {
70
+ throw new RequestError(400,
71
+ `model ${ctx.modelId} has no audio tower — audio input needs ` +
72
+ `a model whose config.json carries audio_config and whose ` +
73
+ `sidecar ships the audio tensors (e.g. gemma-4 e4b OptiQ)`);
74
+ }
75
+ let visionSide: MultimodalTowers<{ softTokens: number }>["vision"];
76
+ if (hasImages) {
77
+ const tower = getVisionTower(ctx);
78
+ if (!tower) throw new RequestError(400, "model has no vision sidecar");
79
+ visionSide = { tower, tokenIds: ctx.visionTokenIds,
80
+ cache: objects && tower.cacheIdentity ? new EncoderCache(objects, tower.cacheIdentity) : undefined };
81
+ }
82
+ return {
80
83
  ...(visionSide ? { vision: visionSide } : {}),
81
- audio: { tower: audioTower, tokenIds: ctx.audioTokenIds },
82
- },
83
- ctx.tokenizer, ctx.template, messages, images, wavs, toolList,
84
+ audio: { tower: audioTower, tokenIds: ctx.audioTokenIds,
85
+ cache: objects ? new EncoderCache(objects, audioTower.cacheIdentity) : undefined },
86
+ };
87
+ });
88
+ const mp = await buildMultimodalPrompt(
89
+ ctx.model as Gemma4Model, towers, ctx.tokenizer, ctx.template,
90
+ messages, images, wavs, toolList, nativeWork,
84
91
  );
85
92
  // bidirMask is null whenever audio is present (§3.3 Q1: mixed
86
93
  // prompts run fully causal); the union mask does the per-layer
@@ -89,7 +96,7 @@ export async function buildModelPrompt(
89
96
  ...noMedia,
90
97
  promptIds: mp.ids,
91
98
  vision: {
92
- embeddings: mp.embeddings,
99
+ embeddings: mp.embeddings, prefixIdentity: mp.prefixIdentity,
93
100
  ...(mp.bidirMask ? { imageMask: mp.bidirMask } : {}),
94
101
  multimodalMask: mp.multimodalMask,
95
102
  },
@@ -106,7 +113,7 @@ export async function buildModelPrompt(
106
113
  throw new RequestError(400, "DiffusionGemma image input supports exactly one image");
107
114
  const rendered = ctx.template.render(messages, { tools: toolList, addGenerationPrompt: true });
108
115
  const rawIds = ctx.tokenizer.encode(rendered, /* addSpecialTokens */ false);
109
- const { pixels, softTokens } = await dm.visionTower.preprocess(images[0]!);
116
+ const { pixels, softTokens } = await nativeWork(() => dm.visionTower!.preprocess(images[0]!));
110
117
  const diffusionPixels = ownership.own(pixels);
111
118
  const promptIds = spliceImageTokens(rawIds, [softTokens], {
112
119
  image: ctx.visionTokenIds.imageTokenId,
@@ -121,13 +128,16 @@ export async function buildModelPrompt(
121
128
  // image/video spans splice into input embeddings and the request
122
129
  // carries mRoPE positions + delta (PLAN 14v/14w). Videos decode
123
130
  // to sampled frames via the AVFoundation sidecar.
124
- const tower = getVisionTower(ctx) as unknown as Qwen3VLVisionTower | null;
125
- if (!tower) throw new RequestError(400, "model has no vision sidecar");
126
131
  const { messages: withVideos, images } =
127
132
  await extractImages(normalizeMessages(body.messages));
128
133
  const { messages, videos } = await extractVideos(withVideos);
134
+ const { tower, encoderCache } = await nativeWork(async () => {
135
+ const tower = getVisionTower(ctx) as unknown as Qwen3VLVisionTower | null;
136
+ if (!tower) throw new RequestError(400, "model has no vision sidecar");
137
+ return { tower, encoderCache: objects ? new EncoderCache(objects, tower.cacheIdentity) : undefined };
138
+ });
129
139
  const vp = await buildQwen3VLVisionPrompt(
130
- ctx.model, tower, ctx.tokenizer, ctx.template, messages, images,
140
+ ctx.model as Qwen35Model, tower, ctx.tokenizer, ctx.template, messages, images,
131
141
  {
132
142
  imageTokenId: (ctx.model.config.raw.image_token_id as number) ?? 248056,
133
143
  videoTokenId: (ctx.model.config.raw.video_token_id as number) ?? 248057,
@@ -135,23 +145,27 @@ export async function buildModelPrompt(
135
145
  visionEndId: (ctx.model.config.raw.vision_end_token_id as number) ?? 248054,
136
146
  },
137
147
  prep.templateOptionsFor(body, toolList),
138
- videos,
148
+ videos, nativeWork, encoderCache,
139
149
  );
140
- return { ...noMedia, promptIds: vp.ids, vision: { embeddings: vp.embeddings, mrope: vp.mrope } };
150
+ return { ...noMedia, promptIds: vp.ids, vision: { embeddings: vp.embeddings, mrope: vp.mrope, prefixIdentity: vp.prefixIdentity } };
141
151
  }
142
152
  if (hasImages) {
143
153
  // Loads (and caches) the tower on first image request — text-only
144
154
  // sessions never pay for it.
145
- const tower = getVisionTower(ctx);
146
- if (!tower) throw new RequestError(400, "model has no vision sidecar");
147
155
  const { messages, images } = await extractImages(normalizeMessages(body.messages));
148
156
  // The tower is only ever non-null for Gemma4 (sidecar gate in
149
157
  // makeVisionLoader), so the model narrow is safe here.
158
+ const { tower, encoderCache } = await nativeWork(async () => {
159
+ const tower = getVisionTower(ctx);
160
+ if (!tower) throw new RequestError(400, "model has no vision sidecar");
161
+ return { tower, encoderCache: objects && tower.cacheIdentity
162
+ ? new EncoderCache(objects, tower.cacheIdentity) : undefined };
163
+ });
150
164
  const vp = await buildVisionPrompt(
151
165
  ctx.model as Gemma4Model, tower, ctx.tokenizer, ctx.template,
152
- messages, images, ctx.visionTokenIds, toolList,
166
+ messages, images, ctx.visionTokenIds, toolList, nativeWork, encoderCache,
153
167
  );
154
- return { ...noMedia, promptIds: vp.ids, vision: { embeddings: vp.embeddings, imageMask: vp.imageMask } };
168
+ return { ...noMedia, promptIds: vp.ids, vision: { embeddings: vp.embeddings, imageMask: vp.imageMask, prefixIdentity: vp.prefixIdentity } };
155
169
  }
156
170
  const text = prep.promptIdsFor(body, toolList);
157
171
  return {
@@ -8,8 +8,8 @@ import { buildModelPrompt } from "./model-prompt";
8
8
  import { MLX_VERSION, deviceArchitecture } from "../../mlx/ffi";
9
9
 
10
10
  export function modelPromptBuilder(context: ServingContext): ModelServingBinding["buildPrompt"] {
11
- return context.serving?.buildPrompt ?? ((body, tools, ownership, prep) =>
12
- buildModelPrompt(context as ServerContext, prep, body, tools, ownership));
11
+ return context.serving?.buildPrompt ?? ((body, tools, ownership, prep, nativeWork, objects) =>
12
+ buildModelPrompt(context as ServerContext, prep, body, tools, ownership, nativeWork, objects));
13
13
  }
14
14
 
15
15
  /** Existing model classes enter the interface once here. New implementations
@@ -27,8 +27,9 @@ export function modelServingBinding(context: ServingContext): ModelServingBindin
27
27
  stateCompatibility: `mlx-${MLX_VERSION}-${deviceArchitecture()}`,
28
28
  gateway: bindMlxGateway(model, ctx.draft ?? undefined),
29
29
  createSerial: (services) => createMlxSerialExecutor(serial, services),
30
- buildPrompt: (body, tools, ownership, prep) => buildModelPrompt(ctx, prep, body, tools, ownership),
30
+ buildPrompt: (body, tools, ownership, prep, nativeWork, objects) => buildModelPrompt(ctx, prep, body, tools, ownership, nativeWork, objects),
31
31
  restore: (store, entry) => store.restore(entry, model),
32
+ restoreAsync: (store, entry) => store.restoreAsync(entry, model),
32
33
  async signal(ids, count, minimum) {
33
34
  const caches = model.makeCache();
34
35
  try {
@@ -0,0 +1,27 @@
1
+ import type { CacheCodec } from "../../kv-store";
2
+ import { BlockPool, PagedKVCache } from "../../lab/paged-kv/paged-kv";
3
+
4
+ /** Page layout owns its persistence codec, independently of SSD transport. */
5
+ export const pagedCacheCodec: CacheCodec = {
6
+ matches: cache => cache instanceof PagedKVCache,
7
+ snapshot(cache, context) {
8
+ const c = cache as PagedKVCache, p = c.pool!;
9
+ for (const array of p.arrays()) context.push(array, false);
10
+ return { kind: "paged", offset: c.offset, tensors: context.slots,
11
+ ...(p.quantization ? p.quantization : {}),
12
+ paged: { capacityTokens: c.capacityTokens, blockSize: c.blockSize, blockTable: [...c.blockTable],
13
+ numBlocks: p.numBlocks, headDim: p.headDim, vHeadDim: p.vHeadDim, dtype: p.dtype, direct: c.direct } };
14
+ },
15
+ clone: cache => (cache as PagedKVCache).clone(),
16
+ load(entry, context) {
17
+ const layout = entry.paged!;
18
+ const quantization = entry.bits ? { bits: entry.bits as 4 | 8, groupSize: entry.groupSize! } : undefined;
19
+ const cache = new PagedKVCache(layout.capacityTokens, layout.blockSize, layout.direct, quantization);
20
+ const pool = new BlockPool({ ...layout, numKvHeads: entry.tensors[0]!.shape[1]!, quantization });
21
+ try { pool.restoreArrays(entry.tensors.map(context.arr), layout.blockTable); }
22
+ catch (error) { pool.dispose(); throw error; }
23
+ cache.pool = pool; cache.offset = entry.offset; cache.blockTable = [...layout.blockTable];
24
+ return cache;
25
+ },
26
+ headerTrimmable: () => true,
27
+ };
@@ -32,6 +32,10 @@ interface PrefillHost {
32
32
 
33
33
  /** Ordinary sampling/cache policy over the shared target preparation driver. */
34
34
  export class MlxPrefillCohort extends MlxPrefillRows<PrefillState> {
35
+ override get canAdmit(): boolean {
36
+ return super.canAdmit && this.supportsMixedWork;
37
+ }
38
+ get supportsMixedWork(): boolean { return !this.rows.some(row => row.req.promptInput); }
35
39
  constructor(readonly host: PrefillHost) {
36
40
  super({
37
41
  stateCodecs: host.stateCodecs, maintain: host.maintain,
@@ -47,7 +51,7 @@ export class MlxPrefillCohort extends MlxPrefillRows<PrefillState> {
47
51
  return { row, solo: continuation.caches, pos: row.req.promptIds.length,
48
52
  snapAt: null, continuation };
49
53
  }
50
- const hit = host.promptCache?.take(row.req.promptIds, row.cacheNamespace) ?? null;
54
+ const hit = host.promptCache?.take(row.req.promptIds, row.cacheNamespace, row.req.cacheSessionId) ?? null;
51
55
  if (hit) owned = hit;
52
56
  closeCache?.(); closeCache = undefined;
53
57
  if (hit) row.cachedTokens = hit.tokens.length;
@@ -59,6 +63,7 @@ export class MlxPrefillCohort extends MlxPrefillRows<PrefillState> {
59
63
  });
60
64
  const closeSetup = row.req.trace?.begin("prefill.batch_setup", { mechanism: "continuous" });
61
65
  owned ??= { caches: row.req.statePolicy?.create() ?? host.model.makeCache() }; closeSetup?.();
66
+ if (row.req.promptInput) host.maintain?.(owned.caches);
62
67
  return { row, solo: owned.caches, pos: hit?.tokens.length ?? 0, retain: owned.retain, snapAt, closePrefill };
63
68
  } catch (error) {
64
69
  return cleanupFailure(error, () => disposeResources([...(owned?.caches ?? []),
@@ -66,16 +71,22 @@ export class MlxPrefillCohort extends MlxPrefillRows<PrefillState> {
66
71
  } finally { closeCache?.(); }
67
72
  },
68
73
  ready: state => !!state.continuation,
69
- plan(state) { return nextPrefillStep({ length: state.row.promptTokens, position: state.pos,
74
+ plan(state) {
75
+ if (state.row.req.promptInput) return { kind: "final", start: state.pos,
76
+ end: state.row.promptTokens, snapshot: false, batchYield: false, atomic: true };
77
+ return nextPrefillStep({ length: state.row.promptTokens, position: state.pos,
70
78
  chunkSize: state.row.req.prefillChunkSize ?? host.chunkSize,
71
79
  tailSplit: host.tailSplit, snapshotAt: state.snapAt }); },
72
- forward: host.forward.bind(host), project: host.project.bind(host),
80
+ forward: (ids, caches, states, work) => states[0]?.row.req.promptInput
81
+ ? Promise.resolve(states[0].row.req.promptInput.forward(ids, caches, states[0].pos))
82
+ : work ? work(ids, caches) : host.forward(ids, caches),
83
+ project: host.project.bind(host),
73
84
  complete: (state, logits) => state.continuation ? host.resume!(state) : host.complete(state, logits!), reject: host.reject.bind(host),
74
85
  checkpoint(state, capture) {
75
86
  let snapshot: Cache[] | undefined;
76
87
  try {
77
88
  snapshot = capture();
78
- host.promptCache!.put(state.row.req.promptIds.slice(0, state.pos), snapshot, state.row.cacheNamespace);
89
+ host.promptCache!.put(state.row.req.promptIds.slice(0, state.pos), snapshot, state.row.cacheNamespace, undefined, undefined, state.row.req.cacheSessionId);
79
90
  snapshot = undefined;
80
91
  } catch (error) {
81
92
  if (snapshot) disposeResources(snapshot);
@@ -0,0 +1,26 @@
1
+ import type { ModelConfig } from "../../config";
2
+ import type { PrefillPolicy } from "../../inference/prefill";
3
+ import { sdpaFallbackBytes } from "../../kv-scheme";
4
+ import type { RuntimeConfig } from "../../runtime-config";
5
+
6
+ /** Retain large chunks for short prompts, but bound the materialized attention
7
+ * score workspace of recurrent-attention models at long context. This changes
8
+ * work size, never request admission. Explicit chunk settings remain exact. */
9
+ export function resolveMlxPrefillPolicy(config: ModelConfig, runtime: RuntimeConfig,
10
+ override?: number): PrefillPolicy {
11
+ const explicit = override ?? (runtime.value("MLX_BUN_RD_PREFILL_CHUNK") === undefined
12
+ ? undefined : runtime.number("MLX_BUN_RD_PREFILL_CHUNK", 2048));
13
+ if (explicit !== undefined) {
14
+ const chunk = Math.max(1, Math.floor(explicit));
15
+ return { chunkSize: () => chunk };
16
+ }
17
+ if (!config.text?.layerTypes?.includes("linear_attention")) return { chunkSize: () => 2048 };
18
+ // Capture geometry now, independently of later configuration mutation.
19
+ const geometry = { ...config, text: { ...config.text, layerTypes: [...config.text.layerTypes] } };
20
+ const workspaceBytes = 1024 ** 3;
21
+ return { chunkSize(promptTokens) {
22
+ let chunk = 2048;
23
+ while (chunk > 8 && sdpaFallbackBytes(geometry, chunk, promptTokens) > workspaceBytes) chunk /= 2;
24
+ return chunk;
25
+ } };
26
+ }
@@ -9,9 +9,23 @@ import type { MlxGroupPreparation, Row } from "./batch-group";
9
9
  import { evalCacheState } from "./prefill";
10
10
  import { prefillCacheLayout } from "./cache-layout";
11
11
  import { MlxStateRows } from "./state-rows";
12
+ import type { P2RTracePhase, P2RTraceAttributes } from "../../serve/prompt-response-trace";
13
+ import type { MlxForwardWork, MlxPreparationWork } from "./mixed-iteration";
12
14
  import type { KvMaintenance } from "./kv-maintenance";
13
15
 
16
+ // Shared work is recorded on every participating request. workId identifies
17
+ // duplicates when constructing a process timeline; row spans are not additive.
18
+ let nextWorkId = 0;
19
+ function traceRows(states: readonly MlxPrefillState[], phase: P2RTracePhase, attributes: P2RTraceAttributes) {
20
+ if (!states.some(state => state.row.req.trace)) return null;
21
+ const closes = states.map(state => state.row.req.trace?.begin(phase, attributes));
22
+ return { [Symbol.dispose]() { for (const close of closes) close?.(); } };
23
+ }
24
+
14
25
  export interface MlxPrefillStep extends PrefillStep {
26
+ /** A non-causal input cannot be split at a scheduler token budget.
27
+ * Its preparation must decline joins until this step completes. */
28
+ readonly atomic?: boolean;
15
29
  /** Some methods maintain their final target chunk before sampling. */
16
30
  readonly maintain?: boolean;
17
31
  }
@@ -34,7 +48,7 @@ export interface MlxPrefillRowsHost<State extends MlxPrefillState> {
34
48
  plan(state: State): MlxPrefillStep;
35
49
  /** Already prepared state can complete without any target forward. */
36
50
  ready?(state: State): boolean;
37
- forward(ids: MlxArray, caches: Cache[], states: readonly State[]): Promise<MlxArray>;
51
+ forward(ids: MlxArray, caches: Cache[], states: readonly State[], work?: MlxForwardWork): Promise<MlxArray>;
38
52
  project(hidden: MlxArray, caches: Cache[], completed: readonly State[]): MlxArray | null;
39
53
  /** Consume method context after target maintenance, before snapshots. */
40
54
  afterForward?(ids: MlxArray, caches: Cache[], states: readonly State[], hidden: MlxArray): void | Promise<void>;
@@ -105,7 +119,8 @@ export class MlxPrefillRows<State extends MlxPrefillState> implements MlxGroupPr
105
119
  for (const cache of this.#stateRows.caches) cache.prefillMaintenance?.beginPrefill();
106
120
  }
107
121
 
108
- async advance(): Promise<boolean> {
122
+ async advance(workLimit?: MlxPreparationWork): Promise<boolean> {
123
+ let remaining = workLimit?.maxTokens ?? Infinity;
109
124
  // Complete restored inputs before merging: their cache coverage must not
110
125
  // introduce padding or a zero-width forward into unrelated cold rows.
111
126
  if (this.operations.ready && this.#states.some(state => this.operations.ready!(state))) {
@@ -122,7 +137,10 @@ export class MlxPrefillRows<State extends MlxPrefillState> implements MlxGroupPr
122
137
  this.#states = remaining; this.#rows = remaining.map(state => state.row);
123
138
  if (!remaining.length) { this.dispose(); return true; }
124
139
  }
125
- this.#synchronizeRows();
140
+ {
141
+ using span = traceRows(this.#states, "prefill.row_sync", { workId: ++nextWorkId, batchSize: this.#states.length });
142
+ this.#synchronizeRows();
143
+ }
126
144
  const keep: number[] = [];
127
145
  for (const [index, state] of this.#states.entries()) {
128
146
  if (!state.row.req.signal?.aborted) { keep.push(index); continue; }
@@ -132,15 +150,23 @@ export class MlxPrefillRows<State extends MlxPrefillState> implements MlxGroupPr
132
150
  if (!this.#states.length) { this.dispose(); return true; }
133
151
  while (this.#states.length) {
134
152
  for (const state of this.#states) state.planned ??= this.operations.plan(state);
135
- const count = Math.min(...this.#states.map(state => state.planned!.end - state.pos));
153
+ const count = Math.min(this.#states.some(state => state.planned!.atomic) ? Infinity
154
+ : Math.max(1, Math.floor(remaining / this.#states.length)),
155
+ ...this.#states.map(state => state.planned!.end - state.pos));
156
+ const work = { workId: ++nextWorkId, batchSize: this.#states.length, tokensPerRow: count };
136
157
  const closes = this.#states.map(state => state.row.req.trace?.begin("prefill.chunk", {
137
- mechanism: "continuous", startToken: state.pos, batchSize: this.#states.length,
158
+ mechanism: "continuous", startToken: state.pos, ...work,
138
159
  }));
139
160
  const caches = this.#stateRows?.caches ?? this.#states[0]!.solo;
140
161
  try {
141
162
  using ids = count ? ops.fromInt32(this.#states.flatMap(state => state.row.req.promptIds.slice(state.pos, state.pos + count)),
142
163
  [this.#states.length, count]) : null;
143
- using hidden = ids ? await this.operations.forward(ids, caches, this.#states) : null;
164
+ let forwarded: MlxArray | null;
165
+ {
166
+ using span = traceRows(this.#states, "prefill.forward", work);
167
+ forwarded = ids ? await this.operations.forward(ids, caches, this.#states, workLimit?.forward) : null;
168
+ }
169
+ using hidden = forwarded;
144
170
  const drains: number[] = [], finals: number[] = [];
145
171
  let batchYield = false;
146
172
  for (const [row, state] of this.#states.entries()) {
@@ -152,25 +178,42 @@ export class MlxPrefillRows<State extends MlxPrefillState> implements MlxGroupPr
152
178
  }
153
179
  const maintained = [...drains, ...finals.filter(row => this.#states[row]!.planned!.maintain)];
154
180
  if (count && (maintained.length || !finals.length)) {
155
- evalCacheState(caches);
181
+ {
182
+ using span = traceRows(this.#states, "prefill.evaluate", work);
183
+ evalCacheState(caches);
184
+ }
185
+ using span = traceRows(this.#states, "prefill.kv_maintenance", work);
156
186
  if (this.#stateRows) {
157
187
  for (const cache of caches) cache.prefillMaintenance?.commitPrefill(maintained);
158
188
  } else if (maintained.length) this.operations.maintain?.(this.#states[0]!.solo);
159
- clearCache();
189
+ // A scheduler budget can split one planned chunk into many pieces.
190
+ // Resolve state at each yield, but retain allocator reuse until the
191
+ // method's maintenance boundary instead of purging it every piece.
192
+ if (maintained.length) clearCache();
160
193
  }
161
- if (ids) {
194
+ if (ids && this.operations.afterForward) {
195
+ using span = traceRows(this.#states, "prefill.companion", work);
162
196
  const companion = this.operations.afterForward?.(ids, caches, this.#states, hidden!);
163
197
  if (companion) await companion;
164
198
  }
165
199
  for (const row of [...drains, ...finals]) {
166
200
  const state = this.#states[row]!;
167
- if (state.planned!.snapshot) this.operations.checkpoint(state, () =>
168
- this.#stateRows ? this.#stateRows.extractRow(row) : cloneKvCaches(state.solo, this.operations.stateCodecs), row);
201
+ if (state.planned!.snapshot) {
202
+ using span = traceRows([state], "prefill.checkpoint", { ...work, row });
203
+ this.operations.checkpoint(state, () =>
204
+ this.#stateRows ? this.#stateRows.extractRow(row) : cloneKvCaches(state.solo, this.operations.stateCodecs), row);
205
+ }
169
206
  if (!finals.includes(row)) state.planned = undefined;
170
207
  }
171
- using logits = hidden && finals.length ? this.operations.project(hidden, caches, finals.map(row => this.#states[row]!)) : null;
208
+ let projected: MlxArray | null = null;
209
+ if (hidden && finals.length) {
210
+ using span = traceRows(this.#states, "prefill.project", work);
211
+ projected = this.operations.project(hidden, caches, finals.map(row => this.#states[row]!));
212
+ }
213
+ using logits = projected;
172
214
  for (const row of finals) {
173
215
  const state = this.#states[row]!;
216
+ using span = traceRows([state], "prefill.complete", { ...work, row });
174
217
  if (this.#stateRows) state.solo = this.#stateRows.extractRow(row);
175
218
  using part = logits ? logits.slice([row, logits.shape[1]! - 1, 0], [row + 1, logits.shape[1]!, logits.shape[2]!]) : null;
176
219
  try {
@@ -185,7 +228,8 @@ export class MlxPrefillRows<State extends MlxPrefillState> implements MlxGroupPr
185
228
  for (const cache of caches) (cache as { releaseRopeArr?: () => void }).releaseRopeArr?.();
186
229
  if (finals.length) this.#filter(this.#states.flatMap((_, row) => finals.includes(row) ? [] : [row]));
187
230
  if (!this.#states.length) { this.dispose(); return true; }
188
- if (batchYield || finals.length) return false;
231
+ remaining -= count * work.batchSize;
232
+ if (batchYield || finals.length || remaining < this.#states.length) return false;
189
233
  } finally { for (const close of closes) close?.(); }
190
234
  }
191
235
  return true;
@@ -0,0 +1,29 @@
1
+ import type { MlxArray } from "../../mlx/array";
2
+ import type { Cache } from "../../model/gemma4-base";
3
+
4
+ /** Prepared native input with an indivisible prefill. The preparation owner
5
+ * retains its tensors; the model binding returns an owned final hidden row.
6
+ * Decode, sampling and row retirement use the ordinary execution group. */
7
+ export interface MlxPromptInput {
8
+ forward(ids: MlxArray, caches: Cache[], start?: number): MlxArray;
9
+ readonly decodeState?: MlxDecodeState;
10
+ }
11
+
12
+ /** Additional model-owned forward state. Equal keys declare compatible row
13
+ * state; the backend supplies the current row order after joins/retirement. */
14
+ export interface MlxDecodeState {
15
+ readonly key: string;
16
+ forward(ids: MlxArray, caches: Cache[], rows: readonly MlxDecodeState[]): MlxArray;
17
+ }
18
+
19
+ /** Preserve the ordinary generator's projection geometry for a prepared
20
+ * embedding sequence: process the whole prompt, then project only its tip. */
21
+ export function bindEmbeddingsInput(
22
+ forward: (ids: MlxArray, caches: Cache[], start: number) => MlxArray,
23
+ ): MlxPromptInput {
24
+ return { forward(ids, caches, start = 0) {
25
+ using hidden = forward(ids, caches, start);
26
+ const [batch, length, width] = hidden.shape;
27
+ return hidden.slice([0, length! - 1, 0], [batch!, length!, width!]);
28
+ } };
29
+ }
@@ -0,0 +1,40 @@
1
+ import type { Qwen35Model } from "../../model/qwen3_5";
2
+ import { mropePositionIds, type MropeRequestState } from "../../model/qwen3-mrope";
3
+ import type { MlxArray } from "../../mlx/array";
4
+ import type { Cache } from "../../model/gemma4-base";
5
+ import * as ops from "../../mlx/ops";
6
+ import { Dtype } from "../../mlx/ffi";
7
+ import { bindEmbeddingsInput, type MlxDecodeState, type MlxPromptInput } from "./prompt-input";
8
+
9
+ class QwenMediaDecodeState implements MlxDecodeState {
10
+ readonly key = "qwen-interleaved-mrope";
11
+ constructor(readonly model: Qwen35Model, readonly delta: number) {}
12
+
13
+ forward(ids: MlxArray, caches: Cache[], rows: readonly MlxDecodeState[]): MlxArray {
14
+ const B = rows.length, L = ids.shape[1]!;
15
+ const cache = caches[this.model.faIdx]!;
16
+ // The cache supplies logical positions, including left padding, on device.
17
+ // No offset readback or request-global position mutation is needed.
18
+ const offsets = (cache as Cache & { ropeOffsetArr?: MlxArray }).ropeOffsetArr;
19
+ using deltas = ops.fromInt32(rows.map(row => (row as QwenMediaDecodeState).delta), [B]);
20
+ using scalar = offsets ? null : ops.fromInt32([cache.offset], []);
21
+ using starts = ops.add(offsets ?? scalar!, deltas);
22
+ using startRows = ops.reshape(starts, [B, 1]);
23
+ using indices = ops.arange(0, L, 1, Dtype.int32);
24
+ using positions = ops.add(startRows, indices);
25
+ using axis = ops.reshape(positions, [1, B, L]);
26
+ using grid = ops.concatAxis([axis, axis, axis], 0);
27
+ return this.model.forwardHiddenAtPositions(ids, caches, grid);
28
+ }
29
+ }
30
+
31
+ /** The prompt owns its grid; only its continuation delta survives into decode. */
32
+ export function bindQwenMediaInput(model: Qwen35Model, embeddings: MlxArray,
33
+ state: MropeRequestState): MlxPromptInput {
34
+ const input = bindEmbeddingsInput((ids, caches, start) => {
35
+ using positions = mropePositionIds(state, start, ids.shape[1]!);
36
+ return start > 0 ? model.forwardHiddenAtPositions(ids, caches, positions)
37
+ : model.forwardEmbeddingsAtPositions(embeddings, caches, positions);
38
+ });
39
+ return { ...input, decodeState: new QwenMediaDecodeState(model, state.delta) };
40
+ }