mlx-bun 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/docs/README.md +4 -1
  2. package/docs/archive/investigations/openjarvis-fit.md +198 -0
  3. package/docs/archive/investigations/qwen38-27b-campaign-log.md +1092 -0
  4. package/docs/design/batching.md +1321 -49
  5. package/docs/design/decode-speed-program.md +331 -1163
  6. package/docs/design/generic-model-support.md +79 -3
  7. package/docs/design/kv-cache.md +198 -54
  8. package/docs/design/speculative-decoding.md +387 -24
  9. package/docs/design/turboquant.md +123 -13
  10. package/docs/design/unified-engine-frontier-plan.md +144 -142
  11. package/docs/planning/PRODUCT_ROADMAP.md +16 -0
  12. package/docs/planning/release-notes-v0.4.0.md +45 -0
  13. package/docs/reference/benchmarks.md +2457 -6
  14. package/docs/reference/cli.md +41 -2
  15. package/docs/reference/distribution.md +16 -9
  16. package/docs/reference/environment.md +55 -5
  17. package/docs/reference/models.md +13 -4
  18. package/docs/reference/server-api.md +79 -25
  19. package/docs/reference/server-config.md +178 -84
  20. package/docs/reference/troubleshooting.md +13 -21
  21. package/package.json +1 -1
  22. package/src/backends/mlx/assistant-target.ts +46 -0
  23. package/src/backends/mlx/autoregressive-draft-rows.ts +120 -0
  24. package/src/backends/mlx/batch-group.ts +368 -224
  25. package/src/backends/mlx/cache-attachment.ts +67 -0
  26. package/src/backends/mlx/cache-layout.ts +31 -0
  27. package/src/backends/mlx/checkpoint-state.ts +53 -0
  28. package/src/backends/mlx/continuation-persistence.ts +106 -0
  29. package/src/backends/mlx/continuation-request.ts +43 -0
  30. package/src/backends/mlx/continuation.ts +77 -0
  31. package/src/backends/mlx/draft-target.ts +7 -22
  32. package/src/backends/mlx/full-kv-row-append.ts +32 -0
  33. package/src/backends/mlx/full-kv-row-donor.ts +54 -0
  34. package/src/backends/mlx/full-transitioning-kv-rows.ts +65 -0
  35. package/src/backends/mlx/gateway-binding.ts +77 -18
  36. package/src/backends/mlx/kv-codec.ts +9 -0
  37. package/src/backends/mlx/kv-maintenance.ts +133 -0
  38. package/src/backends/mlx/kv-tensor-rows.ts +212 -0
  39. package/src/backends/mlx/model-serving.ts +1 -1
  40. package/src/backends/mlx/prefill-cohort.ts +89 -0
  41. package/src/backends/mlx/prefill-rows.ts +200 -0
  42. package/src/backends/mlx/prefill.ts +1 -1
  43. package/src/backends/mlx/projected-context-rows.ts +72 -0
  44. package/src/backends/mlx/request-state-policy.ts +41 -0
  45. package/src/backends/mlx/rollback.ts +41 -3
  46. package/src/backends/mlx/row-sampling.ts +49 -0
  47. package/src/backends/mlx/serial-executor.ts +35 -66
  48. package/src/backends/mlx/speculative-group.ts +359 -0
  49. package/src/backends/mlx/speculative-round.ts +161 -0
  50. package/src/backends/mlx/state-rows.ts +104 -0
  51. package/src/backends/mlx/state-views.ts +21 -1
  52. package/src/backends/mlx/target-layout-capability.ts +35 -0
  53. package/src/backends/mlx/transitioning-kv-rows.ts +165 -0
  54. package/src/cli.ts +11 -36
  55. package/src/contracts/execution.ts +11 -0
  56. package/src/contracts/prefix-cache.ts +23 -0
  57. package/src/contracts/resources.ts +7 -0
  58. package/src/contracts/sampling.ts +23 -0
  59. package/src/contracts/scheduling.ts +18 -0
  60. package/src/engine/execution-plan.ts +18 -16
  61. package/src/engine/resources.ts +12 -1
  62. package/src/engine/scheduler.ts +10 -5
  63. package/src/fit.ts +29 -15
  64. package/src/generate.ts +77 -173
  65. package/src/inference/draft-acceptance.ts +41 -0
  66. package/src/inference/draft-output.ts +49 -0
  67. package/src/inference/rollback.ts +2 -2
  68. package/src/kv-scheme.ts +70 -12
  69. package/src/kv-store.ts +70 -16
  70. package/src/lab/paged-kv/paged-kv-rows.ts +83 -0
  71. package/src/lab/paged-kv/paged-kv.ts +25 -11
  72. package/src/mlx/array.ts +2 -1
  73. package/src/model/artifact-identity.ts +73 -0
  74. package/src/model/batched-kv.ts +235 -0
  75. package/src/model/batched-mask.ts +12 -4
  76. package/src/model/batched-quant.ts +8 -2
  77. package/src/model/batched-quantized-kv.ts +144 -0
  78. package/src/model/batched-rotating-quant.ts +76 -15
  79. package/src/model/batched-rotating-state.ts +76 -0
  80. package/src/model/batched-rotating.ts +64 -22
  81. package/src/model/batched-row-storage.ts +52 -0
  82. package/src/model/batched-ssm.ts +52 -0
  83. package/src/model/batched-turboquant-kv.ts +93 -0
  84. package/src/model/compiled-decode.ts +7 -1
  85. package/src/model/decoded-kv-donor.ts +12 -0
  86. package/src/model/delayed-quantized-kv.ts +72 -0
  87. package/src/model/delayed-rotating-quantized-kv.ts +93 -0
  88. package/src/model/delayed-turboquant-kv.ts +55 -0
  89. package/src/model/full-prefill-padding.ts +56 -0
  90. package/src/model/full-prefill-row.ts +105 -0
  91. package/src/model/gemma4-base.ts +162 -80
  92. package/src/model/gemma4.ts +34 -26
  93. package/src/model/generated/gemma4-12b.ts +4 -1
  94. package/src/model/generated/gemma4-26b.ts +4 -1
  95. package/src/model/generated/gemma4-e4b.ts +4 -1
  96. package/src/model/glm52-cache.ts +80 -4
  97. package/src/model/glm52-mla.ts +12 -6
  98. package/src/model/glm52.ts +19 -40
  99. package/src/model/kv-attention-view.ts +67 -0
  100. package/src/model/minicpm5.ts +11 -7
  101. package/src/model/qwen3-delta-state.ts +74 -0
  102. package/src/model/qwen3-delta.ts +59 -36
  103. package/src/model/qwen3_5.ts +82 -42
  104. package/src/model/rotating-kv-donor.ts +35 -0
  105. package/src/model/rotating-kv-layout.ts +262 -0
  106. package/src/model/rotating-row-transaction.ts +35 -0
  107. package/src/model/speculative-rotating-kv.ts +101 -0
  108. package/src/model/ssm-prefill-padding.ts +39 -0
  109. package/src/model/trellis-linear.ts +2 -2
  110. package/src/model/turboquant-codec.ts +83 -0
  111. package/src/model/universal/dense.ts +14 -13
  112. package/src/preflight.ts +31 -15
  113. package/src/prompt-cache.ts +42 -44
  114. package/src/sampler.ts +36 -25
  115. package/src/serve/chat-stage.ts +2 -2
  116. package/src/serve/generation-gateway.ts +39 -30
  117. package/src/serve/model-host.ts +3 -4
  118. package/src/serve/request-plan.ts +4 -3
  119. package/src/serve/request-prep.ts +2 -4
  120. package/src/serve/text-completion-stage.ts +2 -2
  121. package/src/server.ts +64 -108
  122. package/src/spec/assistant-rows.ts +92 -0
  123. package/src/spec/assistant-source.ts +28 -18
  124. package/src/spec/cache-identity.ts +22 -0
  125. package/src/spec/deepspec-rows.ts +29 -0
  126. package/src/spec/deepspec-source.ts +12 -10
  127. package/src/spec/dflash-source.ts +28 -4
  128. package/src/spec/drafter.ts +153 -142
  129. package/src/spec/dspark/deepspec-module.ts +95 -120
  130. package/src/spec/dspark/module-dflash.ts +126 -170
  131. package/src/spec/glm52-mtp-graph.ts +29 -0
  132. package/src/spec/glm52-mtp-rows.ts +132 -0
  133. package/src/spec/glm52-mtp-source.ts +53 -136
  134. package/src/spec/glm52-mtp-state.ts +33 -0
  135. package/src/spec/ngram-source.ts +92 -20
  136. package/src/spec/projected-draft-rows.ts +73 -0
  137. package/src/spec/qwen-mtp-module.ts +171 -0
  138. package/src/spec/qwen-mtp-rows.ts +219 -0
  139. package/src/spec/qwen-mtp-source.ts +81 -226
  140. package/src/spec/qwen-mtp-state.ts +30 -0
  141. package/src/spec/serve-loop.ts +70 -68
  142. package/src/spec/source.ts +80 -15
  143. package/src/spec/two-model-rows.ts +61 -0
  144. package/src/spec/two-model.ts +43 -101
  145. package/src/ssd-cache.ts +33 -21
  146. package/src/ssd-durability.ts +11 -7
  147. package/src/web/app.js +57 -57
  148. package/src/backends/mlx/memory-guard.ts +0 -40
  149. package/src/spec/prefix-state.ts +0 -45
  150. /package/docs/{planning → archive/planning}/release-notes-v0.3.0.md +0 -0
