mlx-bun 0.0.8 → 0.0.9

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 (133) hide show
  1. package/README.md +112 -20
  2. package/docs/design/batching-v2-plan.md +515 -0
  3. package/docs/design/bucketing-stage.md +196 -0
  4. package/docs/design/compat-cli-surface-design.md +11 -11
  5. package/docs/design/docs-reorg-plan.md +1 -1
  6. package/docs/design/dreaming-nightly-pipeline.md +291 -0
  7. package/docs/design/dspark-speculative-decoding.md +106 -0
  8. package/docs/design/generic-model-support.md +381 -0
  9. package/docs/design/memory-inference-path.md +422 -0
  10. package/docs/design/memory-synthesis.md +287 -0
  11. package/docs/design/minicpm5-decode-megakernel.md +484 -0
  12. package/docs/design/mlx-lm-tool-parity-plan.md +535 -0
  13. package/docs/design/orpo-dynamic-lambda.md +41 -0
  14. package/docs/design/orpo-training.md +35 -5
  15. package/docs/design/parallel-slots.md +12 -1
  16. package/docs/design/parity-tier-dag.md +13 -5
  17. package/docs/design/spec-decode-larger-targets.md +229 -0
  18. package/docs/design/tauri-desktop-app.md +2 -2
  19. package/docs/design/the-dreaming-handoff.md +141 -0
  20. package/docs/design/the-dreaming-master-plan.md +446 -0
  21. package/docs/design/write-pipeline-entity.md +468 -0
  22. package/docs/investigations/curve-bisector-routing.md +1 -1
  23. package/docs/investigations/decode-roofline-lookagain.md +262 -0
  24. package/docs/investigations/dspark-handoff.md +58 -0
  25. package/docs/investigations/kernel-perf-review-2026-07.md +92 -0
  26. package/docs/planning/PRODUCT_ROADMAP.md +2 -2
  27. package/docs/planning/curve-sampler-research-plan.md +331 -0
  28. package/docs/planning/memory-docs-and-dag-plan.md +185 -0
  29. package/docs/planning/pi-integration-review.md +178 -0
  30. package/docs/planning/release-notes-v0.0.9.md +122 -0
  31. package/docs/planning/web-ui-pass-plan.md +213 -0
  32. package/docs/planning/website-readme-pass-plan.md +224 -0
  33. package/docs/reference/cli.md +255 -0
  34. package/docs/reference/embedding.md +7 -2
  35. package/docs/reference/library-api.md +22 -3
  36. package/docs/reference/memory.md +5 -5
  37. package/docs/reference/models.md +137 -0
  38. package/docs/reference/orpo-quickstart.md +6 -0
  39. package/docs/reference/server-api.md +187 -9
  40. package/docs/reference/server-config.md +40 -9
  41. package/docs/reference/training.md +25 -11
  42. package/package.json +1 -1
  43. package/src/anthropic.ts +22 -0
  44. package/src/assets/curve-designer.html +1 -1
  45. package/src/chat-template.ts +25 -7
  46. package/src/cli.ts +1029 -86
  47. package/src/config.ts +4 -1
  48. package/src/download.ts +99 -8
  49. package/src/embed.ts +77 -0
  50. package/src/eval/perplexity.ts +164 -0
  51. package/src/eval/runner.ts +15 -6
  52. package/src/eval/tasks/bfcl.ts +1 -1
  53. package/src/generate.ts +141 -18
  54. package/src/harness-pi.ts +43 -17
  55. package/src/index.ts +2 -0
  56. package/src/lora.ts +2 -2
  57. package/src/memory/article.ts +558 -0
  58. package/src/memory/chunk-validation.ts +150 -0
  59. package/src/memory/chunk.ts +305 -12
  60. package/src/memory/cluster.ts +344 -17
  61. package/src/memory/coverage.ts +134 -0
  62. package/src/memory/crosslink.ts +0 -0
  63. package/src/memory/db.ts +184 -45
  64. package/src/memory/entity.ts +203 -0
  65. package/src/memory/gate.ts +187 -0
  66. package/src/memory/history.ts +152 -0
  67. package/src/memory/ingest.ts +324 -11
  68. package/src/memory/model.ts +321 -0
  69. package/src/memory/normalize.ts +671 -0
  70. package/src/memory/parse.ts +22 -0
  71. package/src/memory/pipeline.ts +242 -30
  72. package/src/memory/prompts.ts +42 -0
  73. package/src/memory/query.ts +334 -0
  74. package/src/memory/reconcile.ts +734 -0
  75. package/src/memory/redirect.ts +108 -0
  76. package/src/memory/reindex.ts +325 -0
  77. package/src/memory/resolve.ts +307 -0
  78. package/src/memory/route.ts +272 -0
  79. package/src/memory/schedule.ts +6 -4
  80. package/src/memory/stages.ts +774 -0
  81. package/src/memory/synthesize.ts +1629 -20
  82. package/src/memory/tools.ts +446 -96
  83. package/src/memory/vault.ts +462 -35
  84. package/src/memory/wikify.ts +1057 -16
  85. package/src/mlx/ffi.ts +3 -0
  86. package/src/mlx/ops.ts +46 -0
  87. package/src/model/compiled-decode.ts +43 -8
  88. package/src/model/factory.ts +29 -8
  89. package/src/model/flash-attention.ts +4 -2
  90. package/src/model/fused-decode-kernel.ts +14 -7
  91. package/src/model/fused-mlp-kernel.ts +169 -0
  92. package/src/model/fused-swiglu-kernel.ts +240 -0
  93. package/src/model/gemma4-base.ts +62 -6
  94. package/src/model/gemma4.ts +33 -2
  95. package/src/model/minicpm5.ts +106 -8
  96. package/src/model/qwen3.ts +276 -0
  97. package/src/model/steel-linear-kernel.ts +96 -0
  98. package/src/model/support.ts +38 -8
  99. package/src/model/universal/archs.ts +332 -0
  100. package/src/model/universal/dense.ts +576 -0
  101. package/src/model/universal/modules.ts +163 -0
  102. package/src/model/universal/rope.ts +366 -0
  103. package/src/pi-provider.ts +1 -1
  104. package/src/pi-terminal.ts +29 -8
  105. package/src/pi-web.ts +19 -2
  106. package/src/quantize/config-writer.ts +1 -1
  107. package/src/registry.ts +215 -7
  108. package/src/responses.ts +23 -0
  109. package/src/sampler.ts +220 -9
  110. package/src/serve/batch-scheduler.ts +272 -80
  111. package/src/serve/generation-gateway.ts +86 -18
  112. package/src/server.ts +612 -49
  113. package/src/spec/drafter.ts +81 -49
  114. package/src/spec/dspark/data-dflash.ts +156 -0
  115. package/src/spec/dspark/data.ts +201 -0
  116. package/src/spec/dspark/generate-dflash.ts +193 -0
  117. package/src/spec/dspark/generate.ts +290 -0
  118. package/src/spec/dspark/loss.ts +213 -0
  119. package/src/spec/dspark/module-dflash.ts +361 -0
  120. package/src/spec/dspark/module.ts +540 -0
  121. package/src/spec/dspark/sample.ts +112 -0
  122. package/src/spec/generate.ts +40 -19
  123. package/src/status-page.html +1 -1
  124. package/src/tokenizer.ts +7 -0
  125. package/src/train/flash-cce.ts +70 -15
  126. package/src/train/fuse.ts +243 -0
  127. package/src/train/index.ts +3 -0
  128. package/src/train/job.ts +8 -0
  129. package/src/train/loss.ts +289 -30
  130. package/src/train/prefix-shared.ts +33 -5
  131. package/src/train/segmented.ts +67 -11
  132. package/src/train/trainer.ts +92 -28
  133. package/src/web/skills/memory/SKILL.md +40 -17
