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
@@ -17,11 +17,9 @@
17
17
  // trimmable mid-generation; a serve endpoint must not 500 mid-stream, so
18
18
  // we STOP SPECULATING and finish the generation with plain single-token
19
19
  // decode (bit-equivalent continuation — the target's own samples).
20
- // - Prompt-cache reuse is BYPASSED in v1 (fresh caches per spec request,
21
- // cachedTokens=0). mlx-lm composes spec with its LRU prompt cache
22
- // (target+draft caches per entry); wiring that through our PromptCache +
23
- // SSD tier is a tracked follow-up in the integration plan, not silently
24
- // absent.
20
+ // - Sources with checkpoint support use the injected prefix-cache service.
21
+ // Target and companion state share the existing RAM/SSD lifecycle. Other
22
+ // sources begin with fresh state until they supply a checkpoint codec.
25
23
  //
26
24
  // Grammar × spec (Phase C, the constrained verify walk — novel: NO runtime
27
25
  // serves both; mlx-lm has no grammar, oMLX no spec): the drafter runs FREE
@@ -40,14 +38,18 @@
40
38
  // false halts the generation mid-burst.
41
39
 
42
40
  import { MlxArray } from "../mlx/array";
41
+ import { sampleSpeculativeRows } from "../backends/mlx/speculative-round";
43
42
  import * as ops from "../mlx/ops";
44
43
  import { clearCache } from "../mlx/ffi";
45
44
  import { cleanupFailure, disposeResources } from "../engine/resources";
46
45
  import type { DisposableResource } from "../contracts/resources";
47
46
  import { assertMlxSpeculativeBinding, bindLegacySpeculativeModel, type MlxSpeculativeBinding } from "../backends/mlx/speculative";
48
47
  import type { RuntimeModel } from "../model/factory";
48
+ import { cloneKvCaches } from "../kv-store";
49
+ import { disposeAttachments, type CheckpointAttachment } from "../backends/mlx/checkpoint-state";
49
50
  import type { Cache } from "../model/gemma4";
50
- import { maybeQuantizeKv, withModelUsageFlush, withModelWiredLimit, type GenerateOptions, type GenerateStats } from "../generate";
51
+ import { withModelUsageFlush, withModelWiredLimit, type GenerateOptions, type GenerateStats } from "../generate";
52
+ import { createKvMaintenance } from "../backends/mlx/kv-maintenance";
51
53
  import { runtimeConfig, withRuntimeConfig } from "../runtime-config";
52
54
  import { makeSampler, makeStepSampler } from "../sampler";
53
55
  import type { OnToken } from "../serve/generation-gateway";
@@ -72,6 +74,13 @@ export interface SpecServeExtras {
72
74
  acceptedByPos: number[];
73
75
  }
74
76
 