package/src/generate.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  import { appendFileSync } from "node:fs";
11
11
  import { MlxArray, gpuStream } from "./mlx/array";
12
12
  import {
13
+ Dtype,
13
14
  activeMemory,
14
15
  clearCache,
15
16
  maxRecommendedWorkingSetSize,
@@ -18,8 +19,9 @@ import {
18
19
  synchronize,
19
20
  } from "./mlx/ffi";
20
21
  import * as ops from "./mlx/ops";
21
- import { KVCache, RotatingKVCache, TurboQuantKVCache, type Cache } from "./model/gemma4";
22
- import { PagedKVCache } from "./lab/paged-kv/paged-kv";
22
+ import { KVCache, RotatingKVCache, type Cache } from "./model/gemma4";
23
+ import { maybePageKv } from "./backends/mlx/request-state-policy";
24
+ export { maybePageKv } from "./backends/mlx/request-state-policy";
23
25
  import { DiffusionGemmaModel } from "./model/diffusion-gemma";
24
26
  import { denoiseAsync } from "./diffusion/diffusion-generate";
25
27
  import { bindLegacyDenoisingModel, type MlxDenoisingBinding } from "./backends/mlx/diffusion";
@@ -28,6 +30,7 @@ import {
28
30
  assertMlxAutoregressiveBinding, bindLegacyAutoregressiveModel,
29
31
  type MlxAutoregressiveBinding, type MlxModelMemory, type MlxDecodeStep, type MlxTokenAppend,
30
32
  } from "./backends/mlx/autoregressive";
33
+ import { createKvMaintenance } from "./backends/mlx/kv-maintenance";
31
34
  import { nextPrefillStep } from "./inference/prefill";
32
35
  import { evalCacheState, executeMlxPrefillStep } from "./backends/mlx/prefill";
33
36
  import { appendFillHidden } from "./backends/mlx/fill-append";
@@ -40,6 +43,7 @@ import { runtimeConfig, runtimeValue, withRuntimeConfig, type RuntimeConfig } fr
40
43
  import {
41
44
  disposeStepExtras,
42
45
  makeStepSampler,
46
+ readStepExtras,
43
47
  stepExtrasArrays,
44
48
  type LogitsProcessorOptions,
45
49
  type SamplerOptions,
@@ -57,8 +61,6 @@ import {
57
61
  } from "./fill/fill-session";
58
62
 
59
63
  export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions {
60
- /** RAM budget for a model-owned paired target/draft prompt snapshot. */
61
- speculativeCacheBytes?: number;
62
64
  /** Resolved host policy. Direct compatibility calls resolve from their
63
65
  * captured binding when this is absent. */
64
66
  decodePolicy?: Readonly<Pick<import("./contracts/execution").ResolvedExecution, "compiledDecode" | "grammarJump">>;
@@ -121,14 +123,13 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
121
123
  * Caller keeps ownership. */
122
124
  multimodalMask?: MlxArray;
123
125
  /** Quantize full-attention KV caches to this many bits (4 or 8).
124
- * Rotating (sliding-window) caches stay bf16 — they're window-capped
125
- * and upstream rotating-cache quantization is NYI. */
126
+ * Full-attention and rotating caches both convert when populated. */
126
127
  kvBits?: number;
127
128
  kvGroupSize?: number;
128
129
  /** Per-layer mixed-precision KV from kv_config.json (config.kvQuant).
129
130
  * Overrides kvBits, like optiq serve's --kv-config. layerIdx indexes
130
- * the cache list (== layer index for the donor prefix); entries for
131
- * rotating/sliding caches are skipped until Phase 9. */
131
+ * the cache list (== layer index for the donor prefix), including
132
+ * rotating/sliding caches. */
132
133
  kvConfig?: KvQuantSpec[];
133
134
  /** Convert once a cache's offset reaches this (uniform-kvBits default
134
135
  * 5000 = mlx-lm; kvConfig default 0 = optiq serve). */
@@ -136,14 +137,14 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
136
137
  /** TurboQuant scheme (docs/design/turboquant.md): rotation-based KV
137
138
  * quantization, a CLI-only runtime lever in the same class as uniform
138
139
  * kvBits (mutually exclusive with kvBits/kvConfig — maybeQuantizeKv
139
- * checks kvBits/kvConfig first, so set at most one). Full-attention
140
+ * checks turboQuant first, so set at most one). Full-attention
140
141
  * KVCache layers convert via TurboQuantKVCache.fromKVCache;
141
142
  * RotatingKVCache (sliding-window) layers stay bf16 in v1 — a one-time
142
143
  * warning names the limitation, never a throw. */
143
144
  turboQuant?: TurboQuantScheme;
144
145
  /** OPTIONAL paged KV storage (docs/design/kv-cache.md): fresh
145
146
  * full-attention KVCache layers are replaced with PagedKVCache (block
146
- * pool + gather-to-contiguous) before prefill. v1 scope: serial batch=1
147
+ * pool + gather-to-contiguous) before prefill. Scope: B1/B>1
147
148
  * Gemma4-family, bf16 — mutually exclusive with kvBits/kvConfig/
148
149
  * turboQuant/draft/compiled decode (callers refuse the combos; the
149
150
  * cache swap itself only ever touches plain empty KVCache entries).
@@ -230,88 +231,12 @@ export function shouldUseFill(
230
231
  options.turboQuant === undefined;
231
232
  }
232
233
 
233
- /** Port of mlx-lm maybe_quantize_kv_cache + BOTH halves of optiq serve's
234
- * per-layer patched variant (incl. patch_rotating_to_quantized: rotating
235
- * caches convert too — Phase 9):
236
- * - per-layer bits/group_size selection (kvConfig overrides kvBits,
237
- * matching optiq's --kv-config precedence; shipped kv_config.json
238
- * files cover EVERY cache-owning layer, sliding ones included —
239
- * verified 12B 48/48, 26B 30/30, e4b 24/24 distinct caches — so
240
- * rotating quantization engages straight from the config; uniform
241
- * kvBits — like optiq --kv-bits — reaches them too), and
242
- * - STREAMING conversion (optiq streaming_kv_quant / serve.py
243
- * patched_maybe_quantize): eval each layer's quantized triples and
244
- * clear the buffer pool before building the next layer's conversion.
245
- * Lazily batching every layer's toQuantized into one eval pins ALL
246
- * layers' bf16 K/V as graph inputs alongside ALL quantized outputs —
247
- * the exact transient optiq's fix kills (16.35 → 7.60 GB at 32k on a
248
- * 24 GB Mac). Numerics untouched: same quantize math, only the eval
249
- * ordering is forced (tests/parity/kv-quant.test.ts, tests/parity/rotating-kvq.test.ts). */
234
+ /** Compatibility entry point for callers that perform a single conversion.
235
+ * Execution sessions compose createKvMaintenance once and reuse it. */
250
236
  export function maybeQuantizeKv(cache: Cache[], options: GenerateOptions): void {
251
- const { kvBits, kvConfig, turboQuant } = options;
252
- if (turboQuant) {
253
- maybeTurboQuantizeKv(cache, turboQuant, options.quantizedKvStart ?? 0);
254
- return;
255
- }
256
- if (!kvBits && !kvConfig?.length) return;
257
- const start = options.quantizedKvStart ?? (kvConfig?.length ? 0 : 5000);
258
- const byLayer = kvConfig?.length
259
- ? new Map(kvConfig.map((e) => [e.layerIdx, e]))
260
- : null;
261
- for (let i = 0; i < cache.length; i++) {
262
- const c = cache[i]!;
263
- if (!(c instanceof KVCache || c instanceof RotatingKVCache) || c.offset < start) continue;
264
- // OptiQ's mixed-KV hook skips empty caches: the first prompt prefill
265
- // runs bf16, then the populated cache is quantized before decode.
266
- // Converting empty caches at start=0 makes prefill itself quantized
267
- // and diverges from the oracle path.
268
- if (c.offset === 0) continue;
269
- if (byLayer) {
270
- const e = byLayer.get(i);
271
- if (!e) continue;
272
- cache[i] = c.toQuantized(e.groupSize, e.bits);
273
- } else {
274
- cache[i] = c.toQuantized(options.kvGroupSize ?? 64, kvBits!);
275
- }
276
- // Streaming half: materialize THIS layer's conversion now so its bf16
277
- // source (already released by toQuantized) frees before the next layer
278
- // converts — the transient stays ~one layer, not the whole cache.
279
- ops.evalAll(cache[i]!.state());
280
- clearCache();
281
- }
282
- }
283
-
284
- /** Paged-KV conversion (docs/design/kv-cache.md): swap each FRESH
285
- * plain full-attention KVCache for a PagedKVCache sized to hold
286
- * `capacityTokens` (prompt + maxTokens — known exactly at generate()
287
- * setup, so pool exhaustion is unreachable absent an accounting bug).
288
- * Same in-place-mutation shape as maybeQuantizeKv, but runs ONCE before
289
- * prefill: paging changes storage layout, not arithmetic, so there is no
290
- * "convert when populated" trigger. Sliding-window (RotatingKVCache)
291
- * layers keep today's scheme — mixed paged-full + rotating-sliding is
292
- * the supported v1 shape. Pre-warmed caches (offset > 0) skip conversion
293
- * entirely: the serve lane bypasses prompt-cache reuse for paged
294
- * requests, so this only guards library callers. */
295
- export function maybePageKv(
296
- cache: Cache[], options: GenerateOptions, capacityTokens: number,
297
- ): void {
298
- if (!options.pagedKv) return;
299
- if (cache.some((c) => c.offset > 0)) return;
300
- const blockSize = options.pagedKv.blockSize ?? PagedKVCache.DEFAULT_BLOCK_SIZE;
301
- for (let i = 0; i < cache.length; i++) {
302
- if (cache[i] instanceof KVCache) {
303
- cache[i]!.dispose(); // fresh (offset 0) — nothing stored yet
304
- cache[i] = new PagedKVCache(capacityTokens, blockSize);
305
- }
306
- }
237
+ createKvMaintenance(options)(cache);
307
238
  }
308
239
 
309
- /** Emitted once per process: RotatingKVCache (sliding-window) layers are a
310
- * documented v1 non-goal (docs/design/turboquant.md) — they stay bf16
311
- * rather than throwing, so mixed full-attention/sliding-window models (e.g.
312
- * Gemma) still serve correctly under --kv-quant turbo. */
313
- let warnedTurboRotating = false;
314
-
315
240
  /** Emitted once per process: token fast-forwarding skips models with
316
241
  * sliding-window layers in v1 (see the gate in generateInner). */
317
242
  let warnedFillRotating = false;
@@ -330,39 +255,6 @@ type RewindableCache = {
330
255
  };
331
256
  const rewindable = (c: unknown): RewindableCache => c as RewindableCache;
332
257
 
333
- /** TurboQuant conversion chokepoint (mirrors the uniform/config branch
334
- * above): only plain full-attention KVCache instances convert, via
335
- * TurboQuantKVCache.fromKVCache — RotatingKVCache stays bf16 (warn once,
336
- * never throw). Same offset===0 skip-empty-cache rule as the affine path. */
337
- function maybeTurboQuantizeKv(cache: Cache[], scheme: TurboQuantScheme, start: number): void {
338
- for (let i = 0; i < cache.length; i++) {
339
- const c = cache[i]!;
340
- if (c instanceof RotatingKVCache) {
341
- if (!warnedTurboRotating) {
342
- warnedTurboRotating = true;
343
- console.warn(
344
- "[turbo-quant] sliding-window (RotatingKVCache) layers stay bf16 in v1 " +
345
- "(full-attention only) — docs/design/turboquant.md.",
346
- );
347
- }
348
- continue;
349
- }
350
- if (!(c instanceof KVCache) || c.offset < start || c.offset === 0) continue;
351
- const tq = TurboQuantKVCache.fromKVCache(c, scheme.kBits, scheme.vBits);
352
- cache[i] = tq;
353
- // state() allocates fresh trimmed slice views for this cache kind
354
- // (see evalCacheState) — dispose after materializing (throw included),
355
- // or they leak.
356
- const state = tq.state();
357
- try {
358
- ops.evalAll(state);
359
- } finally {
360
- for (const a of state) a.dispose();
361
- }
362
- clearCache();
363
- }
364
- }
365
-
366
258
  export interface GenerateStats {
367
259
  promptTokens: number;
368
260
  /** An observed terminal cause takes precedence over the token-count limit. */
@@ -455,9 +347,11 @@ export class Generation implements AsyncIterable<GeneratedToken> {
455
347
  // Scope semantics match the reference: set → generate → synchronize →
456
348
  // restore; nothing stays pinned between generations. Re-entrant: only
457
349
  // the outermost wiring scope touches the limit.
458
- // macOS 26.6 reports a 24.96 GiB recommended set on this 24 GB machine.
459
- // The old 0.75 fraction therefore stopped wiring the 13-16 GiB Qwen/GLM
460
- // models even though they page heavily without an explicit wired limit.
350
+ // macOS 26.6 reports a 26.8e9 B (24.96 GiB, 0.78 × RAM) recommended set on
351
+ // a 32 GB M1 Max (2026-09-08; the earlier "24 GB machine" attribution was
352
+ // wrong — 24.96 GiB exceeds a 24 GB box's RAM). At 0.75 × that the old
353
+ // fraction stopped wiring the 13-16 GiB Qwen/GLM models even though they
354
+ // page heavily without an explicit wired limit.
461
355
  // Keep smaller 8-9 GiB models unwired while covering the large-model class.
462
356
  const WIRE_THRESHOLD = 0.5;
463
357
  let wiredScopeDepth = 0;
@@ -719,6 +613,7 @@ async function* generateInner(
719
613
  throw error;
720
614
  }
721
615
  const graph = binding.graph;
616
+ const maintainKv = createKvMaintenance(options);
722
617
 
723
618
  // logprobs capture (mlx_lm.server semantics — see GenerateOptions.logprobs).
724
619
  // Everything below is gated: when neither flag is set, no extra ops, evals,
@@ -797,25 +692,6 @@ async function* generateInner(
797
692
  (c) => c.isTrimmable() || typeof rewindable(c).specRoundRollback === "function",
798
693
  );
799
694
  closeBatchSetup?.();
800
- /** Device-side logprob capture for one step, computed from the SAME lp the
801
- * sampler saw (post-processors, pre-truncation) — read back lazily with the
802
- * token so decode pipelining is preserved. */
803
- /** Read extras back to JS (forces eval — they were async-dispatched with the
804
- * token) without appending casts behind the next decode step. */
805
- const readExtras = (e: StepExtras | null): TokenLogprobs | undefined => {
806
- if (!e) return undefined;
807
- const out: TokenLogprobs = {};
808
- if (e.sel) out.logprob = e.sel.toFloat32Host()[0]!;
809
- if (e.topIdx && e.topVals) {
810
- const ids = e.topIdx.toIntTokens();
811
- const vals = e.topVals.toFloat32Host();
812
- out.top = Array.from(ids, (id, i) => ({ id, logprob: vals[i]! })).sort(
813
- (a, b) => b.logprob - a.logprob,
814
- );
815
- }
816
- disposeStepExtras(e);
817
- return out;
818
- };
819
695
 
820
696
  // logits [1,1,V] → sampled token array [1] (+ optional logprob capture,
821
697
  // all on-device)
@@ -848,6 +724,7 @@ async function* generateInner(
848
724
  let finished = false;
849
725
  let threw = false;
850
726
  let executionError: unknown;
727
+ let earlyUnforwardedToken: number | null = null;
851
728
  let closeTokenZero: (() => void) | undefined;
852
729
  const makeStats = (): GenerateStats => ({
853
730
  promptTokens: options.originalPromptTokens ?? promptTokens.length,
@@ -948,7 +825,7 @@ async function* generateInner(
948
825
  nextExtras = null;
949
826
  // No-op under the fill exclusions (kv quant refuses fill); kept so the
950
827
  // boundary stays correct if those exclusions ever loosen.
951
- maybeQuantizeKv(cache, options);
828
+ maintainKv(cache);
952
829
 
953
830
  let checkpointMs = 0;
954
831
  let roundOpen = false;
@@ -1061,7 +938,10 @@ async function* generateInner(
1061
938
  // pending token was sampled from that exact state before the snapshot,
1062
939
  // so resume starts directly at the decode loop without another forward.
1063
940
  if (needsTokenHistory) stepSampler.seedHistory(promptTokens);
1064
- pending = ops.fromInt32([options.initialPendingToken!], [1]);
941
+ // Device samplers return uint32. Preserve that signature on restore so
942
+ // compiled decode does not trace a different signed-token graph first.
943
+ using restoredToken = ops.fromInt32([options.initialPendingToken!], [1]);
944
+ pending = restoredToken.astype(Dtype.uint32);
1065
945
  ops.asyncEvalAll([pending]);
1066
946
  prefillMs = 0;
1067
947
  closePrefill?.();
@@ -1072,7 +952,7 @@ async function* generateInner(
1072
952
  mechanism: diagnostics.mechanism ?? "serial",
1073
953
  boundary: "initial",
1074
954
  });
1075
- maybeQuantizeKv(cache, options);
955
+ maintainKv(cache);
1076
956
  closeInitialKv?.();
1077
957
  tPrefill = performance.now();
1078
958
  let h0: MlxArray;
@@ -1096,7 +976,7 @@ async function* generateInner(
1096
976
  : undefined;
1097
977
  const tailSplit = runtime.flag("MLX_BUN_PREFILL_TAIL_SPLIT", true);
1098
978
  const forward = graph.forwardHidden.bind(graph);
1099
- const maintain = () => maybeQuantizeKv(cache, options);
979
+ const maintain = () => maintainKv(cache);
1100
980
  while (true) {
1101
981
  options.signal?.throwIfAborted();
1102
982
  const step = nextPrefillStep({ length: promptTokens.length, position: pos,
@@ -1186,14 +1066,16 @@ async function* generateInner(
1186
1066
  closeTokenZero?.();
1187
1067
  closeTokenZero = undefined;
1188
1068
  tDecode = performance.now();
1189
- const logprobs = readExtras(curExtras);
1069
+ const logprobs = readStepExtras(curExtras);
1190
1070
  curExtras = null;
1191
1071
  pendingExtras = null;
1192
1072
  // Count before yielding so an early return reports this token. Its
1193
1073
  // KV is still absent; forwarded describes the prompt-only cache.
1194
1074
  generated++;
1195
1075
  yieldedEarly = true;
1076
+ earlyUnforwardedToken = firstToken;
1196
1077
  yield { token: firstToken, index: stepIndex, ...(logprobs ? { logprobs } : {}) };
1078
+ earlyUnforwardedToken = null;
1197
1079
  options.signal?.throwIfAborted();
1198
1080
  }
1199
1081
  }
@@ -1242,7 +1124,7 @@ async function* generateInner(
1242
1124
  // contract); the next sampled token, if the budget and grammar allow
1243
1125
  // one, comes from its last position. Compiled decode resumes on the
1244
1126
  // following iteration (supports() re-checks the grown caches).
1245
- maybeQuantizeKv(cache, options);
1127
+ maintainKv(cache);
1246
1128
  pushHistory(cur);
1247
1129
  stepSampler.commitNumbers(jumpEmit);
1248
1130
  const chunk = [grammarTok, ...jumpEmit];
@@ -1267,7 +1149,7 @@ async function* generateInner(
1267
1149
  h.dispose(); // burst ends the generation (max_tokens or grammar done)
1268
1150
  }
1269
1151
  } else if (stepIndex + 1 < maxTokens && !options.grammar?.isTerminated) {
1270
- maybeQuantizeKv(cache, options);
1152
+ maintainKv(cache);
1271
1153
  pushHistory(cur);
1272
1154
  let logits: MlxArray | null = null;
1273
1155
  let evalWith: MlxArray[] = [];
@@ -1347,7 +1229,7 @@ async function* generateInner(
1347
1229
  }
1348
1230
  // readExtras before the yield: if the consumer breaks at this yield,
1349
1231
  // the extras are already read and disposed.
1350
- const logprobs = readExtras(curExtras);
1232
+ const logprobs = readStepExtras(curExtras);
1351
1233
  pendingExtras = null;
1352
1234
  if (!yieldedEarly) yield { token, index: generated - 1, ...(logprobs ? { logprobs } : {}) };
1353
1235
  options.signal?.throwIfAborted();
@@ -1412,31 +1294,53 @@ async function* generateInner(
1412
1294
  executionError = e;
1413
1295
  throw e;
1414
1296
  } finally {
1415
- if (!finished) {
1416
- pending?.dispose();
1417
- nextPending?.dispose();
1418
- disposeStepExtras(pendingExtras);
1419
- disposeStepExtras(nextExtras);
1420
- }
1421
1297
  try {
1422
- const closing = decoder?.close();
1423
- if (closing) await closing;
1298
+ // An early consumer return leaves token zero outside the retained KV.
1299
+ // Finish that one M=1 forward before handing caller-owned caches back,
1300
+ // matching the ordinary pipeline's boundary. Otherwise a later prefix
1301
+ // hit folds the token into an M>1 prefill and changes native reductions.
1302
+ // Aborted requests and caches owned by this run need no retained state.
1303
+ if (!ownsCache && !threw && !options.signal?.aborted && earlyUnforwardedToken !== null) {
1304
+ maintainKv(cache);
1305
+ const ids = ops.reshape(pending!, [1, 1]);
1306
+ try {
1307
+ const hidden = await graph.forwardHidden(ids, cache);
1308
+ try { evalCacheState(cache); }
1309
+ finally { hidden.dispose(); }
1310
+ } finally { ids.dispose(); }
1311
+ forwarded.push(earlyUnforwardedToken);
1312
+ }
1424
1313
  } catch (error) {
1425
- if (threw) throw new AggregateError([executionError, error],
1426
- "AR execution and decoder cleanup failed", { cause: executionError });
1314
+ threw = true;
1315
+ executionError = error;
1427
1316
  throw error;
1428
1317
  } finally {
1429
- if (ownsCache) for (const c of cache) c.dispose();
1430
- // TokenizerInfo is process-cached; this request owns the matcher.
1431
- options.grammar?.dispose();
1432
- stepSampler.dispose();
1433
- }
1434
- if (!finished && !threw) {
1435
- // forced early return (consumer break at a yield): still report
1436
- // stats — `forwarded` only lists tokens whose KV actually entered
1437
- // the cache, so cacheTokens stays exact for PromptCache.put().
1438
- decodeMs = performance.now() - tDecode;
1439
- return makeStats();
1318
+ if (!finished) {
1319
+ pending?.dispose();
1320
+ nextPending?.dispose();
1321
+ disposeStepExtras(pendingExtras);
1322
+ disposeStepExtras(nextExtras);
1323
+ }
1324
+ try {
1325
+ const closing = decoder?.close();
1326
+ if (closing) await closing;
1327
+ } catch (error) {
1328
+ if (threw) throw new AggregateError([executionError, error],
1329
+ "AR execution and decoder cleanup failed", { cause: executionError });
1330
+ throw error;
1331
+ } finally {
1332
+ if (ownsCache) for (const c of cache) c.dispose();
1333
+ // TokenizerInfo is process-cached; this request owns the matcher.
1334
+ options.grammar?.dispose();
1335
+ stepSampler.dispose();
1336
+ }
1337
+ if (!finished && !threw) {
1338
+ // forced early return (consumer break at a yield): still report
1339
+ // stats — `forwarded` only lists tokens whose KV actually entered
1340
+ // the cache, so cacheTokens stays exact for PromptCache.put().
1341
+ decodeMs = performance.now() - tDecode;
1342
+ return makeStats();
1343
+ }
1440
1344
  }
1441
1345
  }
1442
1346
  }
@@ -0,0 +1,41 @@
1
+ /** One request's exact-token speculative acceptance walk. The backend owns
2
+ * logits and sampling; this state owns only the accepted prefix and stop facts.
3
+ * A group advances each live walk independently, regardless of batch size. */
4
+ export class DraftAcceptance {
5
+ position = 0;
6
+ accepted = 0;
7
+ correction: number | null = null;
8
+ readonly emitted: number[] = [];
9
+ sawEos = false;
10
+ grammarDone = false;
11
+ done = false;
12
+
13
+ constructor(
14
+ readonly drafts: readonly number[],
15
+ readonly remaining: number,
16
+ readonly eosTokenIds: readonly number[],
17
+ ) {}
18
+
19
+ /** Supply one target sample after its sampler has updated request history
20
+ * and grammar. Once done, this request needs no further target samples. */
21
+ accept(token: number, grammarDone = false): void {
22
+ const matches = this.position < this.drafts.length && token === this.drafts[this.position];
23
+ this.position++;
24
+ if (matches) {
25
+ this.accepted++;
26
+ if (this.eosTokenIds.includes(token)) {
27
+ this.sawEos = this.done = true;
28
+ return;
29
+ }
30
+ this.emitted.push(token);
31
+ this.grammarDone = grammarDone;
32
+ this.done = grammarDone || this.emitted.length >= this.remaining;
33
+ return;
34
+ }
35
+ this.correction = token;
36
+ this.sawEos = this.eosTokenIds.includes(token);
37
+ if (!this.sawEos) this.emitted.push(token);
38
+ this.grammarDone = grammarDone;
39
+ this.done = true;
40
+ }
41
+ }
@@ -0,0 +1,49 @@
1
+ import type { Cancellation, CancelReason, GenerationOutput, TokenLogprobs } from "../contracts/generation";
2
+ import type { DraftAcceptance } from "./draft-acceptance";
3
+
4
+ export interface DraftOutputRow {
5
+ readonly acceptance: DraftAcceptance;
6
+ readonly logprobs?: readonly (TokenLogprobs | undefined)[];
7
+ readonly output: Pick<GenerationOutput, "commit">;
8
+ readonly cancellation?: Cancellation;
9
+ }
10
+
11
+ /** Counts apply to this round. Accepted counts describe processed proposed
12
+ * inputs, excluding an unprocessed correction/bonus and any unpublished EOS.
13
+ * Failed/cancelled requests must be discarded, never checkpointed. */
14
+ export type DraftOutputResult = { readonly generated: number; readonly accepted: number } & (
15
+ | { readonly kind: "continue"; readonly pending: number }
16
+ | { readonly kind: "stop" | "length" }
17
+ | { readonly kind: "cancelled"; readonly reason: CancelReason }
18
+ | { readonly kind: "failed"; readonly error: unknown }
19
+ );
20
+
21
+ /** Deliver verified content through the output interface before resolving
22
+ * retained state. A stopped or failed consumer affects only its own request.
23
+ * Token-sized publications preserve callbacks that stop inside a draft burst. */
24
+ export async function deliverDraftOutputs(rows: readonly DraftOutputRow[]): Promise<DraftOutputResult[]> {
25
+ const results: DraftOutputResult[] = [];
26
+ for (const row of rows) results.push(await deliver(row));
27
+ return results;
28
+ }
29
+
30
+ async function deliver(row: DraftOutputRow): Promise<DraftOutputResult> {
31
+ const { acceptance } = row;
32
+ let generated = 0;
33
+ const retained = () => ({ generated, accepted: Math.min(generated, acceptance.accepted) });
34
+ try {
35
+ for (const token of acceptance.emitted) {
36
+ const reason = row.cancellation?.reason;
37
+ if (reason) return { kind: "cancelled", reason, generated, accepted: 0 };
38
+ const metadata = row.logprobs?.[generated];
39
+ const more = await row.output.commit([token], metadata ? [metadata] : undefined);
40
+ generated++;
41
+ if (more === false) return { kind: "stop", ...retained() };
42
+ }
43
+ const reason = row.cancellation?.reason;
44
+ if (reason) return { kind: "cancelled", reason, generated, accepted: 0 };
45
+ if (acceptance.sawEos || acceptance.grammarDone) return { kind: "stop", ...retained() };
46
+ if (generated >= acceptance.remaining) return { kind: "length", ...retained() };
47
+ return { kind: "continue", pending: acceptance.correction!, ...retained() };
48
+ } catch (error) { return { kind: "failed", error, generated, accepted: 0 }; }
49
+ }
@@ -1,7 +1,7 @@
1
1
  /** A complete transaction over every participating target state layer.
2
2
  * A failed begin/resolve invalidates the run; its state must be discarded. */
3
- export interface SpeculativeTransaction {
3
+ export interface SpeculativeTransaction<Acceptance = number> {
4
4
  canBegin(drafts: number): boolean;
5
5
  begin(drafts: number): void;
6
- resolve(accepted: number): void;
6
+ resolve(accepted: Acceptance): void;
7
7
  }
package/src/kv-scheme.ts CHANGED
@@ -86,6 +86,42 @@ export function kvBytesAt(
86
86
  value.linearStateBytes;
87
87
  }
88
88
 
89
+ /** Head dims MLX 0.32.2's fused multi-query SDPA kernel serves for L > 8
90
+ * queries on M1–M4 GPUs (mlx/backend/metal/scaled_dot_product_attention.cpp
91
+ * `use_fallback`: 192 and 256 always fall back off-NAX; `has_fused_kernel`
92
+ * lists 64/72/80/96/128 for the full kernel). Every other head dim runs
93
+ * mlx/fast.cpp's composed fallback, which materializes the bf16 scores
94
+ * tensor [heads, L, S] plus a bool mask per full-attention layer. The
95
+ * decode kernel (L ≤ 8) fuses 256, so this term is a prefill-only cost. */
96
+ export const FUSED_SDPA_HEAD_DIMS: ReadonlySet<number> = new Set([64, 72, 80, 96, 128]);
97
+ const SDPA_SCORES_BYTES = 2; // bf16 scores in q's dtype
98
+ const SDPA_FUSED_MAX_QUERIES = 8;
99
+
100
+ /** Peak bytes of the SDPA-fallback scores tensor live during one prefill
101
+ * chunk of `chunk` queries against a `ctx`-token context: the last chunk
102
+ * sees the longest keys, and the lazy graph keeps one layer's scores live
103
+ * at a time. Zero when every attention layer's head dim fuses. Sliding
104
+ * layers see at most `window + L` keys. Admission-only (fit and the
105
+ * in-flight memory guard); nothing here changes what is computed. */
106
+ export function sdpaFallbackBytes(config: ModelConfig, chunk: number, ctx: number): number {
107
+ const text = config.text;
108
+ const heads = text.numAttentionHeads || 0;
109
+ const queries = Math.min(chunk, ctx);
110
+ if (heads <= 0 || queries <= SDPA_FUSED_MAX_QUERIES) return 0;
111
+ let peak = 0;
112
+ const layers = text.numHiddenLayers || text.layerTypes.length;
113
+ for (let layer = 0; layer < layers; layer++) {
114
+ const type = text.layerTypes[layer] ?? "full_attention";
115
+ if (type === "linear_attention") continue;
116
+ const sliding = type === "sliding_attention" && text.slidingWindow > 0;
117
+ const headDim = sliding ? text.headDim : text.globalHeadDim;
118
+ if (FUSED_SDPA_HEAD_DIMS.has(headDim)) continue;
119
+ const keys = sliding ? Math.min(ctx, text.slidingWindow + queries) : ctx;
120
+ peak = Math.max(peak, heads * queries * keys * SDPA_SCORES_BYTES);
121
+ }
122
+ return peak;
123
+ }
124
+
89
125
  export class KvScheme {
90
126
  readonly kind: KvSchemeKind;
91
127
  readonly options: Readonly<ResolvedKvSchemeOptions>;
@@ -112,10 +148,17 @@ export class KvScheme {
112
148
  get cacheKey(): string {
113
149
  if (this.kind === "turbo") {
114
150
  const turbo = this.options.turboQuant!;
115
- return `turbo-k${turbo.kBits}v${turbo.vBits}`;
151
+ const start = this.options.quantizedKvStart ?? 0;
152
+ return `turbo-k${turbo.kBits}v${turbo.vBits}${start > 0 ? `-start${start}` : ""}`;
153
+ }
154
+ if (this.kind === "affine-uniform") {
155
+ const start = this.options.quantizedKvStart ?? 5000, group = this.options.kvGroupSize ?? 64;
156
+ return `kv${this.options.kvBits}${group !== 64 ? `-g${group}` : ""}${start > 0 ? `-start${start}` : ""}`;
157
+ }
158
+ if (this.kind === "affine-config") {
159
+ const start = this.options.quantizedKvStart ?? 0;
160
+ return `config${start > 0 ? `-start${start}` : ""}`;
116
161
  }
117
- if (this.kind === "affine-uniform") return `kv${this.options.kvBits}`;
118
- if (this.kind === "affine-config") return "config";
119
162
  return "bf16";
120
163
  }
121
164
 
@@ -154,14 +197,24 @@ export class KvScheme {
154
197
  return this.kind === "turbo" ? {} : this.generationOptions;
155
198
  }
156
199
 
157
- /** The current batch engine accepts only per-layer affine schemes whose
158
- * named cache instances can perform the required conversion. The capability
159
- * probe is mandatory for quantized schemes so config shape alone can never
160
- * authorize placement. */
161
- batchable(config: ModelConfig, canConvert?: (layerIdx: number) => boolean): boolean {
200
+ /** Affine layouts convert during solo prefill. TQ also has a transitional
201
+ * row layout that preserves each request's delayed conversion boundary. */
202
+ batchable(config: ModelConfig, canConvert?: (layerIdx: number) => boolean,
203
+ cacheLayerCount = config.text.numHiddenLayers, capabilities: { delayedAffine?: boolean } = {}): boolean {
162
204
  if (this.kind === "bf16") return true;
163
- if (this.kind !== "affine-config") return false;
205
+ if (this.kind !== "affine-config" && this.kind !== "affine-uniform" && this.kind !== "turbo") return false;
164
206
  if (!canConvert) return false;
207
+ const start = this.options.quantizedKvStart ?? (this.kind === "affine-uniform" ? 5000 : 0);
208
+ if (start > 0 && this.kind !== "turbo" && !capabilities.delayedAffine) return false;
209
+ if (this.kind === "affine-uniform" || this.kind === "turbo") {
210
+ // Some models share the donor prefix's KV in later layers. Probe the
211
+ // actual cache list, just as uniform conversion does.
212
+ for (let layer = 0; layer < cacheLayerCount; layer++) {
213
+ if (config.text.layerTypes[layer] === "linear_attention") continue;
214
+ if (!canConvert(layer)) return false;
215
+ }
216
+ return true;
217
+ }
165
218
  return this.options.kvConfig!.every((entry) => {
166
219
  if (entry.layerIdx < 0 || entry.layerIdx >= config.text.numHiddenLayers) return false;
167
220
  return (config.text.layerTypes[entry.layerIdx] ?? "full_attention") !== "linear_attention" &&
@@ -175,11 +228,15 @@ export function resolveKvScheme(input: {
175
228
  turboQuant?: TurboQuantScheme;
176
229
  config?: readonly KvQuantSpec[] | null;
177
230
  missingConfig?: "bf16" | "error";
231
+ quantizedKvStart?: number;
178
232
  }): KvScheme {
233
+ const start = input.quantizedKvStart;
234
+ if (start !== undefined && (!Number.isSafeInteger(start) || start < 0))
235
+ throw new Error("quantizedKvStart must be a nonnegative integer");
179
236
  if (input.turboQuant) {
180
237
  return new KvScheme("turbo", {
181
238
  turboQuant: input.turboQuant,
182
- quantizedKvStart: 0,
239
+ quantizedKvStart: start ?? 0,
183
240
  });
184
241
  }
185
242
  if (input.override === "off" || input.override === undefined)
@@ -190,10 +247,11 @@ export function resolveKvScheme(input: {
190
247
  throw new Error("model has no kv_config.json (--kv-quant config)");
191
248
  return new KvScheme("bf16", {});
192
249
  }
193
- return new KvScheme("affine-config", { kvConfig: [...input.config] });
250
+ return new KvScheme("affine-config", { kvConfig: [...input.config],
251
+ ...(start === undefined ? {} : { quantizedKvStart: start }) });
194
252
  }
195
253
  return new KvScheme("affine-uniform", {
196
254
  kvBits: input.override,
197
- quantizedKvStart: 0,
255
+ quantizedKvStart: start ?? 0,
198
256
  });
199
257
  }