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
@@ -13,8 +13,11 @@ import * as ops from "../mlx/ops";
13
13
  import { toLogprobs } from "../sampler";
14
14
  import { Glm52DecoderLayer, Glm52Model } from "../model/glm52";
15
15
  import { MLACache } from "../model/glm52-cache";
16
- import { rmsNormF32Mlx } from "../model/glm52-mla";
17
- import type { DraftProvider, DraftSource } from "./source";
16
+ import { Glm52MtpGraph } from "./glm52-mtp-graph";
17
+ import type { DraftProvider, DraftSource, GroupedDraftProvider, TargetView, DraftRowSampling, DraftRowCheckpoint, DraftRowGroup, DraftPrefillGroup } from "./source";
18
+ import { Glm52MtpRows, type Glm52MtpRowState } from "./glm52-mtp-rows";
19
+ import { captureGlm52MtpState, restoreGlm52MtpState } from "./glm52-mtp-state";
20
+ import { applyStateChanges, disposeResources } from "../engine/resources";
18
21
 
19
22
  type Sampler = (logprobs: MlxArray, step: number) => MlxArray;
20
23
 
@@ -26,6 +29,10 @@ function lastToken(feed: readonly number[]): number {
26
29
  }
27
30
 
28
31
  export class Glm52NativeMtpProvider implements DraftProvider {
32
+ readonly grouped: GroupedDraftProvider = {
33
+ open: options => this.#openRows(options.target, options.sampling, options.checkpoints),
34
+ openPrefill: options => this.#openRows(options.target, null, options.checkpoints),
35
+ };
29
36
  readonly id = "glm52-native-mtp";
30
37
  readonly weightsBytes = 0;
31
38
  readonly #layer: Glm52DecoderLayer;
@@ -62,6 +69,34 @@ export class Glm52NativeMtpProvider implements DraftProvider {
62
69
  return new Glm52NativeMtpSource(this.model, this.#layer, opts.sampler);
63
70
  }
64
71
 
72
+ #openRows(target: TargetView, sampling: DraftRowSampling | null,
73
+ checkpoints: readonly (DraftRowCheckpoint | null)[]): DraftRowGroup & DraftPrefillGroup {
74
+ if (target.identity !== this.model) throw new Error("native MTP provider was opened for a different target");
75
+ const rows = new Glm52MtpRows(new Glm52MtpGraph(this.model, this.#layer), sampling);
76
+ const prepareAppend = (checkpoints: readonly (DraftRowCheckpoint | null)[]) => {
77
+ const states: (Glm52MtpRowState | null)[] = [];
78
+ try {
79
+ for (const checkpoint of checkpoints) states.push(checkpoint ? restoreGlm52MtpState(checkpoint, () => rows.makeCache()) : null);
80
+ return rows.prepareAppend(states);
81
+ } finally { disposeResources(states.flatMap(state => state ? [state.cache, state.hidden] : [])); }
82
+ };
83
+ const append = (states: readonly (DraftRowCheckpoint | null)[]) => applyStateChanges([() => prepareAppend(states)]);
84
+ try { append(checkpoints); } catch (error) { rows.dispose(); throw error; }
85
+ return {
86
+ namespace: "glm52-native-mtp-v1", prefillMode: "full", tapLayers: [],
87
+ get rowCount() { return rows.rowCount; },
88
+ append, prepareAppend, prefill: (tokens, context) => rows.prefill(tokens, context!),
89
+ materialize: rows.materialize.bind(rows), filterRows: rows.filterRows.bind(rows),
90
+ draft: rows.draft.bind(rows), commit: rows.commit.bind(rows),
91
+ capture(row) {
92
+ const state = rows.extractRow(row);
93
+ try { return captureGlm52MtpState(state); }
94
+ finally { state.cache.dispose(); state.hidden.dispose(); }
95
+ },
96
+ dispose: rows.dispose.bind(rows),
97
+ };
98
+ }
99
+
65
100
  dispose(): void {
66
101
  // The target model owns every shared/MTP weight and the expert tier.
67
102
  }
@@ -72,6 +107,7 @@ export class Glm52NativeMtpSource implements DraftSource {
72
107
  readonly pinTargetKernelFamily = true;
73
108
  readonly weightsBytes = 0;
74
109
  #cache: MLACache;
110
+ readonly #graph: Glm52MtpGraph;
75
111
  #roundStart = 0;
76
112
  #lastDraftCount = 0;
77
113
  #closed = false;
@@ -81,6 +117,7 @@ export class Glm52NativeMtpSource implements DraftSource {
81
117
  readonly layer: Glm52DecoderLayer,
82
118
  readonly sampler: Sampler,
83
119
  ) {
120
+ this.#graph = new Glm52MtpGraph(model, layer);
84
121
  this.#cache = new MLACache({
85
122
  kvLoraRank: model.glmConfig.kvLoraRank,
86
123
  ropeHeadDim: model.glmConfig.qkRopeHeadDim,
@@ -256,142 +293,22 @@ export class Glm52NativeMtpSource implements DraftSource {
256
293
  hidden: MlxArray,
257
294
  sampleStep: number,
258
295
  ): Promise<{ hidden: MlxArray; token: number }> {
259
- const config = this.model.glmConfig;
260
- const ids = ops.fromInt32([token], [1, 1]);
261
- let embedded: MlxArray | null = null;
262
- let embeddedNorm: MlxArray | null = null;
263
- let hiddenNorm: MlxArray | null = null;
264
- let joined: MlxArray | null = null;
265
- let projected: MlxArray | null = null;
266
- let output: MlxArray | null = null;
267
- let headInput: MlxArray | null = null;
268
- let logits: MlxArray | null = null;
269
- let logprobs: MlxArray | null = null;
270
- let sampled: MlxArray | null = null;
271
- try {
272
- embedded = this.model.weights.embedding(
273
- ids,
274
- "model.embed_tokens.weight",
275
- config.vocabSize,
276
- config.hiddenSize,
277
- );
278
- embeddedNorm = rmsNormF32Mlx(
279
- embedded,
280
- this.model.weights.tensor(
281
- `model.layers.${config.numHiddenLayers}.enorm.weight`,
282
- ),
283
- config.rmsNormEps,
284
- );
285
- hiddenNorm = rmsNormF32Mlx(
286
- hidden,
287
- this.model.weights.tensor(
288
- `model.layers.${config.numHiddenLayers}.hnorm.weight`,
289
- ),
290
- config.rmsNormEps,
291
- );
292
- joined = ops.concatAxis([embeddedNorm, hiddenNorm], 2);
293
- projected = this.model.weights.linear(
294
- joined,
295
- `model.layers.${config.numHiddenLayers}.eh_proj.weight`,
296
- config.hiddenSize,
297
- 2 * config.hiddenSize,
298
- );
299
- output = await this.layer.forwardAsync(projected, this.#cache, null);
300
- headInput = rmsNormF32Mlx(
301
- output,
302
- this.model.weights.tensor(
303
- `model.layers.${config.numHiddenLayers}.shared_head.norm.weight`,
304
- ),
305
- config.rmsNormEps,
306
- );
307
- logits = this.model.logitsFromHidden(headInput);
308
- // Sampler contract is [1, V] — same 3-D top-k hazard as
309
- // qwen-mtp-source #sample (2026-08-20); see the comment there.
310
- {
311
- const V = logits.shape[logits.shape.length - 1]!;
312
- const flat = ops.reshape(logits, [1, V]);
313
- logits.dispose();
314
- logits = flat;
315
- }
316
- logprobs = toLogprobs(logits);
317
- sampled = this.sampler(logprobs, sampleStep);
318
- return {
319
- hidden: ops.contiguous(output),
320
- token: ops.itemUint32(sampled),
321
- };
322
- } finally {
323
- ids.dispose();
324
- embedded?.dispose();
325
- embeddedNorm?.dispose();
326
- hiddenNorm?.dispose();
327
- joined?.dispose();
328
- projected?.dispose();
329
- output?.dispose();
330
- headInput?.dispose();
331
- logits?.dispose();
332
- logprobs?.dispose();
333
- sampled?.dispose();
334
- }
296
+ using ids = ops.fromInt32([token], [1, 1]);
297
+ using output = await this.#graph.forward(ids, hidden, this.#cache);
298
+ using logits = this.#graph.project(output);
299
+ // Sampling consumes [B,V], independently of the graph's token dimension.
300
+ using flat = ops.reshape(logits, [1, logits.shape.at(-1)!]);
301
+ using logprobs = toLogprobs(flat);
302
+ using sampled = this.sampler(logprobs, sampleStep);
303
+ return { hidden: ops.contiguous(output), token: ops.itemUint32(sampled) };
335
304
  }
336
305
 
337
- async #absorb(
338
- acceptedTokens: readonly number[],
339
- verifiedHidden: MlxArray,
340
- ): Promise<void> {
341
- const config = this.model.glmConfig;
342
- const k = acceptedTokens.length;
343
- const ids = ops.fromInt32([...acceptedTokens], [1, k]);
344
- let embedded: MlxArray | null = null;
345
- let embeddedNorm: MlxArray | null = null;
346
- let trueRows: MlxArray | null = null;
347
- let hiddenNorm: MlxArray | null = null;
348
- let joined: MlxArray | null = null;
349
- let projected: MlxArray | null = null;
350
- let output: MlxArray | null = null;
351
- try {
352
- embedded = this.model.weights.embedding(
353
- ids,
354
- "model.embed_tokens.weight",
355
- config.vocabSize,
356
- config.hiddenSize,
357
- );
358
- embeddedNorm = rmsNormF32Mlx(
359
- embedded,
360
- this.model.weights.tensor(
361
- `model.layers.${config.numHiddenLayers}.enorm.weight`,
362
- ),
363
- config.rmsNormEps,
364
- );
365
- trueRows = verifiedHidden.slice(
366
- [0, 0, 0],
367
- [1, k, config.hiddenSize],
368
- );
369
- hiddenNorm = rmsNormF32Mlx(
370
- trueRows,
371
- this.model.weights.tensor(
372
- `model.layers.${config.numHiddenLayers}.hnorm.weight`,
373
- ),
374
- config.rmsNormEps,
375
- );
376
- joined = ops.concatAxis([embeddedNorm, hiddenNorm], 2);
377
- projected = this.model.weights.linear(
378
- joined,
379
- `model.layers.${config.numHiddenLayers}.eh_proj.weight`,
380
- config.hiddenSize,
381
- 2 * config.hiddenSize,
382
- );
383
- output = await this.layer.forwardAsync(projected, this.#cache, null);
384
- output.eval();
385
- } finally {
386
- ids.dispose();
387
- embedded?.dispose();
388
- embeddedNorm?.dispose();
389
- trueRows?.dispose();
390
- hiddenNorm?.dispose();
391
- joined?.dispose();
392
- projected?.dispose();
393
- output?.dispose();
394
- }
306
+ async #absorb(acceptedTokens: readonly number[], verifiedHidden: MlxArray): Promise<void> {
307
+ const count = acceptedTokens.length;
308
+ using ids = ops.fromInt32([...acceptedTokens], [1, count]);
309
+ using trueRows = verifiedHidden.slice([0, 0, 0], [1, count, this.model.glmConfig.hiddenSize]);
310
+ using output = await this.#graph.forward(ids, trueRows, this.#cache);
311
+ output.eval();
395
312
  }
396
313
 
397
314
  #checkOpen(): void {
@@ -0,0 +1,33 @@
1
+ import type { MlxArray } from "../mlx/array";
2
+ import type { MLACache } from "../model/glm52-cache";
3
+ import { cloneAttachments } from "../backends/mlx/checkpoint-state";
4
+ import { disposeResources } from "../engine/resources";
5
+ import type { DraftRowCheckpoint } from "./source";
6
+ import type { Glm52MtpRowState } from "./glm52-mtp-rows";
7
+
8
+ export function captureGlm52MtpState(state: Glm52MtpRowState): DraftRowCheckpoint {
9
+ const held: MlxArray[] = [];
10
+ try {
11
+ if (state.cache.offset) {
12
+ const cache = state.cache.fetch(); held.push(cache.latent, cache.rope);
13
+ }
14
+ held.push(state.hidden.slice([0, 0, 0], [...state.hidden.shape]));
15
+ return { processedTokens: state.processedTokens, attachment: {
16
+ schema: "glm52-native-mtp-v1", metadata: { processedTokens: state.processedTokens, draftOffset: state.cache.offset },
17
+ tensors: held.splice(0),
18
+ } };
19
+ } finally { disposeResources(held); }
20
+ }
21
+
22
+ export function restoreGlm52MtpState(checkpoint: DraftRowCheckpoint, makeCache: () => MLACache): Glm52MtpRowState {
23
+ const { attachment, processedTokens } = checkpoint;
24
+ if (attachment.schema !== "glm52-native-mtp-v1" || attachment.metadata.processedTokens !== processedTokens)
25
+ throw new Error("invalid native GLM MTP checkpoint alignment");
26
+ const held = cloneAttachments([attachment])[0]!.tensors;
27
+ let cache: MLACache | null = makeCache();
28
+ try {
29
+ const offset = attachment.metadata.draftOffset as number;
30
+ if (offset) { cache.restoreCompressedState(held[0]!, held[1]!, null, offset); held.splice(0, 2); }
31
+ const state = { cache, hidden: held.pop()!, processedTokens }; cache = null; return state;
32
+ } finally { disposeResources([...held, ...(cache ? [cache] : [])]); }
33
+ }
@@ -39,7 +39,79 @@
39
39
  // JS scan (~µs against 30k-token histories).
40
40
 
41
41
  import { flagOn } from "../runtime-config";
42
- import type { DraftProvider, DraftSource } from "./source";
42
+ import type { MlxArray } from "../mlx/array";
43
+ import * as ops from "../mlx/ops";
44
+ import type { CheckpointAttachment } from "../backends/mlx/checkpoint-state";
45
+ import type { DraftProvider, DraftSource, DraftRowCheckpoint, DraftRowGroup, DraftPrefillGroup, GroupedDraftProvider } from "./source";
46
+ import { applyStateChanges } from "../engine/resources";
47
+
48
+ /** The matching policy is independent of the executor's feed convention. */
49
+ function proposeNgram(h: readonly number[], max: number, min: number, n: number): number[] {
50
+ const L = h.length;
51
+ for (let k = Math.min(max, L - 1); k >= min; k--) {
52
+ const tailAt = L - k;
53
+ search: for (let i = 0; i + k < L; i++) {
54
+ for (let j = 0; j < k; j++) if (h[i + j] !== h[tailAt + j]) continue search;
55
+ return h.slice(i + k, i + k + n);
56
+ }
57
+ }
58
+ return [];
59
+ }
60
+
61
+ const namespace = (max: number, min: number) => `ngram-history-v1:${max}:${min}`;
62
+ const captureHistory = (history: readonly number[], max: number, min: number): CheckpointAttachment => ({
63
+ schema: "ngram-history-v1", metadata: { max, min }, tensors: [ops.fromInt32([...history], [history.length])],
64
+ });
65
+ function restoreHistory(state: DraftRowCheckpoint, max: number, min: number): number[] {
66
+ const { attachment, processedTokens } = state;
67
+ if (attachment.schema !== "ngram-history-v1" || attachment.metadata.max !== max || attachment.metadata.min !== min ||
68
+ attachment.tensors.length !== 1 || attachment.tensors[0]!.shape.length !== 1 || attachment.tensors[0]!.shape[0] !== processedTokens)
69
+ throw new Error("ngram checkpoint does not match its token coverage or lookup policy");
70
+ return attachment.tensors[0]!.toIntTokens();
71
+ }
72
+
73
+ /** Each row owns only its committed token history. Verification and sampling
74
+ * remain in the shared method; target storage and scheduling are not needed. */
75
+ class NgramRows implements DraftRowGroup, DraftPrefillGroup {
76
+ readonly prefillMode = "tail-split" as const;
77
+ readonly tapLayers: readonly number[] = [];
78
+ readonly namespace: string;
79
+ #histories: number[][] = [];
80
+ #drafts: number[][] = [];
81
+ constructor(readonly max: number, readonly min: number, checkpoints: readonly (DraftRowCheckpoint | null)[]) {
82
+ this.namespace = namespace(max, min);
83
+ this.append(checkpoints);
84
+ }
85
+ get rowCount() { return this.#histories.length; }
86
+ prepareAppend(checkpoints: readonly (DraftRowCheckpoint | null)[]) {
87
+ let next = [...this.#histories, ...checkpoints.map(state => state ? restoreHistory(state, this.max, this.min) : [])];
88
+ return { commit: () => { const old = this.#histories; this.#histories = next; next = old; }, dispose: () => { next = []; } };
89
+ }
90
+ append(checkpoints: readonly (DraftRowCheckpoint | null)[]) { applyStateChanges([() => this.prepareAppend(checkpoints)]); }
91
+ filterRows(keep: readonly number[]) { this.#histories = keep.map(row => this.#histories[row]!); this.#drafts = []; }
92
+ prefill(tokens: MlxArray): void {
93
+ using packed = ops.contiguous(tokens);
94
+ const ids = packed.toIntTokens(), length = tokens.shape[1]!;
95
+ for (let row = 0; row < this.rowCount; row++) this.#histories[row]!.push(...ids.slice(row * length, (row + 1) * length));
96
+ }
97
+ materialize(): void {}
98
+ draft(pending: readonly number[], depth: number): number[][] {
99
+ this.#drafts = this.#histories.map((history, row) => {
100
+ history.push(pending[row]!);
101
+ return proposeNgram(history, this.max, this.min, depth);
102
+ });
103
+ return this.#drafts;
104
+ }
105
+ commit(accepted: readonly number[]) {
106
+ for (let row = 0; row < this.rowCount; row++) this.#histories[row]!.push(...this.#drafts[row]!.slice(0, accepted[row]!));
107
+ this.#drafts = [];
108
+ }
109
+ capture(row: number): DraftRowCheckpoint {
110
+ const history = this.#histories[row]!;
111
+ return { processedTokens: history.length, attachment: captureHistory(history, this.max, this.min) };
112
+ }
113
+ dispose() { this.#histories = []; this.#drafts = []; }
114
+ }
43
115
 
44
116
  export interface NgramOptions {
45
117
  /** Longest suffix k-gram tried first (Saxena max_ngram_size). */
@@ -53,6 +125,11 @@ export class NgramProvider implements DraftProvider {
53
125
  readonly weightsBytes = 0;
54
126
  readonly max: number;
55
127
  readonly min: number;
128
+ readonly grouped: GroupedDraftProvider = {
129
+ supportsTargetAdapters: true,
130
+ open: options => new NgramRows(this.max, this.min, options.checkpoints),
131
+ openPrefill: options => new NgramRows(this.max, this.min, options.checkpoints),
132
+ };
56
133
 
57
134
  constructor(opts: NgramOptions = {}) {
58
135
  this.max = Math.max(1, opts.max ?? 3);
@@ -73,24 +150,33 @@ class NgramSource implements DraftSource {
73
150
  readonly weightsBytes = 0;
74
151
  #hist: number[] = [];
75
152
  #lastDrafts: number[] = [];
153
+ readonly #tailSplit = flagOn("MLX_BUN_PREFILL_TAIL_SPLIT", true);
154
+ readonly checkpoint: NonNullable<DraftSource["checkpoint"]>;
76
155
 
77
156
  constructor(
78
157
  private readonly max: number,
79
158
  private readonly min: number,
80
- ) {}
159
+ ) {
160
+ this.checkpoint = {
161
+ namespace: namespace(max, min),
162
+ capture: () => captureHistory(this.#hist, this.max, this.min),
163
+ restore: (processedTokens, attachment) => {
164
+ this.#hist = restoreHistory({ processedTokens, attachment }, this.max, this.min);
165
+ },
166
+ };
167
+ }
81
168
 
82
169
  /** Test hook — the reconstructed prompt+emitted stream (see header). */
83
170
  get history(): readonly number[] {
84
171
  return this.#hist;
85
172
  }
86
173
 
87
- prefill(promptIds: number[]): void {
174
+ prefill(promptIds: number[], _context?: Parameters<DraftSource["prefill"]>[1], processedTokens?: number): void {
88
175
  // Mirror the serve loop's prefill shape (two-model.ts does the same): under
89
176
  // the oracle tail split the last prompt token is never prefilled — it IS
90
177
  // the first feed. Legacy shape (kill switch / 1-token prompt): full prompt,
91
178
  // and the sampled token0 arrives as the first feed.
92
- const tailSplit = flagOn("MLX_BUN_PREFILL_TAIL_SPLIT", true);
93
- const upTo = tailSplit && promptIds.length > 1 ? promptIds.length - 1 : promptIds.length;
179
+ const upTo = processedTokens ?? (this.#tailSplit && promptIds.length > 1 ? promptIds.length - 1 : promptIds.length);
94
180
  this.#hist = promptIds.slice(0, upTo);
95
181
  }
96
182
 
@@ -111,21 +197,7 @@ class NgramSource implements DraftSource {
111
197
  /** Longest-k-first, first-occurrence prompt lookup (Saxena/vLLM order):
112
198
  * find the trailing k-gram earlier in history, propose what followed it. */
113
199
  #propose(n: number): number[] {
114
- const h = this.#hist;
115
- const L = h.length;
116
- const kTop = Math.min(this.max, L - 1);
117
- for (let k = kTop; k >= this.min; k--) {
118
- const tailAt = L - k;
119
- // First occurrence, left to right; i + k < L both excludes the trailing
120
- // self-match and guarantees at least one continuation token.
121
- search: for (let i = 0; i + k < L; i++) {
122
- for (let j = 0; j < k; j++) {
123
- if (h[i + j] !== h[tailAt + j]) continue search;
124
- }
125
- return h.slice(i + k, i + k + n);
126
- }
127
- }
128
- return [];
200
+ return proposeNgram(this.#hist, this.max, this.min, n);
129
201
  }
130
202
 
131
203
  dispose(): void {}
@@ -0,0 +1,73 @@
1
+ import type { MlxArray } from "../mlx/array";
2
+ import * as ops from "../mlx/ops";
3
+ import { KVCache, type Cache } from "../model/gemma4-base";
4
+ import { MlxProjectedContextRows } from "../backends/mlx/projected-context-rows";
5
+ import { captureCacheAttachment, restoreCacheAttachment } from "../backends/mlx/cache-attachment";
6
+ import { applyStateChanges, disposeResources } from "../engine/resources";
7
+ import type { PreparedStateChange } from "../contracts/resources";
8
+ import type { DraftPrefillGroup, DraftRowGroup, DraftRowCheckpoint, GroupedDraftProvider, TargetView } from "./source";
9
+
10
+ export interface ProjectedDraftMethod {
11
+ namespace: string;
12
+ schema: string;
13
+ layers: number;
14
+ tapLayers: readonly number[];
15
+ project(hidden: MlxArray, positions: number | MlxArray): { k: MlxArray; v: MlxArray }[];
16
+ draft(context: ReturnType<MlxProjectedContextRows["readAttention"]>, pending: readonly number[], positions: number | MlxArray, depth: number): number[][];
17
+ }
18
+
19
+ /** Provider numerics compose with shared context membership and persistence. */
20
+ class ProjectedDraftRows implements DraftPrefillGroup, DraftRowGroup {
21
+ readonly prefillMode = "tail-split" as const;
22
+ readonly tapLayers: readonly number[];
23
+ readonly context: MlxProjectedContextRows;
24
+ constructor(readonly method: ProjectedDraftMethod,
25
+ checkpoints: readonly (DraftRowCheckpoint | null)[]) {
26
+ this.tapLayers = method.tapLayers;
27
+ this.context = new MlxProjectedContextRows(method.layers, { project: method.project });
28
+ try { this.append(checkpoints); } catch (error) { this.dispose(); throw error; }
29
+ }
30
+ get namespace(): string { return this.method.namespace; }
31
+ get rowCount(): number { return this.context.state.rowCount; }
32
+ prepareAppend(checkpoints: readonly (DraftRowCheckpoint | null)[]): PreparedStateChange {
33
+ const restored: Cache[][] = [];
34
+ const empty = () => Array.from({ length: this.method.layers }, () => new KVCache());
35
+ try {
36
+ for (const checkpoint of checkpoints) restored.push(checkpoint ? restoreCacheAttachment(checkpoint.attachment, empty) : empty());
37
+ return this.context.prepareAppend(restored);
38
+ } finally { disposeResources(restored.flat()); }
39
+ }
40
+ append(checkpoints: readonly (DraftRowCheckpoint | null)[]): void {
41
+ if (checkpoints.length) applyStateChanges([() => this.prepareAppend(checkpoints)]);
42
+ }
43
+ prefill(tokens: MlxArray, hidden?: MlxArray): void {
44
+ this.context.append(hidden!, Array(this.rowCount).fill(tokens.shape[1]!)); this.materialize();
45
+ }
46
+ materialize(): void { this.context.materialize(); }
47
+ filterRows(keep: readonly number[]): void { this.context.state.filterRows(keep); }
48
+ capture(row: number): DraftRowCheckpoint {
49
+ const caches = this.context.state.extractRow(row);
50
+ try {
51
+ const processedTokens = caches[0]!.offset;
52
+ return { processedTokens, attachment: captureCacheAttachment(this.method.schema, caches, { processedTokens }) };
53
+ } finally { disposeResources(caches); }
54
+ }
55
+ draft(pending: readonly number[], depth: number): number[][] {
56
+ if (!depth) return pending.map(() => []);
57
+ const positions = this.context.positions, donors = this.context.readAttention();
58
+ using offsets = positions.every(position => position === positions[0]) ? null : ops.fromInt32([...positions], [pending.length]);
59
+ try { return this.method.draft(donors, pending, offsets ?? positions[0]!, depth); }
60
+ finally { donors.dispose(); }
61
+ }
62
+ commit(accepted: readonly number[], hidden: MlxArray): void {
63
+ this.context.append(hidden, accepted.map(count => count + 1));
64
+ }
65
+ dispose(): void { this.context.dispose(); }
66
+ }
67
+
68
+ export function projectedDraftGroups(bind: (target: TargetView) => ProjectedDraftMethod): GroupedDraftProvider {
69
+ const open = ({ target, checkpoints }: {
70
+ target: TargetView; checkpoints: readonly (DraftRowCheckpoint | null)[];
71
+ }) => new ProjectedDraftRows(bind(target), checkpoints);
72
+ return { open, openPrefill: open };
73
+ }
@@ -0,0 +1,171 @@
1
+ // Qwen MTP numerical graph. Request state, sampling and checkpoint retention
2
+ // belong to its callers; the cache supplies positions and attention storage.
3
+ import type { MlxArray } from "../mlx/array";
4
+ import * as ops from "../mlx/ops";
5
+ import type { ModelConfig } from "../config";
6
+ import type { Weights } from "../weights";
7
+ import { disposing, QuantizedLinear, RMSNorm, type Cache, type Mask } from "../model/gemma4-base";
8
+ import { DenseLinear } from "../model/universal/modules";
9
+ import { compiledSwiglu } from "../model/qwen3_5";
10
+
11
+ type MtpLinear = DenseLinear | QuantizedLinear;
12
+
13
+ /** Projection tensors belong to Weights; dense transpose views belong to
14
+ * the provider's resource stack. Quantized heads keep no dense copy. */
15
+ function loadMtpLinear(
16
+ weights: Weights, path: string, config: ModelConfig, resources: DisposableStack,
17
+ ): MtpLinear {
18
+ if (weights.has(`${path}.scales`)) return QuantizedLinear.load(weights, path, config);
19
+ const layer = new DenseLinear(weights.tensor(`${path}.weight`), null);
20
+ resources.use(layer.wT);
21
+ return layer;
22
+ }
23
+
24
+ /** Qwen3Attention.forward with the companion's dense or quantized projections.
25
+ * Attention operations follow src/model/qwen3_5.ts verbatim. */
26
+ class MtpAttention {
27
+ readonly qProj: MtpLinear;
28
+ readonly kProj: MtpLinear;
29
+ readonly vProj: MtpLinear;
30
+ readonly oProj: MtpLinear;
31
+ readonly qNorm: RMSNorm;
32
+ readonly kNorm: RMSNorm;
33
+ readonly nHeads: number;
34
+ readonly nKvHeads: number;
35
+ readonly headDim: number;
36
+ readonly scale: number;
37
+ readonly ropeDims: number;
38
+ readonly ropeBase: number;
39
+
40
+ constructor(weights: Weights, config: ModelConfig, prefix: string, resources: DisposableStack) {
41
+ const t = config.text;
42
+ this.nHeads = t.numAttentionHeads;
43
+ this.nKvHeads = t.numKeyValueHeads;
44
+ this.headDim = t.headDim;
45
+ this.scale = Math.pow(this.headDim, -0.5);
46
+ this.ropeDims = Math.trunc(this.headDim * t.partialRotaryFactor);
47
+ this.ropeBase = t.ropeParameters.full_attention?.ropeTheta ?? 10000;
48
+ this.qProj = loadMtpLinear(weights, `${prefix}.q_proj`, config, resources);
49
+ this.kProj = loadMtpLinear(weights, `${prefix}.k_proj`, config, resources);
50
+ this.vProj = loadMtpLinear(weights, `${prefix}.v_proj`, config, resources);
51
+ this.oProj = loadMtpLinear(weights, `${prefix}.o_proj`, config, resources);
52
+ this.qNorm = new RMSNorm(weights.tensor(`${prefix}.q_norm.weight`), t.rmsNormEps);
53
+ this.kNorm = new RMSNorm(weights.tensor(`${prefix}.k_norm.weight`), t.rmsNormEps);
54
+ }
55
+
56
+ forward(x: MlxArray, mask: Mask, cache: Cache): MlxArray {
57
+ const [B, L] = x.shape as [number, number, number];
58
+ const qp = this.qProj.forward(x);
59
+ const qpr = disposing(qp, ops.reshape(qp, [B, L, this.nHeads, this.headDim * 2]));
60
+ const [qHeads, gateHeads] = ops.split(qpr, [this.headDim], -1) as [MlxArray, MlxArray];
61
+ qpr.dispose();
62
+ const gate = disposing(gateHeads, ops.reshape(gateHeads, [B, L, this.nHeads * this.headDim]));
63
+
64
+ let k = this.kProj.forward(x);
65
+ let v = this.vProj.forward(x);
66
+
67
+ let q = this.qNorm.forward(qHeads);
68
+ qHeads.dispose();
69
+ q = disposing(q, ops.transposeAxes(q, [0, 2, 1, 3]));
70
+ k = disposing(k, ops.reshape(k, [B, L, this.nKvHeads, this.headDim]));
71
+ k = disposing(k, this.kNorm.forward(k));
72
+ k = disposing(k, ops.transposeAxes(k, [0, 2, 1, 3]));
73
+ v = disposing(v, ops.reshape(v, [B, L, this.nKvHeads, this.headDim]));
74
+ v = disposing(v, ops.transposeAxes(v, [0, 2, 1, 3]));
75
+
76
+ const offsets = cache.ropeOffsetArr;
77
+ q = disposing(q, offsets
78
+ ? ops.ropeDynamic(q, this.ropeDims, this.ropeBase, offsets, null)
79
+ : ops.rope(q, this.ropeDims, this.ropeBase, cache.offset, null));
80
+ k = disposing(k, offsets
81
+ ? ops.ropeDynamic(k, this.ropeDims, this.ropeBase, offsets, null)
82
+ : ops.rope(k, this.ropeDims, this.ropeBase, cache.offset, null));
83
+
84
+ const [keys, values] = cache.updateAndFetch(k, v);
85
+ k.dispose();
86
+ v.dispose();
87
+ const attn = ops.sdpa(q, keys, values, this.scale, mask.mode, mask.arr);
88
+ keys.dispose();
89
+ values.dispose();
90
+ q.dispose();
91
+
92
+ const attnT = ops.transposeAxes(attn, [0, 2, 1, 3]);
93
+ attn.dispose();
94
+ const merged = ops.reshape(attnT, [B, L, -1]);
95
+ attnT.dispose();
96
+ const sig = ops.sigmoid(gate);
97
+ gate.dispose();
98
+ const gated = ops.mul(merged, sig);
99
+ merged.dispose();
100
+ sig.dispose();
101
+ const out = this.oProj.forward(gated);
102
+ gated.dispose();
103
+ return out;
104
+ }
105
+ }
106
+
107
+ /** The one MTP decoder block: fc-merge → attention → swiglu MLP → norm. */
108
+ export class MtpModule {
109
+ readonly fc: MtpLinear;
110
+ readonly preFcNormEmbedding: RMSNorm;
111
+ readonly preFcNormHidden: RMSNorm;
112
+ readonly attn: MtpAttention;
113
+ readonly mlpGate: MtpLinear;
114
+ readonly mlpUp: MtpLinear;
115
+ readonly mlpDown: MtpLinear;
116
+ readonly inputNorm: RMSNorm;
117
+ readonly postAttnNorm: RMSNorm;
118
+ readonly finalNorm: RMSNorm;
119
+
120
+ constructor(weights: Weights, config: ModelConfig, resources: DisposableStack) {
121
+ const eps = config.text.rmsNormEps;
122
+ this.fc = loadMtpLinear(weights, "fc", config, resources);
123
+ this.preFcNormEmbedding = new RMSNorm(weights.tensor("pre_fc_norm_embedding.weight"), eps);
124
+ this.preFcNormHidden = new RMSNorm(weights.tensor("pre_fc_norm_hidden.weight"), eps);
125
+ this.attn = new MtpAttention(weights, config, "layers.0.self_attn", resources);
126
+ this.mlpGate = loadMtpLinear(weights, "layers.0.mlp.gate_proj", config, resources);
127
+ this.mlpUp = loadMtpLinear(weights, "layers.0.mlp.up_proj", config, resources);
128
+ this.mlpDown = loadMtpLinear(weights, "layers.0.mlp.down_proj", config, resources);
129
+ this.inputNorm = new RMSNorm(weights.tensor("layers.0.input_layernorm.weight"), eps);
130
+ this.postAttnNorm = new RMSNorm(weights.tensor("layers.0.post_attention_layernorm.weight"), eps);
131
+ this.finalNorm = new RMSNorm(weights.tensor("norm.weight"), eps);
132
+ }
133
+
134
+ /** One block forward over [B,S,·]: token embeddings ([B,S,H], target
135
+ * embed_tokens output) paired with hiddens ([B,S,H], target pre-final-norm
136
+ * or the module's own chained output). Appends S rows to `cache`; returns
137
+ * the module output [B,S,H] (post final norm — what the target lm_head
138
+ * consumes AND what chains into the next step's `hidden`). */
139
+ forward(tokenEmbeds: MlxArray, hiddens: MlxArray, cache: Cache): MlxArray {
140
+ const embNorm = this.preFcNormEmbedding.forward(tokenEmbeds);
141
+ const hidNorm = this.preFcNormHidden.forward(hiddens);
142
+ const joined = ops.concatAxis([embNorm, hidNorm], 2);
143
+ embNorm.dispose();
144
+ hidNorm.dispose();
145
+ const x = this.fc.forward(joined);
146
+ joined.dispose();
147
+
148
+ // Decoder layer (Qwen3Layer.forward shape).
149
+ const L = x.shape[1]!;
150
+ const mask = cache.makeMask(L, null);
151
+ const xn = this.inputNorm.forward(x);
152
+ const r = this.attn.forward(xn, mask, cache);
153
+ xn.dispose();
154
+ mask.arr?.dispose();
155
+ const h = ops.add(x, r);
156
+ x.dispose();
157
+ r.dispose();
158
+ const hn = this.postAttnNorm.forward(h);
159
+ const g = this.mlpGate.forward(hn);
160
+ const u = this.mlpUp.forward(hn);
161
+ hn.dispose();
162
+ const act = compiledSwiglu(g, u);
163
+ g.dispose(); u.dispose();
164
+ const m = this.mlpDown.forward(act);
165
+ act.dispose();
166
+ const out = ops.add(h, m);
167
+ h.dispose();
168
+ m.dispose();
169
+ return disposing(out, this.finalNorm.forward(out));
170
+ }
171
+ }