@@ -87,6 +87,11 @@ export class QuantizedLinear {
87
87
  readonly scales: MlxArray,
88
88
  readonly biases: MlxArray | null,
89
89
  readonly spec: ops.QuantSpec,
90
+ /** ADDITIVE bias term (`.bias`, mlx nn.QuantizedLinear's optional bias —
91
+ * qwen2 qkv, starcoder2, …). Distinct from `biases`, the quantization
92
+ * zero-points. Applied after the matmul, before any LoRA residual,
93
+ * exactly like mlx's QuantizedLinear.__call__. */
94
+ readonly bias: MlxArray | null = null,
90
95
  ) {}
91
96
 
92
97
  static load(weights: Weights, path: string, config: ModelConfig): QuantizedLinear {
@@ -99,6 +104,7 @@ export class QuantizedLinear {
99
104
  weights.tensor(`${path}.scales`),
100
105
  weights.has(`${path}.biases`) ? weights.tensor(`${path}.biases`) : null,
101
106
  spec,
107
+ weights.has(`${path}.bias`) ? weights.tensor(`${path}.bias`) : null,
102
108
  );
103
109
  }
104
110
 
@@ -113,6 +119,9 @@ export class QuantizedLinear {
113
119
 
114
120
  forward(x: MlxArray): MlxArray {
115
121
  let out = ops.quantizedMatmul(x, this.w, this.scales, this.biases, this.spec, true);
122
+ // Additive bias (mlx QuantizedLinear: `x = x + self["bias"]`), before
123
+ // the LoRA residual (LoRALinear calls the base linear bias-inclusive).
124
+ if (this.bias) out = disposing(out, ops.add(out, this.bias));
116
125
  // LoRA residual — composition is mlx-lm LoRALinear / optiq apply.py:
117
126
  // y + (scale · ((x @ A) @ B)).astype(x.dtype)
118
127
  // (optiq mount.py omits the astype, leaking the f32 residual into the
@@ -213,8 +222,11 @@ export interface Cache {
213
222
  /** Can `trim(n)` drop the last n tokens? (Ring caches lose trimability
214
223
  * once wrapped.) */
215
224
  isTrimmable(): boolean;
216
- /** Drop the last n tokens (future writes overwrite them). */
217
- trim(n: number): void;
225
+ /** Drop the last n tokens (future writes overwrite them). `bypass` (default
226
+ * false → unchanged behavior) skips the isTrimmable guard for the
227
+ * speculative-decode rollback case (trim the last n ≤ γ tips right after a
228
+ * >1 concat write); rotating caches then physically shrink the buffer tail. */
229
+ trim(n: number, bypass?: boolean): void;
218
230
  dispose(): void;
219
231
  }
220
232
 
@@ -749,10 +761,32 @@ export class RotatingKVCache implements Cache {
749
761
  return this.offset < this.maxSize;
750
762
  }
751
763
 
752
- trim(n: number): void {
764
+ trim(n: number, bypass = false): void {
753
765
  const k = Math.min(this.offset, n);
766
+ if (!bypass) {
767
+ // mlx-lm semantics: only valid pre-wrap (guarded by isTrimmable upstream).
768
+ this.offset -= k;
769
+ this.#idx -= k;
770
+ return;
771
+ }
772
+ // Spec-decode rollback after a >1 #updateConcat write: the buffer is in
773
+ // temporal order with #idx === bufferLen and the newest tokens are the last
774
+ // rows, so physically slice the last k off and keep #idx === new length.
775
+ // (Only sound right after a concat write with k ≤ γ — DSpark's only caller.)
754
776
  this.offset -= k;
755
- this.#idx -= k;
777
+ if (k > 0 && this.keys && this.values) {
778
+ const [B, H, S, D] = this.keys.shape as [number, number, number, number];
779
+ const vD = this.values.shape[3]!;
780
+ const nk = this.keys.slice([0, 0, 0, 0], [B, H, S - k, D]);
781
+ const nv = this.values.slice([0, 0, 0, 0], [B, H, S - k, vD]);
782
+ this.keys.dispose();
783
+ this.values.dispose();
784
+ this.keys = nk;
785
+ this.values = nv;
786
+ this.#idx = nk.shape[2]!;
787
+ } else {
788
+ this.#idx -= k;
789
+ }
756
790
  }
757
791
 
758
792
  get ringIdx(): number {
@@ -1100,10 +1134,32 @@ export class RotatingQuantizedKVCache implements Cache {
1100
1134
  return this.offset < this.maxSize;
1101
1135
  }
1102
1136
 
1103
- trim(n: number): void {
1137
+ trim(n: number, bypass = false): void {
1104
1138
  const k = Math.min(this.offset, n);
1139
+ if (!bypass) {
1140
+ this.offset -= k;
1141
+ this.ringIdx -= k;
1142
+ return;
1143
+ }
1144
+ // Spec-decode rollback after a >1 concat write — physically slice the last k
1145
+ // off each (packed, scales, biases) component and keep ringIdx === seqLen.
1105
1146
  this.offset -= k;
1106
- this.ringIdx -= k;
1147
+ if (k > 0 && this.keys && this.values) {
1148
+ const S = this.#seqLen();
1149
+ const cut = (a: MlxArray): MlxArray => {
1150
+ const [B, H, , D] = a.shape as [number, number, number, number];
1151
+ return a.slice([0, 0, 0, 0], [B, H, S - k, D]);
1152
+ };
1153
+ const nk = mapTriple(this.keys, cut);
1154
+ const nv = mapTriple(this.values, cut);
1155
+ disposeTriple(this.keys);
1156
+ disposeTriple(this.values);
1157
+ this.keys = nk;
1158
+ this.values = nv;
1159
+ this.ringIdx = this.#seqLen();
1160
+ } else {
1161
+ this.ringIdx -= k;
1162
+ }
1107
1163
  }
1108
1164
 
1109
1165
  /** Oracle: to_quantized on an already-quantized rotating cache is
@@ -295,7 +295,14 @@ class Attention {
295
295
  } else if (
296
296
  ta === "flash" && flashSupported(q) && (mask.mode === "causal" || mask.mode === "array")
297
297
  ) {
298
- // Training: ops.sdpa's dK vjp is wrong, so use the validated flash kernel.
298
+ // Training, OPT-IN flash path (MLX_BUN_TRAIN_ATTN=flash) for O(L)-memory
299
+ // long context. NOTE the default (ops.sdpa, below) is the CORRECT and
300
+ // verified route: its dQ/dK/dV vjps are exact (finite-difference checked;
301
+ // it's what mlx-lm's tuner differentiates). The two historical bugs were
302
+ // in THIS flash kernel (spurious dK transpose, divergent dQ barrier),
303
+ // both fixed and gated by tests/flash-attention.test.ts vs the ops.sdpa
304
+ // oracle — see flash-attention.ts's routing comment. Flash also remains
305
+ // Gemma-guarded in the trainer (e4b >=2K SIGTRAP not yet re-validated).
299
306
  // Full layers → window 0 (pure causal); sliding layers → their window.
300
307
  const window = this.isSliding ? this.windowSize : 0;
301
308
  attn = flashAttention(q, shared.keys, shared.values, 1.0, true, window);
@@ -663,6 +670,13 @@ export class Gemma4Model {
663
670
  /** Set by the trainer to enable gradient checkpointing in forwardLayers
664
671
  * (null = off; the non-checkpointed path is untouched). */
665
672
  gradCkpt: GradCheckpointCtx | null = null;
673
+ /** Optional per-forward hidden-state tap for DSpark's DFlash H_ctx (paper
674
+ * Eq 2). null = off → byte-identical forward. `layers`: residual-output
675
+ * indices to capture (0..nLayers-1 = post-layer residual; nLayers =
676
+ * post-finalNorm). `pos`: if set, capture only [1,1,H] at that seq position
677
+ * (live decode); else the full [1,L,H] (offline regen). The caller owns and
678
+ * disposes the captured arrays. Additive/read-only → parity untouched. */
679
+ hiddenTap: { layers: Set<number>; pos?: number; captured: Map<number, MlxArray> } | null = null;
666
680
  // e2b/e4b per-layer input machinery
667
681
  readonly perLayerEmbed: QuantizedEmbedding | null;
668
682
  readonly perLayerModelProjection: QuantizedLinear | null;
@@ -820,6 +834,20 @@ export class Gemma4Model {
820
834
  return out;
821
835
  }
822
836
 
837
+ /** Store layer `i`'s residual stream `h` ([1,L,H]) if the hidden tap requests
838
+ * it. Copies (astype bf16, optional slice) so the loop's `h.dispose()` can't
839
+ * free the capture. No-op and no graph ops when the tap is null → the forward
840
+ * is byte-identical to an untapped run. (DSpark DFlash H_ctx.) */
841
+ protected captureLayer(i: number, h: MlxArray): void {
842
+ const tap = this.hiddenTap;
843
+ if (!tap || !tap.layers.has(i)) return;
844
+ const H = h.shape[2]!;
845
+ const src = tap.pos !== undefined ? h.slice([0, tap.pos, 0], [1, tap.pos + 1, H]) : h;
846
+ const copy = src.astype(this.embed.scales.dtype); // bf16, matches regen dump dtype
847
+ if (src !== h) src.dispose();
848
+ tap.captured.set(i, copy);
849
+ }
850
+
823
851
  /** Consumes h. */
824
852
  protected forwardLayers(
825
853
  h0: MlxArray, cache: Cache[], bidir: MlxArray | null, ids: MlxArray | null,
@@ -878,6 +906,7 @@ export class Gemma4Model {
878
906
  pls?.dispose();
879
907
  h.dispose();
880
908
  h = next;
909
+ this.captureLayer(i, h); // DFlash H_ctx tap (no-op unless hiddenTap set)
881
910
  intermediates[i] = shared;
882
911
  }
883
912
  perLayer?.dispose();
@@ -895,7 +924,9 @@ export class Gemma4Model {
895
924
  }
896
925
  for (const m of masks.values()) m.arr?.dispose();
897
926
 
898
- return disposing(h, this.finalNorm.forward(h));
927
+ h = disposing(h, this.finalNorm.forward(h));
928
+ this.captureLayer(this.layers.length, h); // post-finalNorm sentinel (index = nLayers)
929
+ return h;
899
930
  }
900
931
 
901
932
  // --- Segmented-backward support (docs/design/segmented-backward-training.md
@@ -4,6 +4,7 @@
4
4
  import type { ModelConfig } from "../config";
5
5
  import type { Weights } from "../weights";
6
6
  import { MlxArray } from "../mlx/array";
7
+ import { Dtype } from "../mlx/ffi";
7
8
  import * as ops from "../mlx/ops";
8
9
  import {
9
10
  argmaxLastPosition,
@@ -16,9 +17,56 @@ import {
16
17
  QuantizedLinear,
17
18
  quantizedSdpa,
18
19
  RMSNorm,
20
+ isCompiledTrace,
19
21
  type Cache,
20
22
  type Mask,
21
23
  } 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
+ }
45
+
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);
59
+ }
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]!]));
66
+ }
67
+
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);
22
70
  import { flashAttention, getTrainingAttn, flashSupported } from "./flash-attention";