77
+ export interface SpecRunServices {
78
+ readonly prefixCache?: import("../backends/mlx/checkpoint-state").MlxPrefixCache;
79
+ /** Mounted adapter revision, resolved while the execution lease is held. */
80
+ readonly cacheNamespace?: string;
81
+ readonly cloneState?: (caches: Cache[]) => Cache[];
82
+ }
83
+
75
84
  export async function specServeRun(
76
85
  model: RuntimeModel,
77
86
  provider: DraftProvider,
@@ -79,8 +88,9 @@ export async function specServeRun(
79
88
  promptIds: number[],
80
89
  options: GenerateOptions & { stopSequences?: string[] },
81
90
  onToken: OnToken,
91
+ services: SpecRunServices = {},
82
92
  ): Promise<GenerateStats> {
83
- return specRun(bindLegacySpeculativeModel(model, provider), numDraftTokens, promptIds, options, onToken);
93
+ return specRun(bindLegacySpeculativeModel(model, provider), numDraftTokens, promptIds, options, onToken, services);
84
94
  }
85
95
 
86
96
  /** The verifier consumes a bound target and source factory, independent of
@@ -91,9 +101,10 @@ export async function specRun(
91
101
  promptIds: number[],
92
102
  options: GenerateOptions & { stopSequences?: string[] },
93
103
  onToken: OnToken,
104
+ services: SpecRunServices = {},
94
105
  ): Promise<GenerateStats> {
95
106
  return withRuntimeConfig(binding.runtime ?? runtimeConfig(), () => {
96
- const run = () => specRunInner(binding, numDraftTokens, promptIds, options, onToken);
107
+ const run = () => specRunInner(binding, numDraftTokens, promptIds, options, onToken, services);
97
108
  return binding.memory
98
109
  ? withModelWiredLimit(binding.memory, () => withModelUsageFlush(binding.memory!, run))
99
110
  : run();
@@ -106,8 +117,10 @@ async function specRunInner(
106
117
  promptIds: number[],
107
118
  options: GenerateOptions & { stopSequences?: string[] },
108
119
  onToken: OnToken,
120
+ services: SpecRunServices = {},
109
121
  ): Promise<GenerateStats> {
110
122
  const maxTokens = options.maxTokens ?? 512;
123
+ const maintainKv = createKvMaintenance(options);
111
124
  const eos = options.eosTokenIds ?? [...binding.eosTokenIds];
112
125
  const sampler = makeSampler(options);
113
126
  const stepSampler = makeStepSampler(options, {
@@ -129,6 +142,7 @@ async function specRunInner(
129
142
  // and turned a config error into a per-request 500. loadContext now
130
143
  // probe-opens the pairing at startup, so a throw here is belt+suspenders.
131
144
  let caches: Cache[] = [];
145
+ let prefixRetain: (() => void) | undefined;
132
146
  let source: import("./source").DraftSource | null = null;
133
147
  let tapLayers: number[] | undefined;
134
148
  // Target final hidden [1,1,H] at the anchor position — the assistant source
@@ -175,16 +189,6 @@ async function specRunInner(
175
189
  const samplePos = async (logits1V: MlxArray, step: number): Promise<number> =>
176
190
  (await stepSampler.sample(logits1V, step)).token;
177
191
 
178
- /** The [1,V] logits row at position `pos` of a hidden window — batched
179
- * lm-head is applied by the caller ONCE; this slices its output. */
180
- const logitsRow = (logitsWindow: MlxArray, pos: number): MlxArray => {
181
- const V = logitsWindow.shape[logitsWindow.shape.length - 1]!;
182
- const sl = logitsWindow.slice([0, pos, 0], [1, pos + 1, V]);
183
- const flat = ops.reshape(sl, [1, V]);
184
- sl.dispose();
185
- return flat;
186
- };
187
-
188
192
  const forwardTokens = async (tokens: number[], taps?: number[]) => {
189
193
  const ids = ops.fromInt32(tokens, [1, tokens.length]);
190
194
  try { return await binding.forward(ids, caches, taps); }
@@ -197,25 +201,50 @@ async function specRunInner(
197
201
  if (!Number.isSafeInteger(prefillChunk) || prefillChunk < 1)
198
202
  throw new Error("speculative prefill chunk must be a positive integer");
199
203
  assertMlxSpeculativeBinding(binding);
200
- if (options.turboQuant || options.kvConfig?.length ||
201
- (options.kvBits && (options.kvBits !== 4 || options.quantizedKvStart !== 0)))
202
- throw new Error("speculative KV requires the qualified uniform KV4 start=0 policy");
204
+ if (options.kvConfig?.length ||
205
+ (options.turboQuant && (options.quantizedKvStart ?? 0) !== 0) ||
206
+ (options.kvBits && ((options.kvBits !== 4 && options.kvBits !== 8) || options.quantizedKvStart !== 0)))
207
+ throw new Error("speculative KV requires uniform KV4/KV8 or TurboQuant start=0");
203
208
  caches = binding.makeCache();
204
209
  const src = binding.openDraft(sampler, caches);
205
210
  source = src;
206
211
  tapLayers = src.tapLayers;
207
- const prefixBudget = Math.max(0, options.speculativeCacheBytes ?? 0);
208
- const prefix = src.prefillMode === "full" ? src.prefix : undefined;
209
- const prefixNamespace = JSON.stringify({ adapters: options.adapters ?? [],
210
- kvBits: options.kvBits ?? null, kvGroupSize: options.kvGroupSize ?? 64,
211
- quantizedKvStart: options.quantizedKvStart ?? null });
212
- const cached = prefix?.restore(promptIds, caches, prefixNamespace, prefixBudget) ?? 0;
212
+ const checkpoint = src.prefillMode === "full" ? src.checkpoint : undefined;
213
+ const prefixCache = checkpoint ? services.prefixCache : undefined;
214
+ const prefixNamespace = JSON.stringify({ method: checkpoint?.namespace,
215
+ adapters: services.cacheNamespace ?? options.adapters ?? [], kvBits: options.kvBits ?? null,
216
+ kvGroupSize: options.kvGroupSize ?? 64, quantizedKvStart: options.quantizedKvStart ?? null,
217
+ turboQuant: options.turboQuant ?? null });
218
+ let cached = 0;
219
+ const hit = prefixCache?.take(promptIds, prefixNamespace);
220
+ if (hit) {
221
+ try {
222
+ disposeResources(caches);
223
+ caches.splice(0, caches.length, ...hit.caches.splice(0));
224
+ prefixRetain = hit.retain;
225
+ hit.retain = undefined;
226
+ checkpoint!.restore(hit.tokens.length, hit.attachments![0]!);
227
+ cached = hit.tokens.length;
228
+ } finally {
229
+ disposeResources([...hit.caches, { dispose: () => disposeAttachments(hit.attachments) },
230
+ { dispose: () => hit.retain?.() }]);
231
+ }
232
+ }
213
233
  stats.cachedTokens = cached;
214
- const snapshotAt = prefix && prefixBudget > 0
215
- ? Math.min(options.snapshotAt ?? promptIds.length, promptIds.length - 1)
216
- : -1;
217
- if (prefix && prefixBudget > 0 && cached > 0 && snapshotAt <= cached)
218
- prefix.capture(promptIds.slice(0, cached), caches, prefixNamespace, prefixBudget);
234
+ const capture = (tokens: number[]) => {
235
+ let state: Cache[] = [];
236
+ let attachments: CheckpointAttachment[] = [];
237
+ try {
238
+ state = (services.cloneState ?? cloneKvCaches)(caches);
239
+ attachments.push(checkpoint!.capture(tokens.length));
240
+ prefixCache!.put(tokens, state, prefixNamespace, undefined, attachments);
241
+ state = []; attachments = [];
242
+ } finally {
243
+ disposeResources([...state, { dispose: () => disposeAttachments(attachments) }]);
244
+ }
245
+ };
246
+ const snapshotAt = prefixCache
247
+ ? Math.min(options.snapshotAt ?? promptIds.length, promptIds.length - 1) : -1;
219
248
  const seedSource = async (end: number) => {
220
249
  if (ctxParts.length === 1) prefillCtx = ctxParts[0]!;
221
250
  else if (ctxParts.length > 1) {
@@ -275,7 +304,7 @@ async function specRunInner(
275
304
  anchorHidden = h.slice([0, L - 1, 0], [1, L, H]); // position len-2
276
305
  }
277
306
  } finally { h.dispose(); } // no logits during the drain
278
- maybeQuantizeKv(caches, options);
307
+ maintainKv(caches);
279
308
  clearCache();
280
309
  pos += n;
281
310
  if (pos < end) await new Promise<void>((resolve) => setImmediate(resolve));
@@ -300,10 +329,10 @@ async function specRunInner(
300
329
  lastLogits = ops.reshape(lg, [1, V]);
301
330
  } finally { lg.dispose(); }
302
331
  } } finally { h.dispose(); }
303
- maybeQuantizeKv(caches, options);
332
+ maintainKv(caches);
304
333
  if (end === snapshotAt && snapshotAt > cached) {
305
334
  await seedSource(end);
306
- prefix!.capture(promptIds.slice(0, end), caches, prefixNamespace, prefixBudget);
335
+ capture(promptIds.slice(0, end));
307
336
  }
308
337
  clearCache();
309
338
  off = end;
@@ -460,39 +489,12 @@ async function specRunInner(
460
489
  // accept while it reproduces the draft. Sampling is sequential because
461
490
  // the processor history (and, in Phase C, the grammar mask) at position
462
491
  // i depends on the tokens accepted at positions < i.
463
- let kAccept = 0;
464
- let correction: number | null = null; // target's token at first mismatch (or bonus)
465
- let sawEos = false;
492
+ const [result] = await sampleSpeculativeRows([{
493
+ pending, step: stats.generatedTokens, remaining: maxTokens - stats.generatedTokens,
494
+ eosTokenIds: eos, sampling: stepSampler, grammarDone: () => grammar?.isTerminated ?? false,
495
+ }], [drafts], vLogits!);
496
+ const { accepted: kAccept, correction, sawEos, emitted, grammarDone } = result!.acceptance;
466
497
  let halted = false;
467
- const emitted: number[] = [];
468
- let grammarDone = false;
469
- for (let i = 0; i <= d; i++) {
470
- roundRow = logitsRow(vLogits!, i);
471
- const tok = await samplePos(roundRow, stats.generatedTokens + emitted.length);
472
- roundRow.dispose();
473
- roundRow = null;
474
- if (i < d && tok === drafts[i]) {
475
- kAccept++;
476
- // EOS is never content — even when it arrives as an ACCEPTED DRAFT
477
- // (the correction/bonus branch below always excluded it; this
478
- // branch pushed-then-broke, leaking the EOS through onToken —
479
- // caught by the 2026-07-07 live oracle gate: streams were
480
- // bit-identical, ours emitted one extra "content" token: <|eot_id|>).
481
- if (eos.includes(tok)) { sawEos = true; break; }
482
- emitted.push(tok);
483
- // grammar termination mid-burst truncates the round — nothing may
484
- // be sampled past a satisfied grammar (the all--inf guarantee).
485
- if (grammar?.isTerminated) { grammarDone = true; break; }
486
- if (stats.generatedTokens + emitted.length >= maxTokens) break;
487
- continue;
488
- }
489
- // mismatch (target replaces the draft) or bonus (i === n)
490
- correction = tok;
491
- if (!eos.includes(tok)) emitted.push(tok);
492
- else sawEos = true;
493
- if (grammar?.isTerminated) grammarDone = true;
494
- break;
495
- }
496
498
  vLogits!.dispose();
497
499
  vLogits = null;
498
500
  extras.accepted += kAccept;
@@ -575,7 +577,7 @@ async function specRunInner(
575
577
  } finally {
576
578
  const resources = [anchorHidden, lastLogits, prefillCtx, ...ctxParts,
577
579
  vLogits, roundRow, vHidden, vCtxML, ...caches, source, stepSampler,
578
- options.grammar, { dispose: clearCache }]
580
+ options.grammar, { dispose: () => prefixRetain?.() }, { dispose: clearCache }]
579
581
  .filter((resource): resource is DisposableResource => resource != null);
580
582
  if (failure) cleanupFailure(failure.error, () => disposeResources(resources));
581
583
  else disposeResources(resources);
@@ -23,6 +23,9 @@
23
23
  import type { MlxArray } from "../mlx/array";
24
24
  import type { Dtype } from "../mlx/ffi";
25
25
  import type { Cache } from "../model/gemma4";
26
+ import type { CheckpointAttachment } from "../backends/mlx/checkpoint-state";
27
+ import type { MlxDraftRows } from "../backends/mlx/speculative-round";
28
+ import type { PreparedStateChange } from "../contracts/resources";
26
29
 
27
30
  /** Numerical ports are backend-specific; returned arrays are caller-owned. */
28
31
  export interface DraftProjection {
@@ -33,11 +36,14 @@ export interface DraftProjection {
33
36
  logitsFromHidden(hidden: MlxArray): MlxArray;
34
37
  }
35
38
 
36
- export interface AssistantTarget {
37
- position(): number;
38
- embedScaled(token: number): MlxArray;
39
- /** Retained temporal views; the draft round disposes every returned array. */
40
- readDonors(): { sliding: [MlxArray, MlxArray]; full: [MlxArray, MlxArray] };
39
+ /** Read-only target state and embedding, independent of draft scheduling. */
40
+ export interface AssistantRowsTarget {
41
+ readonly hiddenSize: number;
42
+ embed(ids: MlxArray): MlxArray;
43
+ readDonors(): import("./drafter").AssistantDonors & {
44
+ readonly positions: readonly number[];
45
+ dispose(): void;
46
+ };
41
47
  }
42
48
 
43
49
  export interface QwenMtpTarget {
@@ -53,7 +59,7 @@ export interface QwenMtpTarget {
53
59
  export interface TargetView {
54
60
  /** Opaque identity for providers borrowing weights from one exact target. */
55
61
  readonly identity: object;
56
- readonly assistant?: AssistantTarget;
62
+ readonly assistantRows?: AssistantRowsTarget;
57
63
  readonly gemmaTaps?: { readonly layerCount: number; readonly projection: DraftProjection };
58
64
  readonly qwenMtp?: QwenMtpTarget;
59
65
  }
@@ -61,12 +67,13 @@ export interface TargetView {
61
67
  /** A per-request draft-token producer. Created per generation (owns its own
62
68
  * draft-side state), disposed by the serve loop's finally. */
63
69
  export interface DraftSource {
64
- /** Optional paired target/draft prefill state. Restore transfers matching
65
- * target state into the supplied array. Capture borrows evaluated state;
66
- * the source owns its retained snapshot. Only full-prefill sources opt in. */
67
- readonly prefix?: {
68
- restore(prompt: readonly number[], caches: Cache[], namespace: string, maxBytes: number): number;
69
- capture(tokens: number[], caches: Cache[], namespace: string, maxBytes: number): void;
70
+ /** Method-owned companion state, independent of retention and storage.
71
+ * Capture returns owned immutable views. Restore borrows its attachment;
72
+ * the source retains the views it needs before returning. */
73
+ readonly checkpoint?: {
74
+ readonly namespace: string;
75
+ capture(processedTokens: number): import("../backends/mlx/checkpoint-state").CheckpointAttachment;
76
+ restore(processedTokens: number, attachment: import("../backends/mlx/checkpoint-state").CheckpointAttachment): void;
70
77
  };
71
78
  /** Target prefill shape required by this source's oracle. Most mlx-lm
72
79
  * sources leave the final prompt token pending; native Colibri MTP starts
@@ -86,8 +93,9 @@ export interface DraftSource {
86
93
  /** Process the prompt (two-model: prefill the draft model's cache;
87
94
  * assistant/dflash: read the target's state — mostly a no-op, but DSpark
88
95
  * seeds H_ctx from `ctxML`, the tapped prefill context [1,Lp,m*H], present
89
- * iff tapLayers is set). */
90
- prefill(promptIds: number[], ctxML?: MlxArray): void | Promise<void>;
96
+ * iff tapLayers is set). A composed prefill may supply its exact processed
97
+ * token count, so a history source need not infer the target's boundary. */
98
+ prefill(promptIds: number[], ctxML?: MlxArray, processedTokens?: number): void | Promise<void>;
91
99
 
92
100
  /** Propose 0..n tokens (RETURN LENGTH IS AUTHORITATIVE — a source may
93
101
  * return fewer than n, e.g. DSpark's confidence-scheduled draft-length
@@ -132,12 +140,69 @@ export interface DraftSource {
132
140
  dispose(): void;
133
141
  }
134
142
 
143
+ export interface DraftRowSampling {
144
+ /** Borrow [B,V] log-probabilities; return owned [B] IDs on device. */
145
+ sample(logprobs: MlxArray, steps: readonly number[]): MlxArray;
146
+ }
147
+
148
+ export interface DraftRowCheckpoint {
149
+ readonly processedTokens: number;
150
+ readonly attachment: CheckpointAttachment;
151
+ }
152
+
153
+ /** Method-owned state membership, independent of scheduling and cache tiers.
154
+ * Open/append borrow checkpoints; capture returns owned immutable state.
155
+ * Membership changes and capture occur only between committed rounds.
156
+ * The caller retains the provider's residency lease for the group's lifetime. */
157
+ export interface DraftRowGroup extends MlxDraftRows {
158
+ readonly namespace: string;
159
+ readonly tapLayers: readonly number[];
160
+ readonly rowCount: number;
161
+ append(checkpoints: readonly DraftRowCheckpoint[]): void;
162
+ prepareAppend(checkpoints: readonly DraftRowCheckpoint[]): PreparedStateChange;
163
+ filterRows(keep: readonly number[]): void;
164
+ capture(row: number): DraftRowCheckpoint;
165
+ dispose(): void;
166
+ }
167
+
168
+ /** Draft preparation consumes each target chunk at the same batch geometry.
169
+ * Inputs are borrowed and contain only new tokens/context, with equal chunk
170
+ * lengths across rows. A null checkpoint appends a cold request. The provider
171
+ * owns alignment and retained hidden state; scheduling and persistence do not.
172
+ * Membership and capture occur between prefill calls. Capture returns owned
173
+ * state after at least one target token has been consumed for that row. */
174
+ export interface DraftPrefillGroup {
175
+ readonly namespace: string;
176
+ readonly prefillMode: NonNullable<DraftSource["prefillMode"]>;
177
+ readonly tapLayers: readonly number[];
178
+ readonly rowCount: number;
179
+ append(checkpoints: readonly (DraftRowCheckpoint | null)[]): void;
180
+ prepareAppend(checkpoints: readonly (DraftRowCheckpoint | null)[]): PreparedStateChange;
181
+ prefill(tokens: MlxArray, context?: MlxArray): void | Promise<void>;
182
+ /** Resolve borrowed backing before its external restore lease is released. */
183
+ materialize(): void;
184
+ filterRows(keep: readonly number[]): void;
185
+ capture(row: number): DraftRowCheckpoint;
186
+ dispose(): void;
187
+ }
188
+
189
+ export interface GroupedDraftProvider {
190
+ /** Draft state remains valid when target forwards run under a mounted
191
+ * adapter context. Unqualified learned draft graphs leave this absent. */
192
+ readonly supportsTargetAdapters?: boolean;
193
+ openPrefill(options: { target: TargetView;
194
+ checkpoints: readonly (DraftRowCheckpoint | null)[] }): DraftPrefillGroup;
195
+ open(options: { target: TargetView; sampling: DraftRowSampling;
196
+ checkpoints: readonly DraftRowCheckpoint[] }): DraftRowGroup;
197
+ }
198
+
135
199
  /** Server-lifetime owner of the draft machinery (the loaded draft model);
136
- * open() mints a per-request DraftSource. */
200
+ * open() mints a per-request DraftSource; grouped opens shared row state. */
137
201
  export interface DraftProvider {
138
202
  /** Human-readable id (registry id / path tail) for logs + cache namespacing. */
139
203
  readonly id: string;
140
204
  readonly weightsBytes: number;
205
+ readonly grouped?: GroupedDraftProvider;
141
206
  open(opts: {
142
207
  /** The request's sampler over logprobs [1,V] → token array [1] (the SAME
143
208
  * sampler as the target — mlx-lm parity; greedy drafting under a
@@ -0,0 +1,61 @@
1
+ import type { MlxAutoregressiveBinding } from "../backends/mlx/autoregressive";
2
+ import { MlxAutoregressiveDraftRows, type MlxDraftLayout } from "../backends/mlx/autoregressive-draft-rows";
3
+ import { prefillCacheLayout } from "../backends/mlx/cache-layout";
4
+ import { MlxStateRows } from "../backends/mlx/state-rows";
5
+ import { captureCacheAttachment, restoreCacheAttachment } from "../backends/mlx/cache-attachment";
6
+ import { applyStateChanges, cleanupFailure, disposeResources } from "../engine/resources";
7
+ import type { Cache } from "../model/gemma4-base";
8
+ import type { DraftPrefillGroup, DraftRowCheckpoint, DraftRowGroup, DraftRowSampling, GroupedDraftProvider } from "./source";
9
+ import type { PreparedStateChange } from "../contracts/resources";
10
+ import type { MlxArray } from "../mlx/array";
11
+
12
+ export interface StandaloneDraftRows extends DraftPrefillGroup {
13
+ draft: DraftRowGroup["draft"];
14
+ commit(accepted: readonly number[], context?: MlxArray): Promise<void>;
15
+ }
16
+
17
+ /** The standalone model supplies graph/state capabilities once. Target
18
+ * verification, scheduling and cache tiers use the existing provider ports. */
19
+ export function openStandaloneDraftRows(binding: MlxAutoregressiveBinding, namespace: string,
20
+ sampling: DraftRowSampling | null, checkpoints: readonly (DraftRowCheckpoint | null)[]): StandaloneDraftRows {
21
+ const empty = binding.makeCache(), layouts: MlxDraftLayout[] = [];
22
+ try { for (const cache of empty) layouts.push(prefillCacheLayout(cache) as MlxDraftLayout); }
23
+ catch (error) { disposeResources(layouts); throw error; }
24
+ finally { disposeResources(empty); }
25
+ const state = new MlxStateRows(layouts), rows = new MlxAutoregressiveDraftRows(binding.graph, state, sampling);
26
+ const prepareAppend = (checkpoints: readonly (DraftRowCheckpoint | null)[]): PreparedStateChange => {
27
+ const restored: Cache[][] = [], pending: Array<number | null> = [];
28
+ try {
29
+ for (const checkpoint of checkpoints) {
30
+ restored.push(checkpoint ? restoreCacheAttachment(checkpoint.attachment, binding.makeCache) : binding.makeCache());
31
+ pending.push(checkpoint && checkpoint.attachment.metadata.pendingLast !== false ? Number(checkpoint.attachment.metadata.pendingLast) : null);
32
+ }
33
+ return rows.prepareAppend(restored, pending);
34
+ } finally { disposeResources(restored.flat()); }
35
+ };
36
+ const append = (checkpoints: readonly (DraftRowCheckpoint | null)[]) => applyStateChanges([() => prepareAppend(checkpoints)]);
37
+ try { append(checkpoints); }
38
+ catch (error) { return cleanupFailure(error, () => rows.dispose()); }
39
+ return {
40
+ namespace, prefillMode: "tail-split", tapLayers: [],
41
+ get rowCount() { return state.rowCount; },
42
+ append, prepareAppend, prefill: tokens => rows.prefill(tokens), materialize: () => rows.materialize(),
43
+ filterRows: keep => rows.filterRows(keep), draft: rows.draft.bind(rows), commit: accepted => rows.commit(accepted),
44
+ capture(row): DraftRowCheckpoint {
45
+ const caches = state.extractRow(row), pendingLast = rows.pendingLast[row] ?? null;
46
+ try {
47
+ const processedTokens = caches[0]!.offset + Number(pendingLast !== null);
48
+ return { processedTokens, attachment: captureCacheAttachment("standalone-draft-v1", caches,
49
+ { processedTokens, pendingLast: pendingLast ?? false }) };
50
+ } finally { disposeResources(caches); }
51
+ },
52
+ dispose: () => rows.dispose(),
53
+ };
54
+ }
55
+
56
+ export function standaloneDraftGroups(binding: MlxAutoregressiveBinding, namespace: string): GroupedDraftProvider {
57
+ return {
58
+ open: options => openStandaloneDraftRows(binding, namespace, options.sampling, options.checkpoints),
59
+ openPrefill: options => openStandaloneDraftRows(binding, namespace, null, options.checkpoints),
60
+ };
61
+ }
@@ -20,9 +20,11 @@ import { flagOn } from "../runtime-config";
20
20
  import { loadModelConfig } from "../config";
21
21
  import { Weights } from "../weights";
22
22
  import { createModel, type RuntimeModel } from "../model/factory";
23
- import type { Cache } from "../model/gemma4";
24
- import { toLogprobs } from "../sampler";
25
- import type { DraftProvider, DraftSource } from "./source";
23
+ import type { DraftProvider, DraftSource, GroupedDraftProvider } from "./source";
24
+ import { standaloneDraftGroups, openStandaloneDraftRows } from "./two-model-rows";
25
+ import { bindLegacyAutoregressiveModel } from "../backends/mlx/autoregressive";
26
+ import { artifactIdentity } from "../model/artifact-identity";
27
+ import { configFingerprint } from "../model/fingerprint";
26
28
 
27
29
  /** mlx-lm's prefill_step_size — the draft drain chunks at the same stride
28
30
  * as the target's (serve-loop.ts); chunking is numerically exact for
@@ -32,13 +34,16 @@ const PREFILL_CHUNK = 2048;
32
34
  export class TwoModelProvider implements DraftProvider {
33
35
  readonly id: string;
34
36
  readonly weightsBytes: number;
37
+ readonly grouped: GroupedDraftProvider;
35
38
 
36
39
  private constructor(
37
40
  readonly model: RuntimeModel,
38
41
  private readonly weights: Weights,
39
42
  id: string,
43
+ private readonly namespace: string,
40
44
  ) {
41
45
  this.id = id;
46
+ this.grouped = standaloneDraftGroups(bindLegacyAutoregressiveModel(model), namespace);
42
47
  this.weightsBytes = [...weights.shards.files.values()].reduce(
43
48
  (a, f) => a + f.mmap.size,
44
49
  0,
@@ -51,20 +56,25 @@ export class TwoModelProvider implements DraftProvider {
51
56
  static async load(modelDir: string, targetVocabSize?: number): Promise<TwoModelProvider> {
52
57
  const config = await loadModelConfig(modelDir);
53
58
  const weights = await Weights.open(modelDir);
54
- const model = createModel(weights, config);
55
- if (
56
- targetVocabSize !== undefined &&
57
- config.text.vocabSize !== targetVocabSize
58
- )
59
- console.warn(
60
- `draft model vocab size ${config.text.vocabSize} != target ${targetVocabSize} — ` +
61
- `speculation will accept nothing useful (mlx-lm warns identically)`,
62
- );
63
- return new TwoModelProvider(model, weights, modelDir.split("/").filter(Boolean).at(-1)!);
59
+ try {
60
+ const model = createModel(weights, config);
61
+ if (
62
+ targetVocabSize !== undefined &&
63
+ config.text.vocabSize !== targetVocabSize
64
+ )
65
+ console.warn(
66
+ `draft model vocab size ${config.text.vocabSize} != target ${targetVocabSize} — ` +
67
+ `speculation will accept nothing useful (mlx-lm warns identically)`,
68
+ );
69
+ const identity = await artifactIdentity(configFingerprint(config),
70
+ [...weights.shards.files].map(([name, shard]) => ({ name, path: shard.path })));
71
+ return new TwoModelProvider(model, weights, modelDir.split("/").filter(Boolean).at(-1)!,
72
+ `standalone-draft-v1:${identity}`);
73
+ } catch (error) { weights.dispose(); throw error; }
64
74
  }
65
75
 
66
76
  open(opts: Parameters<DraftProvider["open"]>[0]): DraftSource {
67
- return new TwoModelSource(this.model, opts.sampler);
77
+ return new TwoModelSource(this.model, opts.sampler, this.namespace);
68
78
  }
69
79
 
70
80
  dispose(): void {
@@ -72,100 +82,32 @@ export class TwoModelProvider implements DraftProvider {
72
82
  }
73
83
  }
74
84
 
85
+ /** Compatibility adapter: the existing request loop also uses the shared
86
+ * draft graph at B=1. Proposal generation and state recovery have one owner. */
75
87
  class TwoModelSource implements DraftSource {
76
- readonly weightsBytes = 0; // provider-owned weights; per-request adds caches only
77
- private caches: Cache[];
78
-
79
- constructor(
80
- private readonly model: RuntimeModel,
81
- private readonly sampler: (lp: MlxArray, step: number) => MlxArray,
82
- ) {
83
- this.caches = this.model.makeCache();
88
+ readonly weightsBytes = 0;
89
+ private readonly rows: ReturnType<typeof openStandaloneDraftRows>;
90
+ private processed = 0;
91
+ constructor(model: RuntimeModel, sampler: (lp: MlxArray, step: number) => MlxArray, namespace: string) {
92
+ this.rows = openStandaloneDraftRows(bindLegacyAutoregressiveModel(model), namespace,
93
+ { sample: (logprobs, steps) => sampler(logprobs, steps[0]!) }, [null]);
84
94
  }
85
-
86
- prefill(promptIds: number[]): void {
87
- // Oracle convention (mlx-lm speculative_generate_step._prefill, re-anchored
88
- // 2026-07-07): the DRAFT model also drains only to len-1 (`while y.size >
89
- // 1`) — the last prompt token stays unprocessed and is consumed by the
90
- // FIRST draft() round's feed ([pending] = that same token under the serve
91
- // loop's oracle shape), mirroring _draft_generate's first _step. Draft-side
92
- // full-prefill was the residual knife-edge flipper in the 2026-07-07 live
93
- // oracle gate (γ=2 haiku cell). MLX_BUN_PREFILL_TAIL_SPLIT=0 reverts.
94
- // (mlx-lm chunks its drain at prefill_step_size=2048; chunking is
95
- // numerically exact for causal attention — same KV, same positions —
96
- // so we chunk too. Pre-2026-07-07 this was a SINGLE forward of the
97
- // whole head: nothing enforced the "<2048 serve regime" the old
98
- // comment assumed, and a 32k prompt through the spec lane ran one
99
- // 32k-position draft forward — a large activation/mask transient on a
100
- // 24 GB box, and untested numerics vs the chunked oracle.)
95
+ async prefill(promptIds: number[]): Promise<void> {
101
96
  const tailSplit = flagOn("MLX_BUN_PREFILL_TAIL_SPLIT", true);
102
97
  const upTo = tailSplit && promptIds.length > 1 ? promptIds.length - 1 : promptIds.length;
103
- for (let at = 0; at < upTo; at += PREFILL_CHUNK) {
98
+ for (let at = this.processed; at < upTo; at += PREFILL_CHUNK) {
104
99
  const end = Math.min(at + PREFILL_CHUNK, upTo);
105
- const ids = ops.fromInt32(promptIds.slice(at, end), [1, end - at]);
106
- const h = this.model.forwardHidden(ids, this.caches);
107
- ids.dispose();
108
- // The head's last-position logits are not needed: the serve loop's
109
- // first `feed` is consumed at the top of draft().
110
- h.dispose();
111
- clearCache();
112
- }
113
- }
114
-
115
- /** One forward over `feed`, sample the first draft from its last position,
116
- * then n-1 single-token steps — mirrors _draft_generate's autoregressive
117
- * chain (with async_eval pipelining upstream; eager here, the draft is
118
- * small and the serve loop overlaps nothing with it in v1). */
119
- draft(feed: number[], n: number, stepBase: number): number[] {
120
- const drafts: number[] = [];
121
- let input = feed;
122
- for (let k = 0; k < n; k++) {
123
- const ids = ops.fromInt32(input, [1, input.length]);
124
- const h = this.model.forwardHidden(ids, this.caches);
125
- ids.dispose();
126
- const L = h.shape[1]!;
127
- const H = h.shape[2]!;
128
- const hLast = h.slice([0, L - 1, 0], [1, L, H]);
129
- h.dispose();
130
- const logits = this.model.logitsFromHidden(hLast);
131
- hLast.dispose();
132
- const V = logits.shape[logits.shape.length - 1]!;
133
- const flat = ops.reshape(logits, [1, V]);
134
- logits.dispose();
135
- const lp = toLogprobs(flat);
136
- flat.dispose();
137
- const tokArr = this.sampler(lp, stepBase + k);
138
- lp.dispose();
139
- const tok = ops.itemUint32(tokArr);
140
- tokArr.dispose();
141
- drafts.push(tok);
142
- input = [tok];
100
+ using ids = ops.fromInt32(promptIds.slice(at, end), [1, end - at]);
101
+ await this.rows.prefill(ids); clearCache();
143
102
  }
144
- return drafts;
103
+ this.processed = upTo;
145
104
  }
146
-
147
- commit(n: number, kAccept: number): void {
148
- const rewind = Math.max(n - kAccept - 1, 0);
149
- if (rewind > 0) {
150
- for (const c of this.caches) {
151
- if (!c.isTrimmable()) {
152
- // The serve loop stops speculating on the TARGET-side trim check
153
- // before this can matter; a wrapped draft ring here just means the
154
- // source is done being useful. Recreate is the safe recovery.
155
- this.resetCaches();
156
- return;
157
- }
158
- c.trim(rewind);
159
- }
160
- }
161
- }
162
-
163
- private resetCaches(): void {
164
- for (const c of this.caches) c.dispose();
165
- this.caches = this.model.makeCache();
105
+ async draft(feed: number[], count: number, step: number): Promise<number[]> {
106
+ return (await this.rows.draft([feed.at(-1)!], count, [step]))[0]!;
166
107
  }
167
-
168
- dispose(): void {
169
- for (const c of this.caches) c.dispose();
108
+ async commit(_drafted: number, accepted: number): Promise<void> {
109
+ await this.rows.commit([accepted]);
110
+ this.processed += accepted + 1;
170
111
  }
112
+ dispose(): void { this.rows.dispose(); }
171
113
  }