mlx-bun 0.0.10 → 0.0.12

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 (216) hide show
  1. package/README.md +107 -32
  2. package/THIRD_PARTY_LICENSES.md +10 -0
  3. package/docs/archive/mlx-bun-lab-report.html +275 -0
  4. package/docs/archive/wwdc-mlx-bun.html +848 -0
  5. package/docs/design/audio-input-plan.md +356 -0
  6. package/docs/design/batching-perf-path.md +58 -35
  7. package/docs/design/batching-v2-plan.md +23 -9
  8. package/docs/design/bucketing-stage.md +2 -1
  9. package/docs/design/colibri-glm52-port.md +1479 -0
  10. package/docs/design/compat-cli-surface-design.md +4 -3
  11. package/docs/design/decode-speed-program.md +172 -0
  12. package/docs/design/docs-reorg-plan.md +1 -1
  13. package/docs/design/dspark-serving-program.md +371 -0
  14. package/docs/design/dspark-speculative-decoding.md +167 -5
  15. package/docs/design/faithful-l1-consolidation.md +310 -0
  16. package/docs/design/generic-model-support.md +8 -5
  17. package/docs/design/grammar-spec-batching-integration.md +301 -0
  18. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  19. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  20. package/docs/design/omlx-adoption-map.md +14 -6
  21. package/docs/design/orpo-training.md +34 -30
  22. package/docs/design/paged-kv-cache.md +214 -0
  23. package/docs/design/parallel-slots.md +82 -49
  24. package/docs/design/parity-tier-dag.md +43 -33
  25. package/docs/design/pre-colibri-stabilization.md +329 -0
  26. package/docs/design/repo-cleanup-plan.md +189 -0
  27. package/docs/design/runtime-isolation.md +19 -3
  28. package/docs/design/segmented-backward-training.md +3 -2
  29. package/docs/design/ssd-kv-cold-tier.md +130 -0
  30. package/docs/design/structured-output.md +484 -0
  31. package/docs/design/the-dreaming-master-plan.md +2 -3
  32. package/docs/design/turboquant-kv.md +235 -0
  33. package/docs/design/unified-engine-frontier-plan.md +774 -0
  34. package/docs/design/web-chat-beat-matrix.md +315 -0
  35. package/docs/design/web-chat-redesign.md +1112 -0
  36. package/docs/investigations/colibri-oracle-pin.md +1284 -0
  37. package/docs/investigations/dspark-handoff.md +182 -16
  38. package/docs/investigations/indexshare-performance-spike.md +357 -0
  39. package/docs/investigations/lab-build-journal.md +2 -2
  40. package/docs/investigations/openwiki-evaluation.md +249 -0
  41. package/docs/planning/IDEAS.md +14 -0
  42. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  43. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  44. package/docs/planning/chat-ui-vision.md +50 -0
  45. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  46. package/docs/planning/release-notes-v0.0.11.md +239 -0
  47. package/docs/planning/release-notes-v0.0.12.md +80 -0
  48. package/docs/reference/cli.md +63 -18
  49. package/docs/reference/distribution.md +37 -6
  50. package/docs/reference/embedding.md +40 -16
  51. package/docs/reference/features-matrix.md +99 -0
  52. package/docs/reference/library-api.md +69 -20
  53. package/docs/reference/memory.md +26 -6
  54. package/docs/reference/models.md +60 -3
  55. package/docs/reference/orpo-quickstart.md +3 -4
  56. package/docs/reference/server-api.md +717 -24
  57. package/docs/reference/server-config.md +265 -129
  58. package/docs/reference/training.md +24 -16
  59. package/package.json +5 -3
  60. package/src/anthropic.ts +67 -44
  61. package/src/audio/conformer.ts +647 -0
  62. package/src/audio/decode.ts +133 -0
  63. package/src/audio/features.ts +202 -0
  64. package/src/audio/transcode.ts +67 -0
  65. package/src/chat-template.ts +155 -10
  66. package/src/cli.ts +483 -113
  67. package/src/config.ts +116 -24
  68. package/src/diffusion/diffusion-generate.ts +14 -8
  69. package/src/download.ts +143 -12
  70. package/src/eval/README.md +7 -6
  71. package/src/eval/ifeval.ts +63 -120
  72. package/src/eval/kl.ts +59 -3
  73. package/src/eval/quality-db.ts +2 -2
  74. package/src/eval/runner.ts +17 -8
  75. package/src/eval/tasks/ifeval.ts +167 -31
  76. package/src/expert-io.ts +391 -0
  77. package/src/expert-offload.ts +15 -2
  78. package/src/expert-residency.ts +1134 -0
  79. package/src/expert-usage.ts +415 -0
  80. package/src/flags.ts +13 -0
  81. package/src/generate.ts +438 -35
  82. package/src/grammar.ts +585 -0
  83. package/src/hf-push.ts +1 -1
  84. package/src/hub-rest.ts +242 -0
  85. package/src/index.ts +14 -2
  86. package/src/jobs/runner.ts +37 -18
  87. package/src/kv-store.ts +586 -93
  88. package/src/lora.ts +85 -14
  89. package/src/md-modules.d.ts +42 -0
  90. package/src/media-fetch.ts +426 -0
  91. package/src/memory/rest.ts +245 -0
  92. package/src/memory/vault.ts +74 -0
  93. package/src/mlx/array.ts +149 -19
  94. package/src/mlx/autograd.ts +53 -16
  95. package/src/mlx/checkpoint.ts +46 -13
  96. package/src/mlx/custom-vjp.ts +83 -35
  97. package/src/mlx/ffi.ts +27 -3
  98. package/src/mlx/metal-kernel.ts +7 -4
  99. package/src/mlx/ops.ts +153 -23
  100. package/src/mlx/turboquant-ops.ts +831 -0
  101. package/src/mlx/turboquant-tables.ts +237 -0
  102. package/src/model/batched-mask.ts +62 -1
  103. package/src/model/batched-quant.ts +206 -0
  104. package/src/model/batched-rotating-quant.ts +346 -0
  105. package/src/model/batched-rotating.ts +46 -1
  106. package/src/model/compiled-decode.ts +4 -15
  107. package/src/model/factory.ts +103 -6
  108. package/src/model/gemma4-base.ts +395 -39
  109. package/src/model/gemma4.ts +122 -33
  110. package/src/model/generated/gemma4-12b.ts +9 -13
  111. package/src/model/generated/gemma4-26b.ts +10 -5
  112. package/src/model/generated/gemma4-e4b.ts +14 -9
  113. package/src/model/glm52-atlas.ts +511 -0
  114. package/src/model/glm52-cache.ts +706 -0
  115. package/src/model/glm52-config.ts +262 -0
  116. package/src/model/glm52-container.ts +305 -0
  117. package/src/model/glm52-coupling.ts +388 -0
  118. package/src/model/glm52-dsa.ts +602 -0
  119. package/src/model/glm52-expert-layout.ts +253 -0
  120. package/src/model/glm52-layout.ts +152 -0
  121. package/src/model/glm52-memory.ts +473 -0
  122. package/src/model/glm52-mla.ts +786 -0
  123. package/src/model/glm52-moe.ts +223 -0
  124. package/src/model/glm52-pilot.ts +537 -0
  125. package/src/model/glm52-reference.ts +445 -0
  126. package/src/model/glm52-residency.ts +557 -0
  127. package/src/model/glm52-resident-weights.ts +135 -0
  128. package/src/model/glm52-streamed-experts.ts +453 -0
  129. package/src/model/glm52-streamed-metal.ts +610 -0
  130. package/src/model/glm52-weights.ts +503 -0
  131. package/src/model/glm52.ts +1153 -0
  132. package/src/model/minicpm5.ts +58 -102
  133. package/src/model/paged-kv.ts +253 -0
  134. package/src/model/qwen3-delta.ts +76 -15
  135. package/src/model/qwen3-moe.ts +404 -0
  136. package/src/model/qwen3.ts +9 -8
  137. package/src/model/qwen3_5.ts +88 -23
  138. package/src/model/support.ts +19 -1
  139. package/src/model/universal/dense.ts +40 -5
  140. package/src/model/universal/rope.ts +36 -21
  141. package/src/native/expert_io.c +523 -0
  142. package/src/native-pack.ts +6 -4
  143. package/src/pi-web.ts +1299 -82
  144. package/src/preflight.ts +27 -8
  145. package/src/prompt-cache.ts +293 -33
  146. package/src/quantize/quantizer.ts +16 -1
  147. package/src/registry.ts +57 -2
  148. package/src/responses.ts +62 -49
  149. package/src/serve/batch-scheduler.ts +1050 -78
  150. package/src/serve/generation-gateway.ts +252 -24
  151. package/src/serve/isolate.ts +417 -0
  152. package/src/serve/lane-registry.ts +44 -0
  153. package/src/serve/session-search.ts +326 -0
  154. package/src/server.ts +1701 -175
  155. package/src/spec/assistant-source.ts +150 -0
  156. package/src/spec/deepspec-source.ts +143 -0
  157. package/src/spec/dflash-source.ts +124 -0
  158. package/src/spec/dspark/ab-stats.ts +136 -0
  159. package/src/spec/dspark/calibration.ts +112 -0
  160. package/src/spec/dspark/deepspec-module.ts +801 -0
  161. package/src/spec/dspark/generate-dflash.ts +72 -20
  162. package/src/spec/dspark/loader.ts +23 -0
  163. package/src/spec/dspark/module-dflash.ts +278 -36
  164. package/src/spec/dspark/quantize-drafter.ts +91 -0
  165. package/src/spec/generate.ts +59 -25
  166. package/src/spec/glm52-mtp-source.ts +392 -0
  167. package/src/spec/ngram-source.ts +132 -0
  168. package/src/spec/serve-loop.ts +617 -0
  169. package/src/spec/source.ts +118 -0
  170. package/src/spec/two-model.ts +171 -0
  171. package/src/ssd-cache.ts +111 -35
  172. package/src/tokenizer.ts +288 -2
  173. package/src/tool-approvals.ts +131 -0
  174. package/src/tool-call.ts +438 -22
  175. package/src/train/flash-cce.ts +2 -2
  176. package/src/train/job.ts +0 -12
  177. package/src/train/lora-params.ts +17 -6
  178. package/src/train/trainer.ts +60 -30
  179. package/src/vision/prompt.ts +217 -48
  180. package/src/web/app.html +1197 -1791
  181. package/src/web/app.js +5694 -0
  182. package/src/web/icon.svg +13 -0
  183. package/src/web/manifest.webmanifest +24 -0
  184. package/src/web/src/adapters-panel.ts +288 -0
  185. package/src/web/src/api.ts +57 -0
  186. package/src/web/src/assistant.ts +485 -0
  187. package/src/web/src/chat.ts +1050 -0
  188. package/src/web/src/composer.ts +959 -0
  189. package/src/web/src/dataset.ts +132 -0
  190. package/src/web/src/finetune.ts +267 -0
  191. package/src/web/src/hub.ts +428 -0
  192. package/src/web/src/main.ts +98 -0
  193. package/src/web/src/markdown.ts +0 -0
  194. package/src/web/src/memory-panel.ts +572 -0
  195. package/src/web/src/model-picker.ts +159 -0
  196. package/src/web/src/palette.ts +346 -0
  197. package/src/web/src/protocol.ts +62 -0
  198. package/src/web/src/quantize.ts +167 -0
  199. package/src/web/src/rag.ts +274 -0
  200. package/src/web/src/sessions.ts +469 -0
  201. package/src/web/src/shell.ts +804 -0
  202. package/src/web/src/status.ts +300 -0
  203. package/src/web/src/ui-catalog.ts +92 -0
  204. package/src/web/sw.js +71 -0
  205. package/src/web/vendor/README.md +61 -0
  206. package/src/web/vendor/hljs-LICENSE +29 -0
  207. package/src/web/vendor/hljs-theme.css +59 -0
  208. package/src/web/vendor/hljs.js +26 -0
  209. package/src/web-tools.ts +54 -2
  210. package/src/weights.ts +82 -19
  211. package/src/model/fused-decode-kernel.ts +0 -312
  212. package/src/model/fused-geglu-kernel.ts +0 -198
  213. package/src/model/fused-mlp-kernel.ts +0 -169
  214. package/src/model/fused-swiglu-kernel.ts +0 -240
  215. package/src/model/steel-linear-kernel.ts +0 -96
  216. package/src/status-page.html +0 -497
