mlx-bun 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/README.md +57 -32
  2. package/docs/README.md +58 -42
  3. package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
  4. package/docs/archive/investigations/openjarvis-fit.md +198 -0
  5. package/docs/archive/investigations/qwen38-27b-campaign-log.md +1092 -0
  6. package/docs/archive/planning/release-notes-v0.4.0.md +45 -0
  7. package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
  8. package/docs/design/batching.md +1535 -50
  9. package/docs/design/decode-speed-program.md +394 -1171
  10. package/docs/design/generic-model-support.md +195 -3
  11. package/docs/design/kv-cache.md +538 -225
  12. package/docs/design/speculative-decoding.md +552 -50
  13. package/docs/design/turboquant.md +140 -17
  14. package/docs/design/unified-engine-frontier-plan.md +259 -150
  15. package/docs/planning/PRODUCT_ROADMAP.md +21 -6
  16. package/docs/planning/public-readiness-handoff.md +244 -0
  17. package/docs/planning/public-readiness.md +149 -0
  18. package/docs/reference/benchmarks.md +3537 -1112
  19. package/docs/reference/cli.md +102 -2
  20. package/docs/reference/distribution.md +45 -13
  21. package/docs/reference/environment.md +159 -13
  22. package/docs/reference/library-api.md +18 -4
  23. package/docs/reference/memory.md +1 -1
  24. package/docs/reference/models.md +28 -7
  25. package/docs/reference/server-api.md +257 -33
  26. package/docs/reference/server-config.md +372 -151
  27. package/docs/reference/troubleshooting.md +13 -21
  28. package/package.json +2 -2
  29. package/src/audio/audiotoolbox.ts +112 -0
  30. package/src/audio/conformer.ts +8 -0
  31. package/src/audio/mic-capture.ts +98 -0
  32. package/src/audio/silero-vad.ts +379 -0
  33. package/src/audio/whisper-decode.ts +774 -0
  34. package/src/audio/whisper-fast.ts +732 -0
  35. package/src/audio/whisper-format.ts +73 -0
  36. package/src/audio/whisper-mel.ts +185 -0
  37. package/src/audio/whisper-timing.ts +274 -0
  38. package/src/audio/whisper-tokenizer.ts +239 -0
  39. package/src/audio/whisper-transcribe.ts +410 -0
  40. package/src/backends/mlx/assistant-target.ts +46 -0
  41. package/src/backends/mlx/autoregressive-draft-rows.ts +120 -0
  42. package/src/backends/mlx/autoregressive.ts +22 -1
  43. package/src/backends/mlx/batch-group.ts +464 -236
  44. package/src/backends/mlx/cache-attachment.ts +67 -0
  45. package/src/backends/mlx/cache-layout.ts +31 -0
  46. package/src/backends/mlx/checkpoint-state.ts +53 -0
  47. package/src/backends/mlx/continuation-persistence.ts +107 -0
  48. package/src/backends/mlx/continuation-request.ts +43 -0
  49. package/src/backends/mlx/continuation.ts +77 -0
  50. package/src/backends/mlx/draft-target.ts +7 -22
  51. package/src/backends/mlx/encoder-cache.ts +25 -0
  52. package/src/backends/mlx/fill-append.ts +21 -8
  53. package/src/backends/mlx/fill-group.ts +497 -0
  54. package/src/backends/mlx/full-kv-row-append.ts +32 -0
  55. package/src/backends/mlx/full-kv-row-donor.ts +54 -0
  56. package/src/backends/mlx/full-transitioning-kv-rows.ts +65 -0
  57. package/src/backends/mlx/gateway-binding.ts +122 -19
  58. package/src/backends/mlx/kv-codec.ts +9 -0
  59. package/src/backends/mlx/kv-maintenance.ts +161 -0
  60. package/src/backends/mlx/kv-tensor-rows.ts +212 -0
  61. package/src/backends/mlx/mixed-iteration.ts +62 -0
  62. package/src/backends/mlx/model-prompt.ts +46 -32
  63. package/src/backends/mlx/model-serving.ts +5 -4
  64. package/src/backends/mlx/paged-cache-codec.ts +27 -0
  65. package/src/backends/mlx/prefill-cohort.ts +100 -0
  66. package/src/backends/mlx/prefill-policy.ts +26 -0
  67. package/src/backends/mlx/prefill-rows.ts +244 -0
  68. package/src/backends/mlx/prefill.ts +1 -1
  69. package/src/backends/mlx/projected-context-rows.ts +72 -0
  70. package/src/backends/mlx/prompt-input.ts +29 -0
  71. package/src/backends/mlx/qwen-prompt-input.ts +40 -0
  72. package/src/backends/mlx/request-state-policy.ts +54 -0
  73. package/src/backends/mlx/rollback.ts +45 -4
  74. package/src/backends/mlx/row-sampling.ts +49 -0
  75. package/src/backends/mlx/serial-executor.ts +51 -73
  76. package/src/backends/mlx/speculative-group.ts +438 -0
  77. package/src/backends/mlx/speculative-round.ts +161 -0
  78. package/src/backends/mlx/speculative.ts +24 -7
  79. package/src/backends/mlx/state-rows.ts +104 -0
  80. package/src/backends/mlx/state-views.ts +21 -1
  81. package/src/backends/mlx/target-layout-capability.ts +35 -0
  82. package/src/backends/mlx/transitioning-kv-rows.ts +173 -0
  83. package/src/cli.ts +333 -36
  84. package/src/contracts/execution.ts +21 -0
  85. package/src/contracts/object-cache.ts +8 -0
  86. package/src/contracts/pi-provider.ts +1 -1
  87. package/src/contracts/pi.ts +3 -1
  88. package/src/contracts/prefix-cache.ts +33 -0
  89. package/src/contracts/resources.ts +7 -0
  90. package/src/contracts/sampling.ts +23 -0
  91. package/src/contracts/scheduling.ts +27 -0
  92. package/src/engine/execution-plan.ts +38 -25
  93. package/src/engine/execution-tasks.ts +44 -0
  94. package/src/engine/namespaced-cache.ts +13 -0
  95. package/src/engine/resources.ts +12 -1
  96. package/src/engine/scheduler.ts +32 -7
  97. package/src/fill/fill-session.ts +14 -3
  98. package/src/fit.ts +29 -15
  99. package/src/generate.ts +105 -195
  100. package/src/grammar.ts +13 -0
  101. package/src/inference/draft-acceptance.ts +41 -0
  102. package/src/inference/draft-output.ts +49 -0
  103. package/src/inference/prefill.ts +6 -0
  104. package/src/inference/rollback.ts +2 -2
  105. package/src/kv-scheme.ts +92 -12
  106. package/src/kv-store.ts +183 -93
  107. package/src/lab/README.md +5 -1
  108. package/src/lab/paged-kv/paged-attention.ts +117 -0
  109. package/src/lab/paged-kv/paged-kv-rows.ts +111 -0
  110. package/src/lab/paged-kv/paged-kv.ts +126 -46
  111. package/src/md-modules.d.ts +5 -0
  112. package/src/mlx/array.ts +39 -1
  113. package/src/mlx/ffi.ts +19 -0
  114. package/src/mlx/normalized-argmax.ts +116 -0
  115. package/src/mlx/ops.ts +97 -10
  116. package/src/model/artifact-identity.ts +73 -0
  117. package/src/model/batched-kv.ts +235 -0
  118. package/src/model/batched-mask.ts +12 -4
  119. package/src/model/batched-quant.ts +8 -2
  120. package/src/model/batched-quantized-kv.ts +144 -0
  121. package/src/model/batched-rotating-quant.ts +76 -15
  122. package/src/model/batched-rotating-state.ts +76 -0
  123. package/src/model/batched-rotating.ts +64 -22
  124. package/src/model/batched-row-storage.ts +52 -0
  125. package/src/model/batched-ssm.ts +52 -0
  126. package/src/model/batched-turboquant-kv.ts +94 -0
  127. package/src/model/compiled-decode.ts +7 -1
  128. package/src/model/decoded-kv-donor.ts +12 -0
  129. package/src/model/delayed-quantized-kv.ts +72 -0
  130. package/src/model/delayed-rotating-quantized-kv.ts +93 -0
  131. package/src/model/delayed-turboquant-kv.ts +57 -0
  132. package/src/model/factory.ts +26 -0
  133. package/src/model/fingerprint.ts +12 -0
  134. package/src/model/full-prefill-padding.ts +56 -0
  135. package/src/model/full-prefill-row.ts +105 -0
  136. package/src/model/gemma4-base.ts +183 -82
  137. package/src/model/gemma4.ts +116 -28
  138. package/src/model/generated/gemma4-12b.ts +4 -1
  139. package/src/model/generated/gemma4-26b.ts +4 -1
  140. package/src/model/generated/gemma4-e4b.ts +4 -1
  141. package/src/model/glm52-cache.ts +80 -4
  142. package/src/model/glm52-mla.ts +12 -6
  143. package/src/model/glm52.ts +19 -40
  144. package/src/model/kv-attention-view.ts +70 -0
  145. package/src/model/minicpm5.ts +11 -7
  146. package/src/model/profile.ts +29 -4
  147. package/src/model/quantized-append-attention.ts +31 -0
  148. package/src/model/qwen3-delta-state.ts +74 -0
  149. package/src/model/qwen3-delta.ts +59 -36
  150. package/src/model/qwen3-mrope.ts +7 -9
  151. package/src/model/qwen3_5.ts +177 -75
  152. package/src/model/rotating-kv-donor.ts +35 -0
  153. package/src/model/rotating-kv-layout.ts +262 -0
  154. package/src/model/rotating-row-transaction.ts +35 -0
  155. package/src/model/speculative-rotating-kv.ts +101 -0
  156. package/src/model/ssm-prefill-padding.ts +39 -0
  157. package/src/model/support.ts +8 -0
  158. package/src/model/token-groups.ts +66 -0
  159. package/src/model/trellis-linear.ts +4 -4
  160. package/src/model/turboquant-codec.ts +89 -0
  161. package/src/model/universal/dense.ts +14 -13
  162. package/src/model/whisper.ts +435 -0
  163. package/src/native/mic_capture.swift +92 -0
  164. package/src/native-pack.ts +6 -5
  165. package/src/pi-web.ts +9 -1
  166. package/src/preflight.ts +31 -15
  167. package/src/prompt-cache.ts +306 -86
  168. package/src/runtime-config.ts +1 -5
  169. package/src/safetensors.ts +9 -2
  170. package/src/sampler.ts +37 -25
  171. package/src/serve/audio-routes.ts +323 -0
  172. package/src/serve/chat-request.ts +2 -0
  173. package/src/serve/chat-stage.ts +19 -14
  174. package/src/serve/completion-executor.ts +9 -1
  175. package/src/serve/discovery-routes.ts +17 -1
  176. package/src/serve/generated-token-history.ts +57 -0
  177. package/src/serve/generation-gateway.ts +88 -38
  178. package/src/serve/model-binding.ts +7 -1
  179. package/src/serve/model-host.ts +3 -4
  180. package/src/serve/preparation.ts +3 -3
  181. package/src/serve/prompt-response-trace.ts +14 -0
  182. package/src/serve/request-plan.ts +6 -3
  183. package/src/serve/request-prep.ts +40 -25
  184. package/src/serve/text-completion-stage.ts +2 -2
  185. package/src/serve/transcription-server.ts +64 -0
  186. package/src/serve/transcription-service.ts +502 -0
  187. package/src/server.ts +185 -261
  188. package/src/spec/assistant-rows.ts +93 -0
  189. package/src/spec/assistant-source.ts +28 -18
  190. package/src/spec/cache-identity.ts +22 -0
  191. package/src/spec/deepspec-rows.ts +29 -0
  192. package/src/spec/deepspec-source.ts +12 -10
  193. package/src/spec/dflash-source.ts +28 -4
  194. package/src/spec/drafter.ts +153 -142
  195. package/src/spec/dspark/deepspec-module.ts +95 -120
  196. package/src/spec/dspark/module-dflash.ts +126 -170
  197. package/src/spec/glm52-mtp-graph.ts +29 -0
  198. package/src/spec/glm52-mtp-rows.ts +132 -0
  199. package/src/spec/glm52-mtp-source.ts +54 -136
  200. package/src/spec/glm52-mtp-state.ts +33 -0
  201. package/src/spec/ngram-source.ts +115 -21
  202. package/src/spec/projected-draft-rows.ts +73 -0
  203. package/src/spec/qwen-mtp-module.ts +171 -0
  204. package/src/spec/qwen-mtp-rows.ts +230 -0
  205. package/src/spec/qwen-mtp-source.ts +84 -226
  206. package/src/spec/qwen-mtp-state.ts +30 -0
  207. package/src/spec/serve-loop.ts +73 -69
  208. package/src/spec/source.ts +93 -15
  209. package/src/spec/two-model-rows.ts +62 -0
  210. package/src/spec/two-model.ts +45 -103
  211. package/src/ssd-cache.ts +107 -36
  212. package/src/ssd-durability.ts +29 -27
  213. package/src/storage/host-buffer.ts +24 -0
  214. package/src/storage/kv-writer.ts +83 -0
  215. package/src/storage/kv-writer.worker.js +264 -0
  216. package/src/storage/retention-policy.ts +37 -0
  217. package/src/tiered-prompt-cache.ts +45 -0
  218. package/src/vision/embedder.ts +8 -0
  219. package/src/vision/prompt.ts +101 -65
  220. package/src/vision/qwen3vl-prompt.ts +101 -64
  221. package/src/vision/qwen3vl-tower.ts +11 -0
  222. package/src/vision/siglip.ts +8 -0
  223. package/src/web/app.html +12 -0
  224. package/src/web/app.js +217 -57
  225. package/src/web/src/chat.ts +4 -1
  226. package/src/web/src/composer.ts +3 -0
  227. package/src/web/src/voice.ts +141 -0
  228. package/src/web/sw.js +1 -1
  229. package/src/backends/mlx/memory-guard.ts +0 -40
  230. package/src/spec/prefix-state.ts +0 -45
  231. /package/docs/{planning → archive/planning}/release-notes-v0.3.0.md +0 -0