23
71
 
24
72
  // Shared prompt-prefix plan (lever 7). When set, the attention ropes the
@@ -156,7 +204,40 @@ class LlamaMLP {
156
204
  this.down = QuantizedLinear.load(weights, `${prefix}.down_proj`, config);
157
205
  }
158
206
 
159
- forward(x: MlxArray): MlxArray {
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. */
211
+ forward(x: MlxArray, residual?: MlxArray): MlxArray {
212
+ const H = x.shape[x.shape.length - 1]!;
213
+ 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
+ }
160
241
  const gate = this.gate.forward(x);
161
242
  const up = this.up.forward(x);
162
243
  const sig = ops.sigmoid(gate);
@@ -166,9 +247,10 @@ class LlamaMLP {
166
247
  const hidden = ops.mul(silu, up);
167
248
  silu.dispose();
168
249
  up.dispose();
169
- const out = this.down.forward(hidden);
250
+ const m = this.down.forward(hidden);
170
251
  hidden.dispose();
171
- return out;
252
+ if (residual == null) return m;
253
+ return disposing(m, ops.add(residual, m));
172
254
  }
173
255
  }
174
256
 
@@ -192,11 +274,10 @@ class LlamaLayer {
192
274
  const h = ops.add(x, a);
193
275
  a.dispose();
194
276
  const hn = this.postAttnNorm.forward(h);
195
- const m = this.mlp.forward(hn);
277
+ // Pass the residual h INTO the MLP so the fused kernel absorbs it (out = h + mlp).
278
+ const out = this.mlp.forward(hn, h);
196
279
  hn.dispose();
197
- const out = ops.add(h, m);
198
280
  h.dispose();
199
- m.dispose();
200
281
  return out;
201
282
  }