@@ -6,6 +6,7 @@ import type { Weights } from "../weights";
6
6
  import { MlxArray } from "../mlx/array";
7
7
  import { Dtype } from "../mlx/ffi";
8
8
  import * as ops from "../mlx/ops";
9
+ import { unrotateValues as tqUnrotateValues } from "../mlx/turboquant-ops";
9
10
  import {
10
11
  argmaxLastPosition,
11
12
  disposeTriple,
@@ -13,6 +14,7 @@ import {
13
14
  KVCache,
14
15
  LoraState,
15
16
  QuantizedKVCache,
17
+ TurboQuantKVCache,
16
18
  QuantizedEmbedding,
17
19
  QuantizedLinear,
18
20
  quantizedSdpa,
@@ -21,52 +23,27 @@ import {
21
23
  type Cache,
22
24
  type Mask,
23
25
  } from "./gemma4-base";
24
- import {
25
- fusedSwiglu,
26
- fusedSwigluEnabled,
27
- fusedSwigluSupported,
28
- swigluMMin,
29
- swigluUnderCap,
30
- } from "./fused-swiglu-kernel";
31
- import { steelLinear } from "./steel-linear-kernel";
32
- import { fusedMlp } from "./fused-mlp-kernel";
33
-
34
- /** Break the graph at a boundary: contiguous → eval → fresh graph-free leaf — the
35
- * exact primitive the segmented TRAINER uses so MLX never plans across boundaries
36
- * and prior-segment activations are freed (copy of train/segmented.ts detachLeaf). */
37
- function detachLeaf(a: MlxArray): MlxArray {
38
- const c = ops.contiguous(a);
39
- c.eval();
40
- const leaf = MlxArray.fromBytesCopy(c.rawBytes(), c.shape, c.dtype);
41
- c.dispose();
42
- a.dispose();
43
- return leaf;
44
- }
26
+ import { CompiledFunction } from "../mlx/compile";
45
27
 
46
- /** A quantized projection as OUR steel dispatch when the controlled path is live
47
- * (no adapter, affine, clean tiling, M>1 so it's not a decode GEMV); otherwise
48
- * the normal linear. x is [B,L,H] or [M,H]. */
49
- function ourLinear(lin: QuantizedLinear, x: MlxArray): MlxArray {
50
- const H = x.shape[x.shape.length - 1]!;
51
- const M = x.shape.reduce((a, b) => a * b, 1) / H;
52
- if (
53
- !fusedSwigluEnabled() || isCompiledTrace() || M < swigluMMin() ||
54
- lin.adapters != null || lin.biases == null ||
55
- (x.dtype !== Dtype.bfloat16 && x.dtype !== Dtype.float16) ||
56
- H % 32 !== 0 || lin.spec.groupSize % 32 !== 0
57
- ) {
58
- return lin.forward(x);
28
+ /** Verbatim port of mlx_lm/models/activations.py:
29
+ * `@partial(mx.compile, shapeless=True) def swiglu(gate, x): return nn.silu(gate) * x`
30
+ * mx.compile fuses sigmoid + mul + mul into ONE kernel (mlx-lm's decode
31
+ * `CV2ISigmoid…Multiply`) instead of our three separate dispatches. Traced
32
+ * once, replayed thereafter. Used on the decode (M=1) path only. */
33
+ let _swigluClosure: CompiledFunction | null = null;
34
+ export function compiledSwiglu(gate: MlxArray, up: MlxArray): MlxArray {
35
+ if (!_swigluClosure) {
36
+ _swigluClosure = new CompiledFunction((inputs) => {
37
+ const g = inputs[0]!, u = inputs[1]!; // nn.silu(gate) * x
38
+ const sig = ops.sigmoid(g);
39
+ const silu = ops.mul(g, sig); sig.dispose();
40
+ const out = ops.mul(silu, u); silu.dispose();
41
+ return [out];
42
+ });
59
43
  }
60
- const x2 = x.shape.length === 2 ? x : ops.reshape(x, [M, H]);
61
- const y = steelLinear(x2, lin.w, lin.scales, lin.biases, lin.spec);
62
- if (x2 !== x) x2.dispose();
63
- return x.shape.length === 2
64
- ? y
65
- : disposing(y, ops.reshape(y, [...x.shape.slice(0, -1), lin.w.shape[0]!]));
44
+ return _swigluClosure.apply([gate, up])[0]!;
66
45
  }
67
46
 
68
- /** How often the inference fused path materializes the residual stream (layers). */
69
- const EVAL_EVERY = Math.max(1, Number(process.env.MLX_BUN_SWIGLU_EVAL_EVERY) || 1);
70
47
  import { flashAttention, getTrainingAttn, flashSupported } from "./flash-attention";
71
48
 
72
49
  // Shared prompt-prefix plan (lever 7). When set, the attention ropes the
@@ -83,6 +60,11 @@ let _prefixPlan: PrefixPlan | null = null;
83
60
  export function setMiniCpmPrefixPlan(p: PrefixPlan | null): void {
84
61
  _prefixPlan = p;
85
62
  }
63
+ /** True while a block-wise prefix-shared RoPE plan is active (ORPO forward).
64
+ * The generated debranched fast path must fall back to the monolith then. */
65
+ export function miniCpmPrefixPlanActive(): boolean {
66
+ return _prefixPlan != null;
67
+ }
86
68
 
87
69
  /** Block-wise RoPE for the prefix-shared concat [prompt; chosen; rejected] along
88
70
  * the sequence axis (axis 2 of [B,H,T,D]): prompt rotated at offset 0, each
@@ -108,7 +90,7 @@ function ropeBlocks(x: MlxArray, dims: number, base: number, plan: PrefixPlan):
108
90
  return out;
109
91
  }
110
92
 
111
- class LlamaAttention {
93
+ export class LlamaAttention {
112
94
  readonly qProj: QuantizedLinear;
113
95
  readonly kProj: QuantizedLinear;
114
96
  readonly vProj: QuantizedLinear;
@@ -167,6 +149,17 @@ class LlamaAttention {
167
149
  attn = quantizedSdpa(q, keys, values, this.scale, mask, cache.groupSize, cache.bits);
168
150
  disposeTriple(keys);
169
151
  disposeTriple(values);
152
+ } else if (cache instanceof TurboQuantKVCache) {
153
+ // Deferred-V: sdpa runs on rotated values, then one InvFWHT on the
154
+ // output (linear in V) — see TurboQuantKVCache.updateAndFetchDeferredV.
155
+ const [keys, values] = cache.updateAndFetchDeferredV(k, v);
156
+ k.dispose();
157
+ v.dispose();
158
+ const rotated = ops.sdpa(q, keys, values, this.scale, mask.mode, mask.arr);
159
+ keys.dispose();
160
+ values.dispose();
161
+ attn = tqUnrotateValues(rotated);
162
+ rotated.dispose();
170
163
  } else {
171
164
  const [keys, values] = cache.updateAndFetch(k, v);
172
165
  k.dispose();
@@ -193,7 +186,7 @@ class LlamaAttention {
193
186
  }
194
187
  }
195
188
 
196
- class LlamaMLP {
189
+ export class LlamaMLP {
197
190
  readonly gate: QuantizedLinear;
198
191
  readonly up: QuantizedLinear;
199
192
  readonly down: QuantizedLinear;
@@ -204,49 +197,29 @@ class LlamaMLP {
204
197
  this.down = QuantizedLinear.load(weights, `${prefix}.down_proj`, config);
205
198
  }
206
199
 
207
- /** MLP. When `residual` (the layer input h) is given AND the fused path applies,
208
- * the kernel computes h + mlp(x) directly — the residual is ABSORBED, so nothing
209
- * stays live across the kernel for MLX's planner to alias (it just runs our one
210
- * dispatch). Otherwise returns mlp(x) and the caller adds the residual. */
200
+ /** MLP: returns mlp(x); the caller adds the residual when given. */
211
201
  forward(x: MlxArray, residual?: MlxArray): MlxArray {
212
202
  const H = x.shape[x.shape.length - 1]!;
213
203
  const M = x.shape.reduce((a, b) => a * b, 1) / H;
214
- if (
215
- residual != null &&
216
- fusedSwigluEnabled() && !isCompiledTrace() && M >= swigluMMin() && swigluUnderCap() &&
217
- this.gate.adapters == null && this.up.adapters == null && this.down.adapters == null &&
218
- this.down.biases != null && this.down.spec.groupSize % 32 === 0 &&
219
- fusedSwigluSupported(x, this.gate.spec, this.up.spec, this.gate.biases, this.up.biases)
220
- ) {
221
- // The WHOLE MLP + residual in ONE kernel — gate+up+silu+down then +h, `hidden`
222
- // never materialized, residual absorbed. MLX runs one dispatch; nothing for
223
- // its planner to alias → chains across layers with no boundary.
224
- const Hin = this.gate.scales.shape[1]! * this.gate.spec.groupSize;
225
- const Ho = this.down.w.shape[0]!;
226
- const x2 = x.shape.length === 2 ? x : ops.reshape(x, [M, Hin]);
227
- const h2 = residual.shape.length === 2 ? residual : ops.reshape(residual, [M, Ho]);
228
- const out2 = fusedMlp(
229
- x2,
230
- this.gate.w, this.gate.scales, this.gate.biases!, this.gate.spec,
231
- this.up.w, this.up.scales, this.up.biases!, this.up.spec,
232
- this.down.w, this.down.scales, this.down.biases!, this.down.spec,
233
- h2,
234
- );
235
- if (x2 !== x) x2.dispose();
236
- if (h2 !== residual) h2.dispose();
237
- return x.shape.length === 2
238
- ? out2
239
- : disposing(out2, ops.reshape(out2, [...x.shape.slice(0, -1), Ho]));
240
- }
241
204
  const gate = this.gate.forward(x);
242
205
  const up = this.up.forward(x);
243
- const sig = ops.sigmoid(gate);
244
- const silu = ops.mul(gate, sig);
245
- gate.dispose();
246
- sig.dispose();
247
- const hidden = ops.mul(silu, up);
248
- silu.dispose();
249
- up.dispose();
206
+ // Decode (M=1): fuse silu·up into one kernel (mlx_lm activations.py swiglu).
207
+ // Plain sigmoid+mul+mul otherwise (M>1 prefill/training, or inside a compile
208
+ // trace) — identical math either way. MLX_BUN_COMPILED_SWIGLU=0 disables.
209
+ let hidden: MlxArray;
210
+ if (M === 1 && !isCompiledTrace() && process.env.MLX_BUN_COMPILED_SWIGLU !== "0") {
211
+ hidden = compiledSwiglu(gate, up);
212
+ gate.dispose();
213
+ up.dispose();
214
+ } else {
215
+ const sig = ops.sigmoid(gate);
216
+ const silu = ops.mul(gate, sig);
217
+ gate.dispose();
218
+ sig.dispose();
219
+ hidden = ops.mul(silu, up);
220
+ silu.dispose();
221
+ up.dispose();
222
+ }
250
223
  const m = this.down.forward(hidden);
251
224
  hidden.dispose();
252
225
  if (residual == null) return m;
@@ -254,7 +227,7 @@ class LlamaMLP {
254
227
  }
255
228
  }
256
229
 
257
- class LlamaLayer {
230
+ export class LlamaLayer {
258
231
  readonly attn: LlamaAttention;
259
232
  readonly mlp: LlamaMLP;
260
233
  readonly inputNorm: RMSNorm;
@@ -337,15 +310,7 @@ export class MiniCPM5Model {
337
310
  }
338
311
 
339
312
  protected forwardLayers(h0: MlxArray, cache: Cache[]): MlxArray {
340
- // When the fused SwiGLU path is live, WE own the eval schedule: materialize
341
- // each layer's output so the custom kernel sits in a shallow per-layer graph
342
- // rather than a 24-deep lazy graph whose buffer planner aliases the kernel's
343
- // output (the deep-graph corruption). Training/segmented-backward call
344
- // runLayerRange directly and never set this, so autograd is untouched.
345
- const h = this.runLayerRange(
346
- h0, 0, this.layers.length, cache,
347
- fusedSwigluEnabled() && process.env.MLX_BUN_SWIGLU_NOEVAL !== "1",
348
- );
313
+ const h = this.runLayerRange(h0, 0, this.layers.length, cache);
349
314
  h0.dispose(); // runLayerRange keeps its input alive; we own h0 here
350
315
  return disposing(h, this.finalNorm.forward(h));
351
316
  }
@@ -358,7 +323,7 @@ export class MiniCPM5Model {
358
323
  * segmented-backward training path (docs/design/segmented-backward-training.md).
359
324
  * MiniCPM5 is plain full-attention with no KV-sharing, so each layer reads
360
325
  * its own stateless `cache[i]` and a single causal mask. */
361
- runLayerRange(h: MlxArray, aIdx: number, bIdx: number, cache: Cache[], evalPerLayer = false): MlxArray {
326
+ runLayerRange(h: MlxArray, aIdx: number, bIdx: number, cache: Cache[]): MlxArray {
362
327
  if (aIdx >= bIdx) return h;
363
328
  const L = h.shape[1]!;
364
329
  const mask = cache[aIdx]!.makeMask(L, null);
@@ -367,15 +332,6 @@ export class MiniCPM5Model {
367
332
  const next = this.layers[i]!.forward(cur, mask, cache[i]!);
368
333
  if (i > aIdx) cur.dispose(); // keep the input boundary leaf; drop interiors
369
334
  cur = next;
370
- // Per-layer boundary (segmented-trainer pattern): materialize each layer's
371
- // output so MLX never plans across layers — our single-dispatch fused MLP is
372
- // the only custom kernel it runs per layer. evalAll (GPU sync, no host copy)
373
- // suffices for one kernel/boundary; MLX_BUN_SWIGLU_DETACH=1 forces the
374
- // stronger graph-breaking detachLeaf copy.
375
- if (evalPerLayer) {
376
- if (process.env.MLX_BUN_SWIGLU_DETACH === "1") cur = detachLeaf(cur);
377
- else ops.evalAll([cur]);
378
- }
379
335
  }
380
336
  mask.arr?.dispose();
381
337
  return cur;
@@ -0,0 +1,253 @@
1
+ // Paged KV cache — vLLM-style block-pool storage behind the standard Cache
2
+ // interface (docs/design/paged-kv-cache.md). OPTIONAL and default-off
3
+ // (`--paged-kv` / GenerateOptions.pagedKv); v1 scope is serial batch=1
4
+ // decode on Gemma4-family plain full-attention layers, bf16 only.
5
+ //
6
+ // Storage: K/V live in fixed-size per-layer pool tensors
7
+ // [numBlocks, H_kv, blockSize, headDim]; a host-side block table maps the
8
+ // sequence's logical block order to physical pool slots. Writes go through
9
+ // ops.sliceUpdate into the tail block; reads gather the occupied blocks
10
+ // back into ONE contiguous [1, H, S, D] pair (ops.takeAxis over the pool's
11
+ // block axis + transpose + reshape) and hand that to the unchanged
12
+ // ops.sdpa call site — identical bytes to what a plain KVCache would have
13
+ // fetched, so the paged path is gated bit-exact, not KL-tolerated
14
+ // (tests/paged-kv-parity.test.ts). The gather IS the cost: a full K/V copy
15
+ // per step, pure bandwidth tax at batch=1 — v1 ships the abstraction for
16
+ // the batched/CoW follow-ups, not a speed win (see the design doc's
17
+ // Motivation, which says this honestly).
18
+ //
19
+ // Deliberately NOT a KVCache subclass (the TurboQuantKVCache reasoning):
20
+ // every instanceof gate in the tree — CompiledDecode.supports, the batch
21
+ // gateway's #modelCachesBatchable, generated forwards' #matches() — must
22
+ // EXCLUDE paged caches and fall back to the monolith/uncompiled path.
23
+
24
+ import { MlxArray } from "../mlx/array";
25
+ import { Dtype } from "../mlx/ffi";
26
+ import * as ops from "../mlx/ops";
27
+ import { createCausalMask, type Cache, type Mask } from "./gemma4-base";
28
+
29
+ /** Typed pool-exhaustion error: a generation outgrew its pool. Sizing from
30
+ * prompt+maxTokens at construction makes this unreachable in practice;
31
+ * it exists so an accounting bug is a clear throw, never silent
32
+ * truncation or an allocator OOM (the byte-capped-not-count-capped
33
+ * lesson). */
34
+ export class PagedPoolExhausted extends Error {
35
+ constructor(numBlocks: number) {
36
+ super(`paged KV pool exhausted (${numBlocks} blocks) — capacity accounting bug`);
37
+ this.name = "PagedPoolExhausted";
38
+ }
39
+ }
40
+
41
+ /** Per-layer block arena. One K and one V pool tensor, mlx-owned
42
+ * (ops.zeros — no host-pointer alignment/dtor hazards), shaped
43
+ * [numBlocks, H, blockSize, D]. alloc()/free() manage physical slot
44
+ * indices; the owning PagedKVCache writes/gathers through the pool. */
45
+ export class BlockPool {
46
+ keys: MlxArray;
47
+ values: MlxArray;
48
+ readonly numBlocks: number;
49
+ readonly blockSize: number;
50
+ #free: number[];
51
+
52
+ constructor(opts: {
53
+ numBlocks: number;
54
+ blockSize: number;
55
+ numKvHeads: number;
56
+ headDim: number;
57
+ /** V head dim when it differs from K's (attention_k_eq_v models don't
58
+ * reach v1's gemma4 scope, but the pool stays shape-honest). */
59
+ vHeadDim?: number;
60
+ dtype: Dtype;
61
+ }) {
62
+ this.numBlocks = opts.numBlocks;
63
+ this.blockSize = opts.blockSize;
64
+ this.keys = ops.zeros(
65
+ [opts.numBlocks, opts.numKvHeads, opts.blockSize, opts.headDim], opts.dtype);
66
+ this.values = ops.zeros(
67
+ [opts.numBlocks, opts.numKvHeads, opts.blockSize, opts.vHeadDim ?? opts.headDim],
68
+ opts.dtype);
69
+ // LIFO free list, low indices first — deterministic layout for tests.
70
+ this.#free = Array.from({ length: opts.numBlocks }, (_, i) => opts.numBlocks - 1 - i);
71
+ }
72
+
73
+ get freeBlocks(): number {
74
+ return this.#free.length;
75
+ }
76
+
77
+ alloc(): number {
78
+ const idx = this.#free.pop();
79
+ if (idx === undefined) throw new PagedPoolExhausted(this.numBlocks);
80
+ return idx;
81
+ }
82
+
83
+ free(physIdx: number): void {
84
+ this.#free.push(physIdx);
85
+ }
86
+
87
+ /** Write `k`/`v` ([1, H, l, D], l ≤ blockSize − within) into physical
88
+ * block `physIdx` starting at row `within`. Functional slice_update:
89
+ * the pool tensors are single-referenced here, so mlx donates the
90
+ * buffer and the write is in-place in the steady state. */
91
+ writeBlock(physIdx: number, k: MlxArray, v: MlxArray, within: number): void {
92
+ const l = k.shape[2]!;
93
+ const [, H, , kD] = this.keys.shape as [number, number, number, number];
94
+ const vD = this.values.shape[3]!;
95
+ const k2 = ops.sliceUpdate(
96
+ this.keys, k, [physIdx, 0, within, 0], [physIdx + 1, H, within + l, kD]);
97
+ this.keys.dispose();
98
+ this.keys = k2;
99
+ const v2 = ops.sliceUpdate(
100
+ this.values, v, [physIdx, 0, within, 0], [physIdx + 1, H, within + l, vD]);
101
+ this.values.dispose();
102
+ this.values = v2;
103
+ }
104
+
105
+ /** Gather `blockTable`'s blocks in logical order into one contiguous
106
+ * [1, H, nb·blockSize, D] pair (the fetch copy). Caller owns both. */
107
+ gather(blockTable: number[]): [MlxArray, MlxArray] {
108
+ const idx = MlxArray.fromInt32(Int32Array.from(blockTable), [blockTable.length]);
109
+ const pick = (pool: MlxArray): MlxArray => {
110
+ const took = ops.takeAxis(pool, idx, 0); // [nb, H, bs, D]
111
+ const [nb, H, bs, D] = took.shape as [number, number, number, number];
112
+ const t = ops.transposeAxes(took, [1, 0, 2, 3]); // [H, nb, bs, D] (view)
113
+ took.dispose();
114
+ const out = ops.reshape(t, [1, H, nb * bs, D]); // materializes seq order
115
+ t.dispose();
116
+ return out;
117
+ };
118
+ const k = pick(this.keys);
119
+ const v = pick(this.values);
120
+ idx.dispose();
121
+ return [k, v];
122
+ }
123
+
124
+ dispose(): void {
125
+ this.keys.dispose();
126
+ this.values.dispose();
127
+ this.#free = [];
128
+ }
129
+ }
130
+
131
+ /** Cache implementation over a BlockPool. Same values as a plain KVCache
132
+ * at every step (parity-gated); only the physical arrangement differs.
133
+ * The pool allocates lazily on the first write (KVCache's lazy-alloc
134
+ * shape): head count / head dims / dtype come from the first k/v pair,
135
+ * so the wiring (maybePageKv) needs no per-model shape plumbing. */
136
+ export class PagedKVCache implements Cache {
137
+ /** Matches KVCache.STEP: v1's growth granularity is a permutation of
138
+ * today's 256-token step into fixed reusable slots, not a new tuning
139
+ * axis (--paged-kv-block-size overrides for experiments). */
140
+ static readonly DEFAULT_BLOCK_SIZE = 256;
141
+ /** Compiled-decode trace adapters never wrap paged caches (see Cache);
142
+ * always unset here. */
143
+ readonly ropeOffsetArr?: MlxArray;
144
+ offset = 0;
145
+ /** Logical block order → physical pool slot. */
146
+ blockTable: number[] = [];
147
+ pool: BlockPool | null = null;
148
+
149
+ constructor(
150
+ /** Tokens this cache must be able to hold (prompt + maxTokens). */
151
+ readonly capacityTokens: number,
152
+ readonly blockSize: number,
153
+ ) {}
154
+
155
+ get #blockSize(): number {
156
+ return this.blockSize;
157
+ }
158
+
159
+ /** Blocks needed to hold `n` tokens. */
160
+ #blocksFor(n: number): number {
161
+ return Math.ceil(n / this.#blockSize);
162
+ }
163
+
164
+ updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
165
+ const L = k.shape[2]!;
166
+ const [, H, , kD] = k.shape as [number, number, number, number];
167
+ const vD = v.shape[3]!;
168
+ if (!this.pool)
169
+ this.pool = new BlockPool({
170
+ numBlocks: poolBlocksFor(this.capacityTokens, this.blockSize),
171
+ blockSize: this.blockSize,
172
+ numKvHeads: H,
173
+ headDim: kD,
174
+ vHeadDim: vD,
175
+ dtype: k.dtype,
176
+ });
177
+ // Write, splitting the incoming L along block boundaries. Blocks past
178
+ // the current tail allocate from the free list as they're reached.
179
+ let written = 0;
180
+ while (written < L) {
181
+ const pos = this.offset + written;
182
+ const bi = Math.floor(pos / this.#blockSize);
183
+ const within = pos % this.#blockSize;
184
+ while (this.blockTable.length <= bi) this.blockTable.push(this.pool.alloc());
185
+ const l = Math.min(this.#blockSize - within, L - written);
186
+ // Full-range pieces skip the slice (fresh-view slice would be a
187
+ // gratuitous op); partial pieces slice the [written, written+l) rows.
188
+ if (l === L && written === 0) {
189
+ this.pool.writeBlock(this.blockTable[bi]!, k, v, within);
190
+ } else {
191
+ const kp = k.slice([0, 0, written, 0], [1, H, written + l, kD]);
192
+ const vp = v.slice([0, 0, written, 0], [1, H, written + l, vD]);
193
+ this.pool.writeBlock(this.blockTable[bi]!, kp, vp, within);
194
+ kp.dispose();
195
+ vp.dispose();
196
+ }
197
+ written += l;
198
+ }
199
+ this.offset += L;
200
+
201
+ // Fetch: gather occupied blocks contiguous, slice to the live prefix.
202
+ const nb = this.#blocksFor(this.offset);
203
+ const [gk, gv] = this.pool.gather(this.blockTable.slice(0, nb));
204
+ const keys = gk.slice([0, 0, 0, 0], [1, H, this.offset, kD]);
205
+ const values = gv.slice([0, 0, 0, 0], [1, H, this.offset, vD]);
206
+ gk.dispose();
207
+ gv.dispose();
208
+ return [keys, values];
209
+ }
210
+
211
+ /** Same mask policy as KVCache.makeMask (it reads only offset). */
212
+ makeMask(N: number, windowSize: number | null): Mask {
213
+ if (N === 1) return { mode: "", arr: null };
214
+ if (windowSize === null) return { mode: "causal", arr: null };
215
+ if (this.offset === 0 && N <= windowSize) return { mode: "causal", arr: null };
216
+ return { mode: "array", arr: createCausalMask(N, this.offset, windowSize) };
217
+ }
218
+
219
+ /** Pool tensors — what a prefill-chunk boundary must materialize.
220
+ * NOT prompt-cache-compatible in v1 (cloneKvCaches never sees paged
221
+ * caches; the serve lane bypasses take/put for paged requests). */
222
+ state(): MlxArray[] {
223
+ return this.pool ? [this.pool.keys, this.pool.values] : [];
224
+ }
225
+
226
+ isTrimmable(): boolean {
227
+ return true;
228
+ }
229
+
230
+ /** Rewind the last n tokens and return now-unoccupied tail blocks to
231
+ * the free list. Stale bytes past offset are never read (fetch slices
232
+ * to offset; writes cover exact ranges) — the KVCache padding
233
+ * invariant, block-shaped. */
234
+ trim(n: number): void {
235
+ this.offset = Math.max(0, this.offset - n);
236
+ const need = this.#blocksFor(this.offset);
237
+ while (this.blockTable.length > need) this.pool!.free(this.blockTable.pop()!);
238
+ }
239
+
240
+ dispose(): void {
241
+ // The cache owns its pool in v1 (one sequence per pool).
242
+ this.pool?.dispose();
243
+ this.pool = null;
244
+ this.blockTable = [];
245
+ this.offset = 0;
246
+ }
247
+ }
248
+
249
+ /** Size a pool for one request: capacity tokens rounded up to whole
250
+ * blocks. Exported for the wiring in generate.ts (maybePageKv). */
251
+ export function poolBlocksFor(capacityTokens: number, blockSize: number): number {
252
+ return Math.max(1, Math.ceil(capacityTokens / blockSize));
253
+ }
@@ -22,6 +22,7 @@
22
22
  import { MlxArray } from "../mlx/array";
23
23
  import { Dtype } from "../mlx/ffi";
24
24
  import { MetalKernel } from "../mlx/metal-kernel";
25
+ import { CompiledFunction } from "../mlx/compile";
25
26
  import * as ops from "../mlx/ops";
26
27
  import type { Cache, Mask } from "./gemma4-base";
27
28
 
@@ -118,21 +119,33 @@ function getKernel(): MetalKernel {
118
119
  /** compute_g: exp(-exp(A_log_f32) * softplus(a + dt_bias)). Output is f32
119
120
  * (the f32 exp(A_log) promotes the bf16 softplus term) — the kernel reads g
120
121
  * as float, so this dtype is load-bearing. */
122
+ let _computeGFn: CompiledFunction | null = null;
121
123
  export function computeG(aLog: MlxArray, a: MlxArray, dtBias: MlxArray): MlxArray {
122
- const aLogF = aLog.astype(Dtype.float32);
123
- const expA = ops.exp(aLogF); // f32 [Hv]
124
- aLogF.dispose();
125
- const negExpA = ops.neg(expA); // f32 [Hv]
126
- expA.dispose();
127
- const adt = ops.add(a, dtBias); // bf16 [B,S,Hv]
128
- const sp = ops.softplus(adt); // bf16 [B,S,Hv]
129
- adt.dispose();
130
- const prod = ops.mul(negExpA, sp); // f32 [B,S,Hv]
131
- negExpA.dispose();
132
- sp.dispose();
133
- const g = ops.exp(prod); // f32 [B,S,Hv]
134
- prod.dispose();
135
- return g;
124
+ // mlx-lm gated_delta.py: `@partial(mx.compile, shapeless=True) compute_g` — the
125
+ // WHOLE chain (exp/neg/softplus/mul/exp) is ONE fused kernel there, not five
126
+ // separate dispatches. Traced once, replayed.
127
+ if (!_computeGFn) {
128
+ _computeGFn = new CompiledFunction((inp) => {
129
+ const aL = inp[0]!, aa = inp[1]!, dt = inp[2]!;
130
+ const aLogF = aL.astype(Dtype.float32);
131
+ const expA = ops.exp(aLogF); aLogF.dispose();
132
+ const negExpA = ops.neg(expA); expA.dispose();
133
+ const adt = ops.add(aa, dt);
134
+ // mlx nn.softplus(x) = mx.logaddexp(x, 0), where 0 is a weak Python int
135
+ // (no cast node). ops.softplus builds the 0 via scalarLike (fromFloat32 →
136
+ // f32→bf16 AsType). Build the 0 at adt's dtype from raw bytes so there is
137
+ // no cast — matching mlx's graph exactly.
138
+ const zbytes = adt.dtype === Dtype.float32
139
+ ? new Uint8Array([0, 0, 0, 0])
140
+ : new Uint8Array([0, 0]);
141
+ const zero = MlxArray.fromBytesCopy(zbytes, [], adt.dtype);
142
+ const sp = ops.logaddexp(adt, zero); adt.dispose(); zero.dispose();
143
+ const prod = ops.mul(negExpA, sp); negExpA.dispose(); sp.dispose();
144
+ const g = ops.exp(prod); prod.dispose();
145
+ return [g];
146
+ });
147
+ }
148
+ return _computeGFn.apply([aLog, a, dtBias])[0]!;
136
149
  }
137
150
 
138
151
  /** gated_delta_update (use_kernel path): returns [y, newState].
@@ -185,6 +198,19 @@ export class SSMCache implements Cache {
185
198
  conv: MlxArray | null = null;
186
199
  recurrent: MlxArray | null = null;
187
200
  offset = 0;
201
+ /** Per-row token coverage (batch lane only; null on serial B=1 caches,
202
+ * where `offset` IS the row's count). mlx-lm's ArraysCache tracks no
203
+ * offset at all (cache.py:594-722) — per-sequence coverage is the
204
+ * server's bookkeeping; ours lives here because prompt-cache put()
205
+ * keys on EXACT coverage (#putOrDispose offset === tokens.length) and
206
+ * merged rows carry different totals. Seeded by mergeRows, advanced in
207
+ * lockstep by advance(), row-filtered by filter(). */
208
+ offsets: number[] | null = null;
209
+
210
+ /** Row `i`'s own token coverage (per-row when batched, `offset` serial). */
211
+ rowOffset(i: number): number {
212
+ return this.offsets ? this.offsets[i]! : this.offset;
213
+ }
188
214
 
189
215
  /** Linear-attn layers don't go through the KV update path. */
190
216
  updateAndFetch(): [MlxArray, MlxArray] {
@@ -201,6 +227,10 @@ export class SSMCache implements Cache {
201
227
 
202
228
  advance(n: number): void {
203
229
  this.offset += n;
230
+ // Batched rows step together (one forward advances every row), so the
231
+ // per-row counts move in lockstep; they DIFFER only in their merge-time
232
+ // seeds (each row's prompt length).
233
+ if (this.offsets) for (let i = 0; i < this.offsets.length; i++) this.offsets[i]! += n;
204
234
  }
205
235
 
206
236
  state(): MlxArray[] {
@@ -230,7 +260,8 @@ export class SSMCache implements Cache {
230
260
  // left-padding, so the batched twins of mergeKVRows/filterKVRows collapse
231
261
  // to B-axis concat/take. `offset` is bookkeeping only for SSM layers (RoPE
232
262
  // reads the FULL-attention caches; the linear-attn forward never reads it),
233
- // so merged rows carrying different token counts share the max.
263
+ // so merged rows carrying different token counts share the max; the exact
264
+ // per-row counts live in `offsets` (extraction's coverage key).
234
265
 
235
266
  /** Merge a fully-prefilled solo row into a running batched cache (`prev`
236
267
  * null ⇒ the solo row becomes the batch). The returned cache owns fresh
@@ -253,6 +284,9 @@ export class SSMCache implements Cache {
253
284
  solo.recurrent = null;
254
285
  }
255
286
  out.offset = Math.max(prev?.offset ?? 0, solo.offset);
287
+ // Seed per-row coverage: an adopted serial `prev` (offsets null) is one
288
+ // row whose exact count is its scalar offset.
289
+ out.offsets = [...(prev ? prev.offsets ?? [prev.offset] : []), solo.offset];
256
290
  return out;
257
291
  }
258
292
 
@@ -267,5 +301,32 @@ export class SSMCache implements Cache {
267
301
  this.recurrent.dispose();
268
302
  this.conv = conv;
269
303
  this.recurrent = recurrent;
304
+ if (this.offsets) this.offsets = keep.map((i) => this.offsets![i]!);
305
+ }
306
+
307
+ /** Row `i` as a fresh SERIAL cache — port of mlx-lm ArraysCache.extract
308
+ * (cache.py:673-676: `cache.cache = [c[idx : idx + 1] for c in self.cache]`,
309
+ * a B-axis slice of every state slot). Ours are OWNED contiguous copies
310
+ * (the entry outlives the batch; the batched buffers must free when the
311
+ * batch moves on — same rule as extractKVRow, batched-mask.ts). Offset =
312
+ * the row's OWN coverage, not the shared max. */
313
+ extractRow(i: number): SSMCache {
314
+ if (!this.conv || !this.recurrent)
315
+ throw new Error("SSMCache.extractRow: empty cache");
316
+ const cut = (a: MlxArray): MlxArray => {
317
+ const lo = a.shape.map(() => 0);
318
+ lo[0] = i;
319
+ const hi = [...a.shape];
320
+ hi[0] = i + 1;
321
+ const view = a.slice(lo, hi);
322
+ const own = ops.contiguous(view);
323
+ view.dispose();
324
+ return own;
325
+ };
326
+ const out = new SSMCache();
327
+ out.conv = cut(this.conv);
328
+ out.recurrent = cut(this.recurrent);
329
+ out.offset = this.rowOffset(i);
330
+ return out;
270
331
  }
271
332
  }