@@ -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
@@ -25,10 +25,11 @@ export function modelServingBinding(context: ServingContext): ModelServingBindin
25
25
  const embedding = isEmbeddingModel(model);
26
26
  return {
27
27
  stateCompatibility: `mlx-${MLX_VERSION}-${deviceArchitecture()}`,
28
- gateway: bindMlxGateway(model),
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
+ };
@@ -0,0 +1,100 @@
1
+ import type { Cache } from "../../model/gemma4-base";
2
+ import type { RuntimeModel } from "../../model/factory";
3
+ import type { MlxArray } from "../../mlx/array";
4
+ import { nextPrefillStep } from "../../inference/prefill";
5
+ import type { CacheCodecProvider } from "../../kv-store";
6
+ import { disposeResources, cleanupFailure } from "../../engine/resources";
7
+ import type { Row, RowPromptCache } from "./batch-group";
8
+ import type { KvMaintenance } from "./kv-maintenance";
9
+ import { MlxPrefillRows, type MlxPrefillState } from "./prefill-rows";
10
+
11
+ export interface PrefillState extends MlxPrefillState {
12
+ snapAt: number | null;
13
+ continuation?: import("./continuation").OrdinaryContinuationState;
14
+ closePrefill?: () => void;
15
+ }
16
+
17
+ interface PrefillHost {
18
+ model: RuntimeModel;
19
+ chunkSize: number;
20
+ tailSplit: boolean;
21
+ promptCache?: RowPromptCache;
22
+ stateCodecs?: CacheCodecProvider;
23
+ maintain?: KvMaintenance;
24
+ forward(ids: MlxArray, caches: Cache[]): Promise<MlxArray>;
25
+ /** Target projection preserves the forward batch geometry before sampling. */
26
+ project(hidden: MlxArray, caches: Cache[], completed: readonly PrefillState[]): MlxArray;
27
+ /** Borrows [1,1,V] logits; takes ownership of the completed request caches. */
28
+ complete(state: PrefillState, logits: MlxArray): Promise<void>;
29
+ resume?(state: PrefillState): Promise<void>;
30
+ reject(row: Row, error: unknown): void;
31
+ }
32
+
33
+ /** Ordinary sampling/cache policy over the shared target preparation driver. */
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); }
39
+ constructor(readonly host: PrefillHost) {
40
+ super({
41
+ stateCodecs: host.stateCodecs, maintain: host.maintain,
42
+ open(row) {
43
+ let owned: { caches: Cache[]; retain?: () => void } | undefined;
44
+ let closeCache: (() => void) | undefined;
45
+ let closePrefill: (() => void) | undefined;
46
+ try {
47
+ closeCache = row.req.trace?.begin("cache.lookup_restore", { mechanism: "continuous" });
48
+ const continuation = row.req.continuation?.restore(row.cacheNamespace ?? "");
49
+ if (continuation) {
50
+ owned = { caches: continuation.caches };
51
+ return { row, solo: continuation.caches, pos: row.req.promptIds.length,
52
+ snapAt: null, continuation };
53
+ }
54
+ const hit = host.promptCache?.take(row.req.promptIds, row.cacheNamespace, row.req.cacheSessionId) ?? null;
55
+ if (hit) owned = hit;
56
+ closeCache?.(); closeCache = undefined;
57
+ if (hit) row.cachedTokens = hit.tokens.length;
58
+ const length = row.req.promptIds.length;
59
+ const boundary = Math.min(row.req.snapshotAt ?? length, length - 1);
60
+ const snapAt = host.promptCache && boundary >= 256 && boundary > (hit?.tokens.length ?? 0) ? boundary : null;
61
+ closePrefill = row.req.trace?.begin("prefill.total", {
62
+ mechanism: "continuous", promptTokens: row.promptTokens, cachedTokens: row.cachedTokens,
63
+ });
64
+ const closeSetup = row.req.trace?.begin("prefill.batch_setup", { mechanism: "continuous" });
65
+ owned ??= { caches: row.req.statePolicy?.create() ?? host.model.makeCache() }; closeSetup?.();
66
+ if (row.req.promptInput) host.maintain?.(owned.caches);
67
+ return { row, solo: owned.caches, pos: hit?.tokens.length ?? 0, retain: owned.retain, snapAt, closePrefill };
68
+ } catch (error) {
69
+ return cleanupFailure(error, () => disposeResources([...(owned?.caches ?? []),
70
+ { dispose: () => owned?.retain?.() }, { dispose: () => closePrefill?.() }]));
71
+ } finally { closeCache?.(); }
72
+ },
73
+ ready: state => !!state.continuation,
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,
78
+ chunkSize: state.row.req.prefillChunkSize ?? host.chunkSize,
79
+ tailSplit: host.tailSplit, snapshotAt: state.snapAt }); },
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),
84
+ complete: (state, logits) => state.continuation ? host.resume!(state) : host.complete(state, logits!), reject: host.reject.bind(host),
85
+ checkpoint(state, capture) {
86
+ let snapshot: Cache[] | undefined;
87
+ try {
88
+ snapshot = capture();
89
+ host.promptCache!.put(state.row.req.promptIds.slice(0, state.pos), snapshot, state.row.cacheNamespace, undefined, undefined, state.row.req.cacheSessionId);
90
+ snapshot = undefined;
91
+ } catch (error) {
92
+ if (snapshot) disposeResources(snapshot);
93
+ console.warn(`batch-lane boundary snapshot skipped: ${(error as Error).message}`);
94
+ }
95
+ state.snapAt = null;
96
+ },
97
+ close(state) { const close = state.closePrefill; state.closePrefill = undefined; close?.(); },
98
+ });
99
+ }
100
+ }
@@ -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
+ }
@@ -0,0 +1,244 @@
1
+ import type { BatchableCache, Cache } from "../../model/gemma4-base";
2
+ import type { MlxArray } from "../../mlx/array";
3
+ import * as ops from "../../mlx/ops";
4
+ import { clearCache } from "../../mlx/ffi";
5
+ import type { PrefillStep } from "../../inference/prefill";
6
+ import { cloneKvCaches, type CacheCodecProvider } from "../../kv-store";
7
+ import { disposeResources, cleanupFailure } from "../../engine/resources";
8
+ import type { MlxGroupPreparation, Row } from "./batch-group";
9
+ import { evalCacheState } from "./prefill";
10
+ import { prefillCacheLayout } from "./cache-layout";
11
+ import { MlxStateRows } from "./state-rows";
12
+ import type { P2RTracePhase, P2RTraceAttributes } from "../../serve/prompt-response-trace";
13
+ import type { MlxForwardWork, MlxPreparationWork } from "./mixed-iteration";
14
+ import type { KvMaintenance } from "./kv-maintenance";
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
+
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;
29
+ /** Some methods maintain their final target chunk before sampling. */
30
+ readonly maintain?: boolean;
31
+ }
32
+
33
+ export interface MlxPrefillState {
34
+ row: Row;
35
+ solo: Cache[];
36
+ pos: number;
37
+ retain?: () => void;
38
+ planned?: MlxPrefillStep;
39
+ }
40
+
41
+ /** The method owns initialization, planning, companion state, snapshots and
42
+ * completion. This driver owns common target batching and work boundaries.
43
+ * Checkpoint factories return owned cache state; close/dispose are idempotent. */
44
+ export interface MlxPrefillRowsHost<State extends MlxPrefillState> {
45
+ stateCodecs?: CacheCodecProvider;
46
+ maintain?: KvMaintenance;
47
+ open(row: Row): State;
48
+ plan(state: State): MlxPrefillStep;
49
+ /** Already prepared state can complete without any target forward. */
50
+ ready?(state: State): boolean;
51
+ forward(ids: MlxArray, caches: Cache[], states: readonly State[], work?: MlxForwardWork): Promise<MlxArray>;
52
+ project(hidden: MlxArray, caches: Cache[], completed: readonly State[]): MlxArray | null;
53
+ /** Consume method context after target maintenance, before snapshots. */
54
+ afterForward?(ids: MlxArray, caches: Cache[], states: readonly State[], hidden: MlxArray): void | Promise<void>;
55
+ checkpoint(state: State, capture: () => Cache[], row: number): void;
56
+ complete(state: State, logits: MlxArray | null, row: number): Promise<void>;
57
+ reject(row: Row, error: unknown): void;
58
+ close(state: State): void;
59
+ filterRows?(keep: readonly number[]): void;
60
+ dispose?(): void;
61
+ }
62
+
63
+ /** One target preparation lifecycle at B1/B>1. Late joins retain each row's
64
+ * planned chunk endpoint, precision transition and checkpoint boundary. */
65
+ export class MlxPrefillRows<State extends MlxPrefillState> implements MlxGroupPreparation {
66
+ #states: State[] = [];
67
+ #rows: Row[] = [];
68
+ #stateRows?: MlxStateRows;
69
+ constructor(readonly operations: MlxPrefillRowsHost<State>) {}
70
+ get rows(): readonly Row[] { return this.#rows; }
71
+ get canAdmit(): boolean { return this.#rows.length > 0; }
72
+ /** Keep a request's prefill weight until retirement so a nearly completed
73
+ * request does not repeatedly attract another long prefill into its tail. */
74
+ get tokenWeight(): number { return this.#states.reduce((sum, state) => sum + state.row.promptTokens - state.row.cachedTokens, 0); }
75
+
76
+ admit(row: Row): void {
77
+ this.#states.push(this.operations.open(row));
78
+ this.#rows.push(row);
79
+ }
80
+
81
+ #release(state: State): void {
82
+ const caches = state.solo; state.solo = [];
83
+ const retain = state.retain; state.retain = undefined;
84
+ disposeResources([...caches, { dispose: () => retain?.() }, { dispose: () => this.operations.close(state) }]);
85
+ }
86
+ #filter(keep: number[]): void {
87
+ if (keep.length) this.#stateRows?.filterRows(keep);
88
+ else { const rows = this.#stateRows; this.#stateRows = undefined; rows?.dispose(); }
89
+ this.operations.filterRows?.(keep);
90
+ this.#states = keep.map(row => this.#states[row]!);
91
+ this.#rows = keep.map(row => this.#rows[row]!);
92
+ }
93
+ /** Admission changes membership only between forwards. Existing planned
94
+ * endpoints survive a join, including partially consumed drain chunks. */
95
+ #synchronizeRows(): void {
96
+ if (!this.#stateRows && this.#states.length === 1) return;
97
+ if (!this.#stateRows) {
98
+ for (const state of this.#states) this.operations.maintain?.preparePrefill?.(state.solo);
99
+ const layouts: BatchableCache[] = [];
100
+ try { for (const cache of this.#states[0]!.solo) layouts.push(prefillCacheLayout(cache)); }
101
+ catch (error) { return cleanupFailure(error, () => disposeResources(layouts)); }
102
+ this.#stateRows = new MlxStateRows(layouts);
103
+ this.#stateRows.mergeRows(this.#states.map(state => state.solo));
104
+ } else {
105
+ for (const state of this.#states.slice(this.#stateRows.rowCount)) {
106
+ this.operations.maintain?.preparePrefill?.(state.solo);
107
+ this.#stateRows.append(state.solo);
108
+ }
109
+ }
110
+ // Resolve input-backed copies before releasing SSD restore leases.
111
+ if (this.#states.some(state => state.solo.length)) {
112
+ evalCacheState(this.#stateRows.caches);
113
+ for (const state of this.#states) {
114
+ const caches = state.solo; state.solo = [];
115
+ const retain = state.retain; state.retain = undefined;
116
+ disposeResources([...caches, { dispose: () => retain?.() }]);
117
+ }
118
+ }
119
+ for (const cache of this.#stateRows.caches) cache.prefillMaintenance?.beginPrefill();
120
+ }
121
+
122
+ async advance(workLimit?: MlxPreparationWork): Promise<boolean> {
123
+ let remaining = workLimit?.maxTokens ?? Infinity;
124
+ // Complete restored inputs before merging: their cache coverage must not
125
+ // introduce padding or a zero-width forward into unrelated cold rows.
126
+ if (this.operations.ready && this.#states.some(state => this.operations.ready!(state))) {
127
+ const remaining: State[] = [];
128
+ for (const state of this.#states) {
129
+ if (!this.operations.ready(state)) { remaining.push(state); continue; }
130
+ try {
131
+ state.row.req.signal?.throwIfAborted();
132
+ await this.operations.complete(state, null, -1);
133
+ state.solo = []; state.retain = undefined;
134
+ } catch (error) { this.operations.reject(state.row, error); this.#release(state); }
135
+ finally { this.operations.close(state); }
136
+ }
137
+ this.#states = remaining; this.#rows = remaining.map(state => state.row);
138
+ if (!remaining.length) { this.dispose(); return true; }
139
+ }
140
+ {
141
+ using span = traceRows(this.#states, "prefill.row_sync", { workId: ++nextWorkId, batchSize: this.#states.length });
142
+ this.#synchronizeRows();
143
+ }
144
+ const keep: number[] = [];
145
+ for (const [index, state] of this.#states.entries()) {
146
+ if (!state.row.req.signal?.aborted) { keep.push(index); continue; }
147
+ this.#release(state); this.operations.reject(state.row, state.row.req.signal.reason);
148
+ }
149
+ if (keep.length !== this.#states.length) this.#filter(keep);
150
+ if (!this.#states.length) { this.dispose(); return true; }
151
+ while (this.#states.length) {
152
+ for (const state of this.#states) state.planned ??= this.operations.plan(state);
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 };
157
+ const closes = this.#states.map(state => state.row.req.trace?.begin("prefill.chunk", {
158
+ mechanism: "continuous", startToken: state.pos, ...work,
159
+ }));
160
+ const caches = this.#stateRows?.caches ?? this.#states[0]!.solo;
161
+ try {
162
+ using ids = count ? ops.fromInt32(this.#states.flatMap(state => state.row.req.promptIds.slice(state.pos, state.pos + count)),
163
+ [this.#states.length, count]) : 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;
170
+ const drains: number[] = [], finals: number[] = [];
171
+ let batchYield = false;
172
+ for (const [row, state] of this.#states.entries()) {
173
+ state.pos += count;
174
+ if (state.pos !== state.planned!.end) continue;
175
+ if (state.planned!.kind === "final") finals.push(row);
176
+ else drains.push(row);
177
+ batchYield ||= state.planned!.batchYield;
178
+ }
179
+ const maintained = [...drains, ...finals.filter(row => this.#states[row]!.planned!.maintain)];
180
+ if (count && (maintained.length || !finals.length)) {
181
+ {
182
+ using span = traceRows(this.#states, "prefill.evaluate", work);
183
+ evalCacheState(caches);
184
+ }
185
+ using span = traceRows(this.#states, "prefill.kv_maintenance", work);
186
+ if (this.#stateRows) {
187
+ for (const cache of caches) cache.prefillMaintenance?.commitPrefill(maintained);
188
+ } else if (maintained.length) this.operations.maintain?.(this.#states[0]!.solo);
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();
193
+ }
194
+ if (ids && this.operations.afterForward) {
195
+ using span = traceRows(this.#states, "prefill.companion", work);
196
+ const companion = this.operations.afterForward?.(ids, caches, this.#states, hidden!);
197
+ if (companion) await companion;
198
+ }
199
+ for (const row of [...drains, ...finals]) {
200
+ const state = this.#states[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
+ }
206
+ if (!finals.includes(row)) state.planned = undefined;
207
+ }
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;
214
+ for (const row of finals) {
215
+ const state = this.#states[row]!;
216
+ using span = traceRows([state], "prefill.complete", { ...work, row });
217
+ if (this.#stateRows) state.solo = this.#stateRows.extractRow(row);
218
+ using part = logits ? logits.slice([row, logits.shape[1]! - 1, 0], [row + 1, logits.shape[1]!, logits.shape[2]!]) : null;
219
+ try {
220
+ state.row.req.signal?.throwIfAborted();
221
+ await this.operations.complete(state, part, row);
222
+ state.solo = []; state.retain = undefined;
223
+ } catch (error) {
224
+ this.operations.reject(state.row, error);
225
+ this.#release(state);
226
+ } finally { this.operations.close(state); }
227
+ }
228
+ for (const cache of caches) (cache as { releaseRopeArr?: () => void }).releaseRopeArr?.();
229
+ if (finals.length) this.#filter(this.#states.flatMap((_, row) => finals.includes(row) ? [] : [row]));
230
+ if (!this.#states.length) { this.dispose(); return true; }
231
+ remaining -= count * work.batchSize;
232
+ if (batchYield || finals.length || remaining < this.#states.length) return false;
233
+ } finally { for (const close of closes) close?.(); }
234
+ }
235
+ return true;
236
+ }
237
+
238
+ dispose(): void {
239
+ const states = this.#states; this.#states = []; this.#rows = [];
240
+ const rows = this.#stateRows; this.#stateRows = undefined;
241
+ disposeResources([...(rows ? [rows] : []), ...states.map(state => ({ dispose: () => this.#release(state) })),
242
+ { dispose: () => this.operations.dispose?.() }]);
243
+ }
244
+ }
@@ -9,7 +9,7 @@ import { withResource } from "../../engine/resources";
9
9
  /** State views with explicit ownership are released even on deferred errors.
10
10
  * Owned views precede borrowed state, preserving the serial eval ordering. */
11
11
  export function evalCacheState(cache: Cache[]): void {
12
- withResource(leaseCacheStates(cache), (state) => ops.evalAll([...state]));
12
+ withResource(leaseCacheStates(cache), (state) => { if (state.length) ops.evalAll([...state]); });
13
13
  }
14
14
 
15
15
  /** Forward once. Drains evaluate state, apply KV maintenance, then clear the