202
283
  }
@@ -256,7 +337,15 @@ export class MiniCPM5Model {
256
337
  }
257
338
 
258
339
  protected forwardLayers(h0: MlxArray, cache: Cache[]): MlxArray {
259
- const h = this.runLayerRange(h0, 0, this.layers.length, cache);
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
+ );
260
349
  h0.dispose(); // runLayerRange keeps its input alive; we own h0 here
261
350
  return disposing(h, this.finalNorm.forward(h));
262
351
  }
@@ -269,7 +358,7 @@ export class MiniCPM5Model {
269
358
  * segmented-backward training path (docs/design/segmented-backward-training.md).
270
359
  * MiniCPM5 is plain full-attention with no KV-sharing, so each layer reads
271
360
  * its own stateless `cache[i]` and a single causal mask. */
272
- runLayerRange(h: MlxArray, aIdx: number, bIdx: number, cache: Cache[]): MlxArray {
361
+ runLayerRange(h: MlxArray, aIdx: number, bIdx: number, cache: Cache[], evalPerLayer = false): MlxArray {
273
362
  if (aIdx >= bIdx) return h;
274
363
  const L = h.shape[1]!;
275
364
  const mask = cache[aIdx]!.makeMask(L, null);
@@ -278,6 +367,15 @@ export class MiniCPM5Model {
278
367
  const next = this.layers[i]!.forward(cur, mask, cache[i]!);
279
368
  if (i > aIdx) cur.dispose(); // keep the input boundary leaf; drop interiors
280
369
  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
+ }
281
379
  }
282
380
  mask.arr?.dispose();
283
381
  return cur;
@@ -0,0 +1,276 @@
1
+ // Concrete model graph for plain Qwen3 (Qwen3ForCausalLM).
2
+ // Port target: mlx_lm.models.qwen3. A standard dense decoder — GQA softmax
3
+ // attention with per-head q/k RMSNorm (applied over head_dim BEFORE RoPE),
4
+ // full-head RoPE (theta 1e6), SwiGLU MLP, RMSNorm, tied embeddings.
5
+ //
6
+ // Primary use here is as a TEXT-EMBEDDING backbone (mlx-community Qwen3-Embedding-*):
7
+ // `embedPooled` returns the last-token hidden, L2-normalized — the pooling
8
+ // Qwen3-Embedding was trained with (an <|endoftext|> token is appended by the
9
+ // caller; its hidden is the sentence vector). The full causal-LM surface
10
+ // (forward/generate via the tied head) is wired too so it conforms to RuntimeModel.
11
+
12
+ import type { ModelConfig } from "../config";
13
+ import type { Weights } from "../weights";
14
+ import { MlxArray } from "../mlx/array";
15
+ import * as ops from "../mlx/ops";
16
+ import {
17
+ argmaxLastPosition,
18
+ disposing,
19
+ KVCache,
20
+ LoraState,
21
+ QuantizedEmbedding,
22
+ QuantizedLinear,
23
+ RMSNorm,
24
+ type Cache,
25
+ type Mask,
26
+ } from "./gemma4-base";
27
+
28
+ class Qwen3Attention {
29
+ readonly qProj: QuantizedLinear;
30
+ readonly kProj: QuantizedLinear;
31
+ readonly vProj: QuantizedLinear;
32
+ readonly oProj: QuantizedLinear;
33
+ readonly qNorm: RMSNorm;
34
+ readonly kNorm: RMSNorm;
35
+ readonly nHeads: number;
36
+ readonly nKvHeads: number;
37
+ readonly headDim: number;
38
+ readonly scale: number;
39
+ readonly ropeBase: number;
40
+
41
+ constructor(weights: Weights, config: ModelConfig, prefix: string) {
42
+ const t = config.text;
43
+ this.nHeads = t.numAttentionHeads;
44
+ this.nKvHeads = t.numKeyValueHeads;
45
+ this.headDim = t.headDim;
46
+ this.scale = Math.pow(this.headDim, -0.5);
47
+ this.ropeBase = t.ropeParameters.full_attention?.ropeTheta ?? 1000000;
48
+ this.qProj = QuantizedLinear.load(weights, `${prefix}.q_proj`, config);
49
+ this.kProj = QuantizedLinear.load(weights, `${prefix}.k_proj`, config);
50
+ this.vProj = QuantizedLinear.load(weights, `${prefix}.v_proj`, config);
51
+ this.oProj = QuantizedLinear.load(weights, `${prefix}.o_proj`, config);
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
+ let q = this.qProj.forward(x);
59
+ let k = this.kProj.forward(x);
60
+ let v = this.vProj.forward(x);
61
+
62
+ // Reshape to heads, then q/k RMSNorm over head_dim BEFORE transpose+RoPE
63
+ // (mlx-lm qwen3 reference order).
64
+ q = disposing(q, ops.reshape(q, [B, L, this.nHeads, this.headDim]));
65
+ k = disposing(k, ops.reshape(k, [B, L, this.nKvHeads, this.headDim]));
66
+ v = disposing(v, ops.reshape(v, [B, L, this.nKvHeads, this.headDim]));
67
+ q = disposing(q, this.qNorm.forward(q));
68
+ k = disposing(k, this.kNorm.forward(k));
69
+
70
+ q = disposing(q, ops.transposeAxes(q, [0, 2, 1, 3]));
71
+ k = disposing(k, ops.transposeAxes(k, [0, 2, 1, 3]));
72
+ v = disposing(v, ops.transposeAxes(v, [0, 2, 1, 3]));
73
+
74
+ q = disposing(q, ops.rope(q, this.headDim, this.ropeBase, cache.offset, null));
75
+ k = disposing(k, ops.rope(k, this.headDim, this.ropeBase, cache.offset, null));
76
+
77
+ const [keys, values] = cache.updateAndFetch(k, v);
78
+ k.dispose();
79
+ v.dispose();
80
+ const attn = ops.sdpa(q, keys, values, this.scale, mask.mode, mask.arr);
81
+ keys.dispose();
82
+ values.dispose();
83
+ q.dispose();
84
+
85
+ const attnT = ops.transposeAxes(attn, [0, 2, 1, 3]);
86
+ attn.dispose();
87
+ const merged = ops.reshape(attnT, [B, L, -1]);
88
+ attnT.dispose();
89
+ const out = this.oProj.forward(merged);
90
+ merged.dispose();
91
+ return out;
92
+ }
93
+ }
94
+
95
+ class Qwen3MLP {
96
+ readonly gate: QuantizedLinear;
97
+ readonly up: QuantizedLinear;
98
+ readonly down: QuantizedLinear;
99
+
100
+ constructor(weights: Weights, config: ModelConfig, prefix: string) {
101
+ this.gate = QuantizedLinear.load(weights, `${prefix}.gate_proj`, config);
102
+ this.up = QuantizedLinear.load(weights, `${prefix}.up_proj`, config);
103
+ this.down = QuantizedLinear.load(weights, `${prefix}.down_proj`, config);
104
+ }
105
+
106
+ forward(x: MlxArray): MlxArray {
107
+ const gate = this.gate.forward(x);
108
+ const up = this.up.forward(x);
109
+ const sig = ops.sigmoid(gate);
110
+ const silu = ops.mul(gate, sig);
111
+ gate.dispose();
112
+ sig.dispose();
113
+ const hidden = ops.mul(silu, up);
114
+ silu.dispose();
115
+ up.dispose();
116
+ const out = this.down.forward(hidden);
117
+ hidden.dispose();
118
+ return out;
119
+ }
120
+ }
121
+
122
+ class Qwen3Layer {
123
+ readonly attn: Qwen3Attention;
124
+ readonly mlp: Qwen3MLP;
125
+ readonly inputNorm: RMSNorm;
126
+ readonly postAttnNorm: RMSNorm;
127
+
128
+ constructor(weights: Weights, config: ModelConfig, prefix: string) {
129
+ this.attn = new Qwen3Attention(weights, config, `${prefix}.self_attn`);
130
+ this.mlp = new Qwen3MLP(weights, config, `${prefix}.mlp`);
131
+ this.inputNorm = new RMSNorm(weights.tensor(`${prefix}.input_layernorm.weight`), config.text.rmsNormEps);
132
+ this.postAttnNorm = new RMSNorm(weights.tensor(`${prefix}.post_attention_layernorm.weight`), config.text.rmsNormEps);
133
+ }
134
+
135
+ forward(x: MlxArray, mask: Mask, cache: Cache): MlxArray {
136
+ const xn = this.inputNorm.forward(x);
137
+ const a = this.attn.forward(xn, mask, cache);
138
+ xn.dispose();
139
+ const h = ops.add(x, a);
140
+ a.dispose();
141
+ const hn = this.postAttnNorm.forward(h);
142
+ const m = this.mlp.forward(hn);
143
+ hn.dispose();
144
+ const out = ops.add(h, m);
145
+ h.dispose();
146
+ m.dispose();
147
+ return out;
148
+ }
149
+ }
150
+
151
+ export class Qwen3Model {
152
+ readonly config: ModelConfig;
153
+ readonly weightsBytes: number;
154
+ readonly prefixBase = "model";
155
+ readonly loraState = new LoraState();
156
+ readonly embed: QuantizedEmbedding;
157
+ readonly layers: Qwen3Layer[];
158
+ readonly finalNorm: RMSNorm;
159
+ /** Separate lm_head when NOT tied; otherwise the tied embedding's asLinear. */
160
+ readonly lmHead: QuantizedLinear | null;
161
+
162
+ constructor(weights: Weights, config: ModelConfig) {
163
+ this.config = config;
164
+ this.weightsBytes = [...weights.shards.files.values()]
165
+ .reduce((a, f) => a + f.mmap.size, 0);
166
+ this.embed = QuantizedEmbedding.load(weights, "model.embed_tokens", config);
167
+ this.layers = Array.from(
168
+ { length: config.text.numHiddenLayers },
169
+ (_, i) => new Qwen3Layer(weights, config, `model.layers.${i}`),
170
+ );
171
+ this.finalNorm = new RMSNorm(weights.tensor("model.norm.weight"), config.text.rmsNormEps);
172
+ this.lmHead = config.text.tieWordEmbeddings
173
+ ? null
174
+ : QuantizedLinear.load(weights, "lm_head", config);
175
+ }
176
+
177
+ loraTargets(): Map<string, QuantizedLinear> {
178
+ const out = new Map<string, QuantizedLinear>();
179
+ for (let i = 0; i < this.layers.length; i++) {
180
+ const l = this.layers[i]!;
181
+ const p = `model.layers.${i}`;
182
+ out.set(`${p}.self_attn.q_proj`, l.attn.qProj);
183
+ out.set(`${p}.self_attn.k_proj`, l.attn.kProj);
184
+ out.set(`${p}.self_attn.v_proj`, l.attn.vProj);
185
+ out.set(`${p}.self_attn.o_proj`, l.attn.oProj);
186
+ out.set(`${p}.mlp.gate_proj`, l.mlp.gate);
187
+ out.set(`${p}.mlp.up_proj`, l.mlp.up);
188
+ out.set(`${p}.mlp.down_proj`, l.mlp.down);
189
+ }
190
+ return out;
191
+ }
192
+
193
+ makeCache(): Cache[] {
194
+ return this.layers.map(() => new KVCache());
195
+ }
196
+
197
+ forwardHidden(ids: MlxArray, cache: Cache[]): MlxArray {
198
+ const h = this.embed.encode(ids);
199
+ return this.forwardLayers(h, cache);
200
+ }
201
+
202
+ forwardEmbeddings(_embeds: MlxArray, _cache: Cache[], _bidir: MlxArray | null): MlxArray {
203
+ throw new Error("qwen3 input-embedding path is not supported");
204
+ }
205
+
206
+ protected forwardLayers(h0: MlxArray, cache: Cache[]): MlxArray {
207
+ const L = h0.shape[1]!;
208
+ const mask = cache[0]!.makeMask(L, null);
209
+ let cur = h0;
210
+ for (let i = 0; i < this.layers.length; i++) {
211
+ const next = this.layers[i]!.forward(cur, mask, cache[i]!);
212
+ cur.dispose();
213
+ cur = next;
214
+ }
215
+ mask.arr?.dispose();
216
+ return disposing(cur, this.finalNorm.forward(cur));
217
+ }
218
+
219
+ /** Last-token pooled, L2-normalized sentence embedding for one sequence.
220
+ * ids [1, L] → vector [1, hidden]. The caller appends the EOS/eod token
221
+ * (Qwen3-Embedding's pooling token) to `ids`. */
222
+ embedPooled(ids: MlxArray): MlxArray {
223
+ const cache = this.makeCache();
224
+ try {
225
+ const h = this.forwardHidden(ids, cache); // [1, L, hidden]
226
+ const [B, L, H] = h.shape as [number, number, number];
227
+ const last = h.slice([0, L - 1, 0], [B, L, H]); // [1, 1, hidden]
228
+ h.dispose();
229
+ const vec = disposing(last, ops.reshape(last, [B, H])); // [1, hidden]
230
+ // L2 normalize: x / max(||x||_2, eps)
231
+ const sq = ops.square(vec);
232
+ const ss = disposing(sq, ops.sumAxis(sq, -1, true));
233
+ const norm = disposing(ss, ops.sqrt(ss));
234
+ const out = ops.div(vec, norm);
235
+ vec.dispose();
236
+ norm.dispose();
237
+ return out;
238
+ } finally {
239
+ for (const c of cache) c.dispose();
240
+ }
241
+ }
242
+
243
+ logitsFromHidden(h: MlxArray): MlxArray {
244
+ return this.lmHead ? this.lmHead.forward(h) : this.embed.asLinear(h);
245
+ }
246
+
247
+ forward(tokens: number[] | MlxArray, cache: Cache[]): MlxArray {
248
+ const ids = Array.isArray(tokens)
249
+ ? ops.fromInt32(tokens, [1, tokens.length])
250
+ : tokens;
251
+ const h = this.forwardHidden(ids, cache);
252
+ if (Array.isArray(tokens)) ids.dispose();
253
+ const logits = this.logitsFromHidden(h);
254
+ h.dispose();
255
+ return logits;
256
+ }
257
+
258
+ generate(promptTokens: number[], maxTokens: number, eosIds: number[] = []): number[] {
259
+ const cache = this.makeCache();
260
+ const out: number[] = [];
261
+ try {
262
+ let tokens = promptTokens;
263
+ for (let step = 0; step < maxTokens; step++) {
264
+ const logits = this.forward(tokens, cache);
265
+ const next = argmaxLastPosition(logits);
266
+ logits.dispose();
267
+ if (eosIds.includes(next)) break;
268
+ out.push(next);
269
+ tokens = [next];
270
+ }
271
+ } finally {
272
+ for (const c of cache) c.dispose();
273
+ }
274
+ return out;
275
+ }
276
+ }