mlx-bun 0.0.4

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 (210) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +362 -0
  3. package/THIRD_PARTY_LICENSES.md +41 -0
  4. package/bin/mlx-bun.mjs +32 -0
  5. package/docs/curve-designer.html +303 -0
  6. package/docs/design/adapters-end-to-end.md +201 -0
  7. package/docs/design/compat-cli-surface-design.md +227 -0
  8. package/docs/design/docs-reorg-plan.md +152 -0
  9. package/docs/design/hlg-sampling.md +340 -0
  10. package/docs/design/memory-system.md +339 -0
  11. package/docs/design/optimization_plan.md +344 -0
  12. package/docs/design/parallel-slots.md +515 -0
  13. package/docs/design/runtime-isolation.md +247 -0
  14. package/docs/design/segmented-backward-training.md +508 -0
  15. package/docs/investigations/chunk-finetune-experiment.md +84 -0
  16. package/docs/investigations/curve-bisector-routing.md +123 -0
  17. package/docs/investigations/curve-runs/bisector-route.json +98 -0
  18. package/docs/investigations/curve-runs/bisector-trace.json +1393 -0
  19. package/docs/investigations/curve-runs/curve-terrain.json +1179 -0
  20. package/docs/investigations/curve-runs/curve-terrain.svg +42 -0
  21. package/docs/investigations/curve-terrain.html +105 -0
  22. package/docs/investigations/expert-offload-single-user-moe.md +292 -0
  23. package/docs/investigations/hlg-ab.html +34 -0
  24. package/docs/investigations/hlg-explorer.html +238 -0
  25. package/docs/investigations/hlg-figs/cliff.svg +1 -0
  26. package/docs/investigations/hlg-figs/cross.svg +1 -0
  27. package/docs/investigations/hlg-figs/edges.svg +1 -0
  28. package/docs/investigations/hlg-figs/k20-grids.svg +1 -0
  29. package/docs/investigations/hlg-figs/k20-peaks.svg +1 -0
  30. package/docs/investigations/hlg-figs/lw-curve.svg +1 -0
  31. package/docs/investigations/hlg-figs/terrain.svg +1 -0
  32. package/docs/investigations/hlg-report.html +262 -0
  33. package/docs/investigations/hlg-runs/hlg-ab.json +149 -0
  34. package/docs/investigations/hlg-runs/hlg-blind-prompts.md +87 -0
  35. package/docs/investigations/hlg-runs/hlg-blind.json +107 -0
  36. package/docs/investigations/hlg-runs/hlg-cliff.json +197 -0
  37. package/docs/investigations/hlg-runs/hlg-cross.json +505 -0
  38. package/docs/investigations/hlg-runs/hlg-edge.json +388 -0
  39. package/docs/investigations/hlg-runs/hlg-eval1.json +25 -0
  40. package/docs/investigations/hlg-runs/hlg-frontier.json +86 -0
  41. package/docs/investigations/hlg-runs/hlg-grid.json +242 -0
  42. package/docs/investigations/hlg-runs/hlg-grid2.json +343 -0
  43. package/docs/investigations/hlg-runs/hlg-lw-coarse.json +110 -0
  44. package/docs/investigations/hlg-runs/hlg-map.json +383 -0
  45. package/docs/investigations/hlg-runs/run0-original.log +159 -0
  46. package/docs/investigations/hlg-runs/runA-recommended.log +131 -0
  47. package/docs/investigations/hlg-runs/runAA-cliff-cpm5.log +26 -0
  48. package/docs/investigations/hlg-runs/runAB-grid2-e4b.log +18 -0
  49. package/docs/investigations/hlg-runs/runAC-peak-e4b-partial.log +25 -0
  50. package/docs/investigations/hlg-runs/runAD-ab-samples.log +8 -0
  51. package/docs/investigations/hlg-runs/runAE-edge-e4b.log +51 -0
  52. package/docs/investigations/hlg-runs/runAF-logit-calibration.log +10 -0
  53. package/docs/investigations/hlg-runs/runAG-eval-config2.log +16 -0
  54. package/docs/investigations/hlg-runs/runB-gain-variants.log +192 -0
  55. package/docs/investigations/hlg-runs/runC-pivots.log +162 -0
  56. package/docs/investigations/hlg-runs/runD-oetf.log +132 -0
  57. package/docs/investigations/hlg-runs/runE-eotf.log +162 -0
  58. package/docs/investigations/hlg-runs/runF-shaper-working.log +86 -0
  59. package/docs/investigations/hlg-runs/runG-shaper-sweep.log +101 -0
  60. package/docs/investigations/hlg-runs/runH-wsweep.log +176 -0
  61. package/docs/investigations/hlg-runs/runI-asweep.log +176 -0
  62. package/docs/investigations/hlg-runs/runJ-knobmap.log +49 -0
  63. package/docs/investigations/hlg-runs/runK-frontier.log +19 -0
  64. package/docs/investigations/hlg-runs/runL-widemap.log +65 -0
  65. package/docs/investigations/hlg-runs/runM-frontier-emb.log +15 -0
  66. package/docs/investigations/hlg-runs/runN-verify.log +116 -0
  67. package/docs/investigations/hlg-runs/runO-frontier-seed5000.log +15 -0
  68. package/docs/investigations/hlg-runs/runP-grid-AxSm.log +15 -0
  69. package/docs/investigations/hlg-runs/runQ-grid-tgW.log +15 -0
  70. package/docs/investigations/hlg-runs/runR-blind-transcript.log +79 -0
  71. package/docs/investigations/hlg-runs/runS-cross-coherence.log +33 -0
  72. package/docs/investigations/hlg-runs/runT-cross-cpm5.log +33 -0
  73. package/docs/investigations/hlg-runs/runU-cross-12b.log +33 -0
  74. package/docs/investigations/hlg-runs/runV-lw-e4b.log +22 -0
  75. package/docs/investigations/hlg-runs/runW-lw-12b.log +22 -0
  76. package/docs/investigations/hlg-runs/runX-lw-cpm5.log +22 -0
  77. package/docs/investigations/hlg-runs/runY-cliff-e4b.log +26 -0
  78. package/docs/investigations/hlg-runs/runZ-cliff-12b.log +26 -0
  79. package/docs/investigations/hlg-sampling-investigation.md +970 -0
  80. package/docs/investigations/lab-build-journal.md +157 -0
  81. package/docs/investigations/pi-builtin-investigation.md +308 -0
  82. package/docs/investigations/starter-model-port-handoff.md +323 -0
  83. package/docs/planning/IDEAS.md +26 -0
  84. package/docs/planning/PRODUCT_ROADMAP.md +426 -0
  85. package/docs/planning/ResearchTopics.md +119 -0
  86. package/docs/planning/chunk-task-roadmap.md +77 -0
  87. package/docs/planning/journal.md +155 -0
  88. package/docs/reference/distribution.md +121 -0
  89. package/docs/reference/embedding.md +168 -0
  90. package/docs/reference/library-api.md +153 -0
  91. package/docs/reference/server-api.md +224 -0
  92. package/docs/reference/server-config.md +243 -0
  93. package/docs/segmented-backward-handoff.md +148 -0
  94. package/package.json +68 -0
  95. package/src/anthropic.ts +491 -0
  96. package/src/chat-template.ts +186 -0
  97. package/src/cli.ts +1075 -0
  98. package/src/config.ts +229 -0
  99. package/src/curve-sampler.ts +122 -0
  100. package/src/dataset/generators.ts +1087 -0
  101. package/src/dataset/index.ts +12 -0
  102. package/src/dataset/job.ts +81 -0
  103. package/src/dataset/llm.ts +71 -0
  104. package/src/dataset/registry.ts +528 -0
  105. package/src/download.ts +283 -0
  106. package/src/eval/README.md +86 -0
  107. package/src/eval/kl-prompts.ts +29 -0
  108. package/src/eval/kl.ts +294 -0
  109. package/src/eval/quality-db.ts +92 -0
  110. package/src/eval/runner.ts +102 -0
  111. package/src/eval/score.ts +49 -0
  112. package/src/eval/tasks/bfcl.ts +500 -0
  113. package/src/eval/tasks/gsm8k.ts +85 -0
  114. package/src/eval/tasks/hashhop.ts +109 -0
  115. package/src/eval/tasks/humaneval.ts +252 -0
  116. package/src/eval/tasks/ifeval.ts +354 -0
  117. package/src/eval/tasks/mmlu.ts +145 -0
  118. package/src/evaldb.ts +199 -0
  119. package/src/expert-offload-build.ts +77 -0
  120. package/src/expert-offload.ts +61 -0
  121. package/src/expert-trace.ts +67 -0
  122. package/src/fit.ts +254 -0
  123. package/src/generate.ts +451 -0
  124. package/src/harness-pi.ts +177 -0
  125. package/src/hf-push.ts +628 -0
  126. package/src/index.ts +22 -0
  127. package/src/jobs/db.ts +139 -0
  128. package/src/jobs/index.ts +29 -0
  129. package/src/jobs/job-entry.ts +106 -0
  130. package/src/jobs/runner.ts +304 -0
  131. package/src/jobs/sse.ts +114 -0
  132. package/src/jobs/types.ts +58 -0
  133. package/src/kv-store.ts +185 -0
  134. package/src/lora.ts +314 -0
  135. package/src/md-modules.d.ts +7 -0
  136. package/src/mlx/array.ts +237 -0
  137. package/src/mlx/autograd.ts +267 -0
  138. package/src/mlx/checkpoint.ts +116 -0
  139. package/src/mlx/compile.ts +158 -0
  140. package/src/mlx/custom-vjp.ts +140 -0
  141. package/src/mlx/ffi.ts +433 -0
  142. package/src/mlx/metal-kernel.ts +130 -0
  143. package/src/mlx/ops.ts +707 -0
  144. package/src/mmap.ts +90 -0
  145. package/src/model/batched-mask.ts +204 -0
  146. package/src/model/batched-rotating.ts +300 -0
  147. package/src/model/compiled-decode.ts +681 -0
  148. package/src/model/factory.ts +27 -0
  149. package/src/model/fingerprint.ts +33 -0
  150. package/src/model/flash-attention.ts +674 -0
  151. package/src/model/fused-decode-kernel.ts +305 -0
  152. package/src/model/fused-geglu-kernel.ts +87 -0
  153. package/src/model/gemma4-base.ts +1616 -0
  154. package/src/model/gemma4.ts +1012 -0
  155. package/src/model/generated/gemma4-12b.ts +526 -0
  156. package/src/model/generated/gemma4-26b.ts +500 -0
  157. package/src/model/generated/gemma4-e4b.ts +757 -0
  158. package/src/model/generated/index.ts +18 -0
  159. package/src/model/minicpm5.ts +278 -0
  160. package/src/model/qwen3-delta.ts +224 -0
  161. package/src/model/qwen3_5.ts +429 -0
  162. package/src/model/support.ts +46 -0
  163. package/src/native-pack.ts +152 -0
  164. package/src/pi-provider.ts +95 -0
  165. package/src/pi-terminal.ts +264 -0
  166. package/src/pi-web.ts +924 -0
  167. package/src/preflight.ts +123 -0
  168. package/src/prompt-cache.ts +113 -0
  169. package/src/quantize/allocator.ts +627 -0
  170. package/src/quantize/calibration.ts +180 -0
  171. package/src/quantize/config-writer.ts +132 -0
  172. package/src/quantize/index.ts +57 -0
  173. package/src/quantize/job.ts +136 -0
  174. package/src/quantize/quantizer.ts +462 -0
  175. package/src/quantize/safetensors-writer.ts +171 -0
  176. package/src/quantize/sensitivity.ts +234 -0
  177. package/src/registry.ts +336 -0
  178. package/src/responses.ts +774 -0
  179. package/src/safetensors.ts +158 -0
  180. package/src/sampler.ts +517 -0
  181. package/src/serve/batch-scheduler.ts +362 -0
  182. package/src/serve/generation-gateway.ts +160 -0
  183. package/src/server.ts +1891 -0
  184. package/src/spec/drafter.ts +261 -0
  185. package/src/spec/generate.ts +221 -0
  186. package/src/status-page.html +497 -0
  187. package/src/tokenizer.ts +38 -0
  188. package/src/tool-call.ts +242 -0
  189. package/src/train/dataset.ts +461 -0
  190. package/src/train/export.ts +32 -0
  191. package/src/train/forward.ts +229 -0
  192. package/src/train/index.ts +43 -0
  193. package/src/train/job.ts +148 -0
  194. package/src/train/lora-params.ts +194 -0
  195. package/src/train/loss.ts +342 -0
  196. package/src/train/merge.ts +195 -0
  197. package/src/train/optimizer.ts +176 -0
  198. package/src/train/rank.ts +101 -0
  199. package/src/train/segmented.ts +520 -0
  200. package/src/train/trainer.ts +600 -0
  201. package/src/tui.ts +161 -0
  202. package/src/vision/embedder.ts +168 -0
  203. package/src/vision/preprocess.ts +247 -0
  204. package/src/vision/prompt.ts +158 -0
  205. package/src/vision/siglip.ts +599 -0
  206. package/src/web/app.html +2471 -0
  207. package/src/web/skills/web-research/SKILL.md +44 -0
  208. package/src/web/skills.ts +59 -0
  209. package/src/web-tools.ts +426 -0
  210. package/src/weights.ts +91 -0
@@ -0,0 +1,1012 @@
1
+ // Gemma 4 text model — line-for-line port of mlx-lm's gemma4_text.py
2
+ // (oracle venv site-packages), covering the paths our target models
3
+ // exercise: 12B (dense), e4b (per-layer-input embeddings + KV-shared
4
+ // layers), 26B-A4B (MoE block: router + gather_qmm experts).
5
+ //
6
+ // Parity notes (see PLAN.md Phase 2 findings):
7
+ // - SDPA scale is 1.0 (Gemma4 normalizes q/k instead).
8
+ // - Full-attention layers: global_head_dim 512, 1 global KV head,
9
+ // attention_k_eq_v (V = same projection as K, with un-scaled RMS norm);
10
+ // ProportionalRoPE rotates only partial_rotary_factor·dims dims.
11
+ // - Python-float scalars promote weakly to the array dtype.
12
+ // - Replicate mlx python helper implementations exactly (x**3 is
13
+ // mx.power, not x·x·x — they round differently in bf16).
14
+ //
15
+ // Masks: ports base.py create_attention_mask/create_causal_mask. Sliding
16
+ // layers use a plain (non-rotating) cache + window masks — numerically
17
+ // identical to mlx-lm's RotatingKVCache, at the cost of unbounded cache
18
+ // growth past the window (memory optimization deferred).
19
+
20
+ import type { ModelConfig } from "../config";
21
+ import { quantFor } from "../config";
22
+ import type { Weights } from "../weights";
23
+ import { MlxArray } from "../mlx/array";
24
+ import { Dtype } from "../mlx/ffi";
25
+ import * as ops from "../mlx/ops";
26
+ import { isExpertTracing, recordRouting } from "../expert-trace";
27
+
28
+ // The shared, config-independent machinery lives in gemma4-base.ts
29
+ // (Phase B extraction); this file keeps the architecture-specific
30
+ // assembly that Phase C generates per model. Re-export the base so
31
+ // existing importers keep one entry point.
32
+ export * from "./gemma4-base";
33
+ import {
34
+ argmaxLastPosition,
35
+ bidirMask,
36
+ disposing,
37
+ isCompiledTrace,
38
+ KVCache,
39
+ logitSoftcap,
40
+ LoraState,
41
+ QuantizedEmbedding,
42
+ QuantizedKVCache,
43
+ QuantizedLinear,
44
+ QuantizedSwitchLinear,
45
+ RMSNorm,
46
+ RotatingKVCache,
47
+ RotatingQuantizedKVCache,
48
+ quantizedSdpa,
49
+ type Cache,
50
+ type Mask,
51
+ type SharedKv,
52
+ type LoraWeights,
53
+ } from "./gemma4-base";
54
+ import { fusedGeglu, fusedGeluEnabled, fusedGeluSupported } from "./fused-geglu-kernel";
55
+ import { Checkpoint } from "../mlx/checkpoint";
56
+ import { flashAttention, getTrainingAttn, flashSupported } from "./flash-attention";
57
+
58
+ /** Optional gradient-checkpointing context for the training forward. When set,
59
+ * forwardLayers wraps each layer in a Checkpoint so its interior activations
60
+ * are recomputed in the backward pass. `byLayer` maps a layer index to that
61
+ * layer's trainable LoRA weights (re-swapped inside the closure so they are
62
+ * explicit checkpoint inputs — required because the autograd primals are
63
+ * disposed before the recompute). `keepAlive` collects the per-layer
64
+ * Checkpoint objects; the trainer disposes them after value_and_grad. */
65
+ export interface GradCheckpointCtx {
66
+ byLayer: Map<number, LoraWeights[]>;
67
+ keepAlive: Checkpoint[];
68
+ }
69
+
70
+ class Attention {
71
+ readonly isSliding: boolean;
72
+ readonly windowSize: number;
73
+ readonly useKEqV: boolean;
74
+ readonly hasKv: boolean;
75
+ readonly headDim: number;
76
+ readonly nHeads: number;
77
+ readonly nKvHeads: number;
78
+ readonly ropeBase: number | null;
79
+ readonly ropeFreqs: MlxArray | null;
80
+ readonly qProj: QuantizedLinear;
81
+ readonly kProj: QuantizedLinear | null;
82
+ readonly vProj: QuantizedLinear | null;
83
+ readonly oProj: QuantizedLinear;
84
+ readonly qNorm: RMSNorm;
85
+ readonly kNorm: RMSNorm | null;
86
+ readonly vNorm: RMSNorm | null;
87
+
88
+ constructor(
89
+ weights: Weights, config: ModelConfig, prefix: string, layerType: string,
90
+ hasKv = true,
91
+ ) {
92
+ const t = config.text;
93
+ this.hasKv = hasKv;
94
+ this.isSliding = layerType === "sliding_attention";
95
+ this.windowSize = t.slidingWindow;
96
+ this.headDim = this.isSliding ? t.headDim : t.globalHeadDim;
97
+ this.nHeads = t.numAttentionHeads;
98
+ this.useKEqV = t.attentionKEqV && !this.isSliding;
99
+ this.nKvHeads = this.useKEqV ? t.numGlobalKeyValueHeads : t.numKeyValueHeads;
100
+
101
+ const rp = t.ropeParameters[this.isSliding ? "sliding_attention" : "full_attention"]!;
102
+ if (rp.ropeType === "default") {
103
+ this.ropeBase = rp.ropeTheta;
104
+ this.ropeFreqs = null;
105
+ } else if (rp.ropeType === "proportional") {
106
+ // Port of rope_utils.ProportionalRoPE.__init__. The rotated freqs
107
+ // MUST be computed on-device in f32 (arange/dims, then base**x)
108
+ // exactly like the reference — computing them host-side in f64 and
109
+ // rounding to f32 lands 17/64 of them 1 ulp off (f64 pow ≠ f32
110
+ // powf), a latent knife-edge that Phase 10's tiled values exposed
111
+ // as a 1-ulp q-rope divergence at layer 11 (Phase 2 porting rule:
112
+ // replicate the helper's IMPLEMENTATION, not its formula).
113
+ const rotated = Math.floor(this.headDim * rp.partialRotaryFactor);
114
+ const n = this.headDim / 2;
115
+ const exponentsRaw = ops.arange(0, rotated, 2, Dtype.float32);
116
+ const dims = ops.scalarLike(this.headDim, exponentsRaw);
117
+ const exponents = ops.div(exponentsRaw, dims);
118
+ const base = ops.scalarLike(rp.ropeTheta, exponents);
119
+ let rotFreqs = ops.pow(base, exponents);
120
+ if (rp.factor !== 1.0) {
121
+ const f = ops.scalarLike(rp.factor, rotFreqs);
122
+ rotFreqs = disposing(rotFreqs, ops.mul(f, rotFreqs));
123
+ f.dispose();
124
+ }
125
+ this.ropeBase = null;
126
+ const tailLen = n - rotated / 2;
127
+ if (tailLen > 0) {
128
+ const tail = MlxArray.fromFloat32(
129
+ new Float32Array(tailLen).fill(Infinity), [tailLen],
130
+ );
131
+ this.ropeFreqs = ops.concatAxis([rotFreqs, tail], 0);
132
+ tail.dispose();
133
+ } else {
134
+ this.ropeFreqs = rotFreqs;
135
+ }
136
+ for (const a of [exponentsRaw, dims, exponents, base])
137
+ a.dispose();
138
+ if (this.ropeFreqs !== rotFreqs) rotFreqs.dispose();
139
+ } else {
140
+ throw new Error(`unsupported rope_type ${rp.ropeType}`);
141
+ }
142
+
143
+ this.qProj = QuantizedLinear.load(weights, `${prefix}.q_proj`, config);
144
+ this.kProj = hasKv ? QuantizedLinear.load(weights, `${prefix}.k_proj`, config) : null;
145
+ this.vProj = hasKv && !this.useKEqV
146
+ ? QuantizedLinear.load(weights, `${prefix}.v_proj`, config) : null;
147
+ this.oProj = QuantizedLinear.load(weights, `${prefix}.o_proj`, config);
148
+ this.qNorm = new RMSNorm(weights.tensor(`${prefix}.q_norm.weight`), t.rmsNormEps);
149
+ this.kNorm = hasKv
150
+ ? new RMSNorm(weights.tensor(`${prefix}.k_norm.weight`), t.rmsNormEps) : null;
151
+ this.vNorm = hasKv ? new RMSNorm(null, t.rmsNormEps) : null;
152
+ }
153
+
154
+ /** A number offset takes the static fast::rope; an array offset (set by
155
+ * compiled-decode trace adapters) takes the dynamic variant — same
156
+ * kernel, offset read from the array (bit-exactness asserted in
157
+ * tests/compile.test.ts). */
158
+ rope(x: MlxArray, offset: number | MlxArray): MlxArray {
159
+ return typeof offset === "number"
160
+ ? ops.rope(x, this.headDim, this.ropeBase, offset, this.ropeFreqs)
161
+ : ops.ropeDynamic(x, this.headDim, this.ropeBase, offset, this.ropeFreqs);
162
+ }
163
+
164
+ /** Returns the attention output plus the fetched KV (for KV-shared
165
+ * sharer layers and the speculative drafter). The SharedKv arrays are
166
+ * owned by the caller (forwardLayers) and disposed after the pass. */
167
+ forward(
168
+ x: MlxArray, mask: Mask, cache: Cache | null, sharedIn: SharedKv | null,
169
+ ): { out: MlxArray; shared: SharedKv } {
170
+ const [B, L] = x.shape as [number, number, number];
171
+
172
+ let q = this.qProj.forward(x);
173
+ q = disposing(q, ops.reshape(q, [B, L, this.nHeads, this.headDim]));
174
+ q = disposing(q, this.qNorm.forward(q));
175
+
176
+ let shared: SharedKv;
177
+ if (!this.hasKv) {
178
+ if (!sharedIn) throw new Error("KV-shared layer received no shared KV");
179
+ shared = sharedIn;
180
+ } else {
181
+ if (!cache) throw new Error("donor layer requires a cache");
182
+ let k = this.kProj!.forward(x);
183
+ k = disposing(k, ops.reshape(k, [B, L, this.nKvHeads, this.headDim]));
184
+ let v: MlxArray;
185
+ if (this.vProj) {
186
+ v = this.vProj.forward(x);
187
+ v = disposing(v, ops.reshape(v, [B, L, this.nKvHeads, this.headDim]));
188
+ } else {
189
+ v = k; // attention_k_eq_v: shared projection; norms differ below
190
+ }
191
+
192
+ const offset = cache.offset;
193
+ // Capture the RoPE offset array ONCE (like `offset` above): the
194
+ // updateAndFetch below advances cache.offset, so re-reading
195
+ // cache.ropeOffsetArr for Q *after* the write would hand K and Q
196
+ // different positions. Harmless today (real caches leave it unset;
197
+ // compiled-decode passes a constant trace input), but a hard
198
+ // prerequisite for batched decode, where ropeOffsetArr carries per-row
199
+ // positions derived from the pre-write offset and MUST be identical for
200
+ // this step's K and Q.
201
+ const offsetArr = cache.ropeOffsetArr;
202
+
203
+ const kNormed = this.kNorm!.forward(k);
204
+ const kT = ops.transposeAxes(kNormed, [0, 2, 1, 3]);
205
+ kNormed.dispose();
206
+ const kRoped = this.rope(kT, offsetArr ?? offset);
207
+ kT.dispose();
208
+
209
+ const vNormed = this.vNorm!.forward(v);
210
+ const vT = ops.transposeAxes(vNormed, [0, 2, 1, 3]);
211
+ vNormed.dispose();
212
+ if (v !== k) v.dispose();
213
+ k.dispose();
214
+
215
+ if (cache instanceof QuantizedKVCache || cache instanceof RotatingQuantizedKVCache) {
216
+ const [kq, vq] = cache.updateAndFetchQuantized(kRoped, vT);
217
+ kRoped.dispose();
218
+ vT.dispose();
219
+ shared = {
220
+ kind: "quant", keys: kq, values: vq, offset,
221
+ groupSize: cache.groupSize, bits: cache.bits,
222
+ offsetArr,
223
+ };
224
+ } else {
225
+ const [keys, values] = cache.updateAndFetch(kRoped, vT);
226
+ kRoped.dispose();
227
+ vT.dispose();
228
+ shared = { kind: "plain", keys, values, offset, offsetArr };
229
+ }
230
+ }
231
+
232
+ q = disposing(q, ops.transposeAxes(q, [0, 2, 1, 3]));
233
+ q = disposing(q, this.rope(q, shared.offsetArr ?? shared.offset));
234
+
235
+ let attn: MlxArray;
236
+ const ta = getTrainingAttn();
237
+ if (shared.kind === "quant") {
238
+ attn = quantizedSdpa(q, shared.keys, shared.values, 1.0, mask, shared.groupSize, shared.bits);
239
+ } else if (
240
+ ta === "flash" && flashSupported(q) && (mask.mode === "causal" || mask.mode === "array")
241
+ ) {
242
+ // Training: ops.sdpa's dK vjp is wrong, so use the validated flash kernel.
243
+ // Full layers → window 0 (pure causal); sliding layers → their window.
244
+ const window = this.isSliding ? this.windowSize : 0;
245
+ attn = flashAttention(q, shared.keys, shared.values, 1.0, true, window);
246
+ } else {
247
+ attn = ops.sdpa(q, shared.keys, shared.values, 1.0, mask.mode, mask.arr);
248
+ }
249
+ q.dispose();
250
+ const attnT = ops.transposeAxes(attn, [0, 2, 1, 3]);
251
+ attn.dispose();
252
+ const merged = ops.reshape(attnT, [B, L, -1]);
253
+ attnT.dispose();
254
+ const out = this.oProj.forward(merged);
255
+ merged.dispose();
256
+ return { out, shared };
257
+ }
258
+ }
259
+
260
+ class MLP {
261
+ readonly gate: QuantizedLinear;
262
+ readonly up: QuantizedLinear;
263
+ readonly down: QuantizedLinear;
264
+
265
+ constructor(weights: Weights, config: ModelConfig, prefix: string) {
266
+ this.gate = QuantizedLinear.load(weights, `${prefix}.gate_proj`, config);
267
+ this.up = QuantizedLinear.load(weights, `${prefix}.up_proj`, config);
268
+ this.down = QuantizedLinear.load(weights, `${prefix}.down_proj`, config);
269
+ }
270
+
271
+ forward(x: MlxArray): MlxArray {
272
+ const g = this.gate.forward(x);
273
+ const u = this.up.forward(x);
274
+ // Fused GeGLU perf kernel (opt-in, not in a compiled trace): one pass
275
+ // instead of ~9 element-wise kernels. Off → the bit-exact spelled path.
276
+ let h: MlxArray;
277
+ if (fusedGeluEnabled() && !isCompiledTrace() && fusedGeluSupported(g)) {
278
+ h = fusedGeglu(g, u);
279
+ g.dispose();
280
+ u.dispose();
281
+ } else {
282
+ const act = ops.geluApprox(g);
283
+ g.dispose();
284
+ h = ops.mul(act, u);
285
+ act.dispose();
286
+ u.dispose();
287
+ }
288
+ const out = this.down.forward(h);
289
+ h.dispose();
290
+ return out;
291
+ }
292
+ }
293
+
294
+ // --- MoE block (26B-A4B) — port of reference Router/Experts/SwitchGLU ----
295
+ // (gemma4_text.py + switch_layers.py in the oracle venv). The checkpoint
296
+ // ships pre-stacked switch_glu tensors [experts, out, in/packed]; only the
297
+ // quantized path exists here (all our targets are OptiQ quants).
298
+
299
+ /** Port of reference Router: norm -> scale -> project -> top-k -> renormalize. */
300
+ class Router {
301
+ readonly proj: QuantizedLinear;
302
+ /** scale · hidden_size^-0.5, precomputed (identical ops to the per-call
303
+ * product in the reference: bf16 array × weak scalar). */
304
+ readonly normWeight: MlxArray;
305
+ readonly perExpertScale: MlxArray;
306
+ readonly eps: number;
307
+ readonly numExperts: number;
308
+ readonly topK: number;
309
+ /** Decoder layer index (parsed from the weight prefix) — for E0 tracing. */
310
+ readonly layerIdx: number;
311
+
312
+ constructor(weights: Weights, config: ModelConfig, prefix: string) {
313
+ const t = config.text;
314
+ this.layerIdx = Number(prefix.match(/layers\.(\d+)/)?.[1] ?? -1);
315
+ this.proj = QuantizedLinear.load(weights, `${prefix}.proj`, config);
316
+ // weights.tensor() returns the Weights-owned cached array — do NOT
317
+ // dispose it (a second model over the same Weights would get a dead
318
+ // handle; Weights.dispose() frees it at end of life)
319
+ const scale = weights.tensor(`${prefix}.scale`);
320
+ this.normWeight = ops.mulScalar(scale, Math.pow(t.hiddenSize, -0.5));
321
+ this.perExpertScale = weights.tensor(`${prefix}.per_expert_scale`);
322
+ this.eps = t.rmsNormEps;
323
+ this.numExperts = t.numExperts;
324
+ this.topK = t.topKExperts;
325
+ }
326
+
327
+ /** x [B, L, H] → { indices [B, L, k] (uint32), weights [B, L, k] }. */
328
+ forward(x: MlxArray): { indices: MlxArray; weights: MlxArray } {
329
+ const normed = ops.rmsNorm(x, this.normWeight, this.eps);
330
+ const scores = this.proj.forward(normed);
331
+ normed.dispose();
332
+
333
+ const part = ops.argpartitionAxis(scores, this.numExperts - this.topK, -1);
334
+ const [B, L] = scores.shape as [number, number, number];
335
+ let indices: MlxArray;
336
+ if (isCompiledTrace()) {
337
+ const start = ops.fromInt32([this.numExperts - this.topK], [1]);
338
+ indices = ops.sliceDynamic(part, start, [2], [B, L, this.topK]);
339
+ start.dispose();
340
+ } else {
341
+ indices = part.slice([0, 0, this.numExperts - this.topK], [B, L, this.numExperts]);
342
+ }
343
+ part.dispose();
344
+
345
+ let w = ops.takeAlongAxis(scores, indices, -1);
346
+ scores.dispose();
347
+ w = disposing(w, ops.softmaxAxis(w, -1, false));
348
+ const gathered = ops.takeAxis(this.perExpertScale, indices, 0);
349
+ w = disposing(w, ops.mul(w, gathered));
350
+ gathered.dispose();
351
+ if (isExpertTracing()) recordRouting(this.layerIdx, indices);
352
+ return { indices, weights: w };
353
+ }
354
+ }
355
+
356
+ /** Port of switch_layers.SwitchGLU (geglu activation), quantized path. */
357
+ class SwitchGLU {
358
+ readonly gate: QuantizedSwitchLinear;
359
+ readonly up: QuantizedSwitchLinear;
360
+ readonly down: QuantizedSwitchLinear;
361
+
362
+ constructor(weights: Weights, config: ModelConfig, prefix: string) {
363
+ this.gate = QuantizedSwitchLinear.load(weights, `${prefix}.gate_proj`, config);
364
+ this.up = QuantizedSwitchLinear.load(weights, `${prefix}.up_proj`, config);
365
+ this.down = QuantizedSwitchLinear.load(weights, `${prefix}.down_proj`, config);
366
+ }
367
+
368
+ /** x [B, L, H], indices [B, L, k] → [B, L, k, H]. */
369
+ forward(x: MlxArray, indices: MlxArray): MlxArray {
370
+ // x → [B, L, 1, 1, H] (reference expand_dims(x, (-2, -3)))
371
+ let h = ops.expandDims(x, -2);
372
+ h = disposing(h, ops.expandDims(h, -3));
373
+
374
+ // Sort tokens by expert when there are many rows (reference threshold).
375
+ const doSort = indices.size >= 64;
376
+ let idx = indices;
377
+ let invOrder: MlxArray | null = null;
378
+ let order: MlxArray | null = null;
379
+ if (doSort) {
380
+ // _gather_sort: flatten indices, argsort, gather rows by order // k
381
+ const k = indices.shape[indices.ndim - 1]!;
382
+ const idxFlat = ops.reshape(indices, [indices.size]);
383
+ order = ops.argsortAxis(idxFlat, 0);
384
+ invOrder = ops.argsortAxis(order, 0);
385
+ const kScalar = ops.scalarLike(k, order);
386
+ const rowIdx = ops.floorDivide(order, kScalar);
387
+ kScalar.dispose();
388
+ const [, , , , H] = h.shape as number[];
389
+ const flat = ops.reshape(h, [-1, 1, H!]);
390
+ h.dispose();
391
+ h = ops.takeAxis(flat, rowIdx, 0);
392
+ flat.dispose();
393
+ rowIdx.dispose();
394
+ idx = ops.takeAxis(idxFlat, order, 0);
395
+ idxFlat.dispose();
396
+ }
397
+
398
+ const xUp = this.up.forward(h, idx, doSort);
399
+ const xGate = this.gate.forward(h, idx, doSort);
400
+ h.dispose();
401
+ // GeGLU activation: gelu_approx(gate) · up (same composition as MLP)
402
+ const act = ops.geluApprox(xGate);
403
+ xGate.dispose();
404
+ const mid = ops.mul(act, xUp);
405
+ act.dispose();
406
+ xUp.dispose();
407
+ let y = this.down.forward(mid, idx, doSort);
408
+ mid.dispose();
409
+ if (idx !== indices) idx.dispose();
410
+
411
+ if (doSort) {
412
+ // _scatter_unsort: restore row order, unflatten to indices.shape
413
+ y = disposing(y, ops.takeAxis(y, invOrder!, 0));
414
+ invOrder!.dispose();
415
+ order!.dispose();
416
+ const [B, L, k] = indices.shape as number[];
417
+ const H = y.shape[y.ndim - 1]!;
418
+ y = disposing(y, ops.reshape(y, [B!, L!, k!, 1, H]));
419
+ }
420
+
421
+ // squeeze(-2)
422
+ const shape = y.shape;
423
+ shape.splice(shape.length - 2, 1);
424
+ return disposing(y, ops.reshape(y, shape));
425
+ }
426
+ }
427
+
428
+ /** Port of reference Experts: switch_glu then top-k weighted sum. */
429
+ class Experts {
430
+ readonly switchGlu: SwitchGLU;
431
+
432
+ constructor(weights: Weights, config: ModelConfig, prefix: string) {
433
+ this.switchGlu = new SwitchGLU(weights, config, `${prefix}.switch_glu`);
434
+ }
435
+
436
+ forward(x: MlxArray, indices: MlxArray, weights: MlxArray): MlxArray {
437
+ const w = ops.expandDims(weights, -1);
438
+ const y = this.switchGlu.forward(x, indices);
439
+ const wy = ops.mul(w, y);
440
+ w.dispose();
441
+ y.dispose();
442
+ return disposing(wy, ops.sumAxis(wy, -2, false));
443
+ }
444
+ }
445
+
446
+ export class DecoderLayer {
447
+ readonly attn: Attention;
448
+ readonly mlp: MLP;
449
+ readonly inputNorm: RMSNorm;
450
+ readonly postAttnNorm: RMSNorm;
451
+ readonly preFfNorm: RMSNorm;
452
+ readonly postFfNorm: RMSNorm;
453
+ readonly layerScalar: MlxArray | null;
454
+ readonly layerType: string;
455
+ // 26B-A4B MoE block (dense MLP + routed experts in parallel branches)
456
+ readonly router: Router | null;
457
+ readonly experts: Experts | null;
458
+ readonly postFfNorm1: RMSNorm | null;
459
+ readonly postFfNorm2: RMSNorm | null;
460
+ readonly preFfNorm2: RMSNorm | null;
461
+ // e2b/e4b per-layer input gating
462
+ readonly perLayerGate: QuantizedLinear | null;
463
+ readonly perLayerProjection: QuantizedLinear | null;
464
+ readonly postPerLayerNorm: RMSNorm | null;
465
+
466
+ constructor(weights: Weights, config: ModelConfig, prefixBase: string, idx: number, hasKv: boolean) {
467
+ const prefix = `${prefixBase}.layers.${idx}`;
468
+ const t = config.text;
469
+ this.layerType = t.layerTypes[idx]!;
470
+ this.attn = new Attention(weights, config, `${prefix}.self_attn`, this.layerType, hasKv);
471
+ this.mlp = new MLP(weights, config, `${prefix}.mlp`);
472
+ const norm = (n: string) => new RMSNorm(weights.tensor(`${prefix}.${n}.weight`), t.rmsNormEps);
473
+ this.inputNorm = norm("input_layernorm");
474
+ this.postAttnNorm = norm("post_attention_layernorm");
475
+ this.preFfNorm = norm("pre_feedforward_layernorm");
476
+ this.postFfNorm = norm("post_feedforward_layernorm");
477
+ this.layerScalar = weights.has(`${prefix}.layer_scalar`)
478
+ ? weights.tensor(`${prefix}.layer_scalar`)
479
+ : null;
480
+ if (t.enableMoeBlock) {
481
+ this.router = new Router(weights, config, `${prefix}.router`);
482
+ this.experts = new Experts(weights, config, `${prefix}.experts`);
483
+ this.postFfNorm1 = norm("post_feedforward_layernorm_1");
484
+ this.postFfNorm2 = norm("post_feedforward_layernorm_2");
485
+ this.preFfNorm2 = norm("pre_feedforward_layernorm_2");
486
+ } else {
487
+ this.router = this.experts = null;
488
+ this.postFfNorm1 = this.postFfNorm2 = this.preFfNorm2 = null;
489
+ }
490
+ if (t.hiddenSizePerLayerInput > 0) {
491
+ this.perLayerGate = QuantizedLinear.load(weights, `${prefix}.per_layer_input_gate`, config);
492
+ this.perLayerProjection = QuantizedLinear.load(weights, `${prefix}.per_layer_projection`, config);
493
+ this.postPerLayerNorm = norm("post_per_layer_input_norm");
494
+ } else {
495
+ this.perLayerGate = this.perLayerProjection = null;
496
+ this.postPerLayerNorm = null;
497
+ }
498
+ }
499
+
500
+ forward(
501
+ x: MlxArray, mask: Mask, cache: Cache | null,
502
+ sharedIn: SharedKv | null, perLayerInput: MlxArray | null,
503
+ ): { h: MlxArray; shared: SharedKv } {
504
+ let h = this.inputNorm.forward(x);
505
+ const { out, shared } = this.attn.forward(h, mask, cache, sharedIn);
506
+ h.dispose();
507
+ h = out;
508
+ h = disposing(h, this.postAttnNorm.forward(h));
509
+ h = disposing(h, ops.add(x, h));
510
+
511
+ const residual = h;
512
+ let f: MlxArray;
513
+ if (this.router && this.experts) {
514
+ // MoE: dense MLP and routed experts as parallel branches off h
515
+ // (reference DecoderLayer, enable_moe path)
516
+ let h1 = this.preFfNorm.forward(h);
517
+ h1 = disposing(h1, this.mlp.forward(h1));
518
+ h1 = disposing(h1, this.postFfNorm1!.forward(h1));
519
+
520
+ const { indices, weights: topKWeights } = this.router.forward(h);
521
+ let h2 = this.preFfNorm2!.forward(h);
522
+ h2 = disposing(h2, this.experts.forward(h2, indices, topKWeights));
523
+ h2 = disposing(h2, this.postFfNorm2!.forward(h2));
524
+ indices.dispose();
525
+ topKWeights.dispose();
526
+
527
+ f = ops.add(h1, h2);
528
+ h1.dispose();
529
+ h2.dispose();
530
+ } else {
531
+ f = this.preFfNorm.forward(h);
532
+ f = disposing(f, this.mlp.forward(f));
533
+ }
534
+ f = disposing(f, this.postFfNorm.forward(f));
535
+ h = ops.add(residual, f);
536
+ residual.dispose();
537
+ f.dispose();
538
+
539
+ // per-layer input gating (reference gemma4_text DecoderLayer)
540
+ if (this.perLayerGate && this.perLayerProjection && this.postPerLayerNorm && perLayerInput) {
541
+ const res2 = h;
542
+ let gate = this.perLayerGate.forward(h);
543
+ gate = disposing(gate, ops.geluApprox(gate));
544
+ gate = disposing(gate, ops.mul(gate, perLayerInput));
545
+ gate = disposing(gate, this.perLayerProjection.forward(gate));
546
+ gate = disposing(gate, this.postPerLayerNorm.forward(gate));
547
+ h = ops.add(res2, gate);
548
+ res2.dispose();
549
+ gate.dispose();
550
+ }
551
+
552
+ if (this.layerScalar) h = disposing(h, ops.mul(h, this.layerScalar));
553
+ return { h, shared };
554
+ }
555
+ }
556
+
557
+ export class Gemma4Model {
558
+ readonly config: ModelConfig;
559
+ /** Total weight-shard bytes (for the conditional wired-limit scope). */
560
+ readonly weightsBytes: number;
561
+ /** Weight-name prefix ("language_model.model" or "model"). */
562
+ readonly prefixBase: string;
563
+ /** Active LoRA adapters for the current generation (see LoraState). */
564
+ readonly loraState = new LoraState();
565
+ readonly embed: QuantizedEmbedding;
566
+ readonly layers: DecoderLayer[];
567
+ readonly finalNorm: RMSNorm;
568
+ readonly embedScale: number;
569
+ readonly windowSize: number;
570
+ /** Donor count: layers [0, numDonors) own caches; the rest share. */
571
+ readonly numDonors: number;
572
+ /** layer idx → donor layer idx whose fetched KV it consumes (self for donors). */
573
+ readonly previousKvs: number[];
574
+ /** layer idx → cache index (donors only; -1 for sharers). */
575
+ readonly cacheIndex: number[];
576
+ /** Set by the trainer to enable gradient checkpointing in forwardLayers
577
+ * (null = off; the non-checkpointed path is untouched). */
578
+ gradCkpt: GradCheckpointCtx | null = null;
579
+ // e2b/e4b per-layer input machinery
580
+ readonly perLayerEmbed: QuantizedEmbedding | null;
581
+ readonly perLayerModelProjection: QuantizedLinear | null;
582
+ readonly perLayerProjectionNorm: RMSNorm | null;
583
+ readonly perLayerWidth: number;
584
+
585
+ constructor(weights: Weights, config: ModelConfig) {
586
+ const t = config.text;
587
+ if (t.hiddenSize === 0 || !config.text.tieWordEmbeddings)
588
+ throw new Error("only tied-embedding text configs are supported");
589
+
590
+ this.config = config;
591
+ this.weightsBytes = [...weights.shards.files.values()]
592
+ .reduce((a, f) => a + f.mmap.size, 0);
593
+ // gemma4_unified prefixes weights with language_model.; gemma4_text doesn't
594
+ const prefixBase = weights.has("language_model.model.embed_tokens.weight")
595
+ ? "language_model.model" : "model";
596
+ this.prefixBase = prefixBase;
597
+ this.embed = QuantizedEmbedding.load(weights, `${prefixBase}.embed_tokens`, config);
598
+
599
+ this.numDonors = t.numHiddenLayers - t.numKvSharedLayers;
600
+ this.layers = Array.from(
601
+ { length: t.numHiddenLayers },
602
+ (_, i) => new DecoderLayer(weights, config, prefixBase, i, i < this.numDonors),
603
+ );
604
+ // previous_kvs (reference Gemma4TextModel): sharer j uses the LAST
605
+ // donor of its own layer type
606
+ this.previousKvs = this.layers.map((_, i) => i);
607
+ if (t.numKvSharedLayers > 0) {
608
+ const lastByType: Record<string, number> = {};
609
+ for (let i = 0; i < this.numDonors; i++)
610
+ lastByType[this.layers[i]!.layerType] = i;
611
+ for (let j = this.numDonors; j < this.layers.length; j++) {
612
+ const donor = lastByType[this.layers[j]!.layerType];
613
+ if (donor === undefined)
614
+ throw new Error(`no donor layer of type ${this.layers[j]!.layerType}`);
615
+ this.previousKvs[j] = donor;
616
+ }
617
+ }
618
+ this.cacheIndex = this.layers.map((_, i) => (i < this.numDonors ? i : -1));
619
+
620
+ this.finalNorm = new RMSNorm(
621
+ weights.tensor(`${prefixBase}.norm.weight`),
622
+ t.rmsNormEps,
623
+ );
624
+ this.embedScale = Math.sqrt(t.hiddenSize);
625
+ this.windowSize = t.slidingWindow;
626
+
627
+ if (t.hiddenSizePerLayerInput > 0) {
628
+ this.perLayerEmbed = QuantizedEmbedding.load(weights, `${prefixBase}.embed_tokens_per_layer`, config);
629
+ this.perLayerModelProjection = QuantizedLinear.load(weights, `${prefixBase}.per_layer_model_projection`, config);
630
+ this.perLayerProjectionNorm = new RMSNorm(
631
+ weights.tensor(`${prefixBase}.per_layer_projection_norm.weight`), t.rmsNormEps,
632
+ );
633
+ this.perLayerWidth = t.hiddenSizePerLayerInput;
634
+ } else {
635
+ this.perLayerEmbed = this.perLayerModelProjection = null;
636
+ this.perLayerProjectionNorm = null;
637
+ this.perLayerWidth = 0;
638
+ }
639
+ }
640
+
641
+ /** LoRA-mountable linears, keyed by weight-file module path: optiq
642
+ * mount.py's 7 target suffixes PLUS the e2b/e4b per-layer-input
643
+ * projections — mlx-lm's trainer targets those on e4b and optiq's
644
+ * mount silently drops their trained weights (deviation documented in
645
+ * PLAN Phase 8 findings; we apply every adapter weight we can map).
646
+ * Expert pools stay non-targets (LoRASwitchLinear is future work). */
647
+ loraTargets(): Map<string, QuantizedLinear> {
648
+ const out = new Map<string, QuantizedLinear>();
649
+ for (let i = 0; i < this.layers.length; i++) {
650
+ const l = this.layers[i]!;
651
+ const p = `${this.prefixBase}.layers.${i}`;
652
+ const add = (suffix: string, lin: QuantizedLinear | null) => {
653
+ if (lin) out.set(`${p}.${suffix}`, lin);
654
+ };
655
+ add("self_attn.q_proj", l.attn.qProj);
656
+ add("self_attn.k_proj", l.attn.kProj);
657
+ add("self_attn.v_proj", l.attn.vProj);
658
+ add("self_attn.o_proj", l.attn.oProj);
659
+ add("mlp.gate_proj", l.mlp.gate);
660
+ add("mlp.up_proj", l.mlp.up);
661
+ add("mlp.down_proj", l.mlp.down);
662
+ add("per_layer_input_gate", l.perLayerGate);
663
+ add("per_layer_projection", l.perLayerProjection);
664
+ }
665
+ return out;
666
+ }
667
+
668
+ /** Caches for donor layers only (sharers consume donors' fetched KV). */
669
+ makeCache(): Cache[] {
670
+ return this.layers.slice(0, this.numDonors).map((l) =>
671
+ l.layerType === "sliding_attention"
672
+ ? new RotatingKVCache(this.windowSize)
673
+ : new KVCache(),
674
+ );
675
+ }
676
+
677
+ /** ids [1, L] → final-norm hidden states [1, L, hidden]. */
678
+ forwardHidden(ids: MlxArray, cache: Cache[]): MlxArray {
679
+ let h = this.embed.encode(ids);
680
+ h = disposing(h, ops.mulScalar(h, this.embedScale));
681
+ return this.forwardLayers(h, cache, null, ids);
682
+ }
683
+
684
+ /** Per-layer inputs (reference _get_per_layer_inputs +
685
+ * _project_per_layer_inputs): [1, L, nLayers, perLayerWidth]. */
686
+ protected computePerLayerInputs(ids: MlxArray, hScaled: MlxArray): MlxArray {
687
+ const t = this.config.text;
688
+ const B = ids.shape[0]!;
689
+ const L = ids.shape[1]!;
690
+ let pli = this.perLayerEmbed!.encode(ids); // [B, L, nLayers*width]
691
+ pli = disposing(pli, ops.mulScalar(pli, Math.sqrt(this.perLayerWidth)));
692
+ pli = disposing(pli, ops.reshape(pli, [B, L, t.numHiddenLayers, this.perLayerWidth]));
693
+
694
+ let proj = this.perLayerModelProjection!.forward(hScaled);
695
+ proj = disposing(proj, ops.mulScalar(proj, 1 / Math.sqrt(t.hiddenSize)));
696
+ proj = disposing(proj, ops.reshape(proj, [B, L, t.numHiddenLayers, this.perLayerWidth]));
697
+ proj = disposing(proj, this.perLayerProjectionNorm!.forward(proj));
698
+
699
+ let combined = ops.add(proj, pli);
700
+ proj.dispose();
701
+ pli.dispose();
702
+ combined = disposing(combined, ops.mulScalar(combined, Math.SQRT1_2));
703
+ return combined;
704
+ }
705
+
706
+ /** Pre-merged (unscaled) input embeddings → hidden states. Used by the
707
+ * vision path; `bidir` (bool [L]) marks image tokens, which attend
708
+ * bidirectionally among themselves (use_bidirectional_attention:
709
+ * "vision" — text stays causal). `ids` ([1, L], the spliced token ids)
710
+ * is required for per-layer-input models (e2b/e4b): image-token
711
+ * positions get token 0's per-layer embedding, and their vision content
712
+ * enters only through the projection term (the merged hidden). Matches
713
+ * optiq's frontend: `zeroed = where(text_mask, input_ids, 0)`. */
714
+ forwardEmbeddings(
715
+ embeds: MlxArray, cache: Cache[], bidir: MlxArray | null,
716
+ ids: MlxArray | null = null,
717
+ ): MlxArray {
718
+ const h = ops.mulScalar(embeds, this.embedScale);
719
+ if (this.perLayerWidth === 0) return this.forwardLayers(h, cache, bidir, null);
720
+
721
+ if (!ids)
722
+ throw new Error("per-layer-input vision models require token ids");
723
+ let plIds = ids;
724
+ if (bidir) {
725
+ const cond = ops.reshape(bidir, ids.shape);
726
+ const zero = ops.zeros(ids.shape, ids.dtype);
727
+ plIds = ops.where(cond, zero, ids);
728
+ cond.dispose();
729
+ zero.dispose();
730
+ }
731
+ const out = this.forwardLayers(h, cache, bidir, plIds);
732
+ if (plIds !== ids) plIds.dispose();
733
+ return out;
734
+ }
735
+
736
+ /** Consumes h. */
737
+ protected forwardLayers(
738
+ h0: MlxArray, cache: Cache[], bidir: MlxArray | null, ids: MlxArray | null,
739
+ ): MlxArray {
740
+ const B = h0.shape[0]!;
741
+ const L = h0.shape[1]!;
742
+ let h = h0;
743
+
744
+ // one mask per layer type, computed from the first cache of that type
745
+ const masks = new Map<string, Mask>();
746
+ for (let i = 0; i < this.numDonors; i++) {
747
+ const type = this.layers[i]!.layerType;
748
+ if (!masks.has(type)) {
749
+ const window = type === "sliding_attention" ? this.windowSize : null;
750
+ if (bidir && L > 1) {
751
+ const c = cache[i]!;
752
+ if (c.offset !== 0)
753
+ throw new Error("bidirectional image masks require offset-0 prefill");
754
+ masks.set(type, bidirMask(L, window, bidir));
755
+ } else {
756
+ masks.set(type, cache[i]!.makeMask(L, window));
757
+ }
758
+ }
759
+ }
760
+
761
+ // per-layer inputs (e2b/e4b)
762
+ let perLayer: MlxArray | null = null;
763
+ if (this.perLayerWidth > 0) {
764
+ if (!ids) throw new Error("per-layer-input models require token ids");
765
+ perLayer = this.computePerLayerInputs(ids, h);
766
+ }
767
+
768
+ const intermediates: (SharedKv | null)[] = Array(this.layers.length).fill(null);
769
+ for (let i = 0; i < this.layers.length; i++) {
770
+ const layer = this.layers[i]!;
771
+ const ci = this.cacheIndex[i]!;
772
+ const sharedIn = ci === -1 ? (intermediates[this.previousKvs[i]!] ?? null) : null;
773
+ let pls: MlxArray | null = null;
774
+ if (perLayer) {
775
+ if (isCompiledTrace()) {
776
+ const start = ops.fromInt32([i], [1]);
777
+ pls = ops.sliceDynamic(perLayer, start, [2], [B, L, 1, this.perLayerWidth]);
778
+ start.dispose();
779
+ } else {
780
+ pls = perLayer.slice([0, 0, i, 0], [B, L, i + 1, this.perLayerWidth]);
781
+ }
782
+ const r = ops.reshape(pls, [B, L, this.perLayerWidth]);
783
+ pls.dispose();
784
+ pls = r;
785
+ }
786
+ const mask = masks.get(layer.layerType)!;
787
+ const layerCache = ci === -1 ? null : cache[ci]!;
788
+ const { h: next, shared } = this.gradCkpt
789
+ ? this.runCheckpointedLayer(i, layer, h, mask, layerCache, sharedIn, pls)
790
+ : layer.forward(h, mask, layerCache, sharedIn, pls);
791
+ pls?.dispose();
792
+ h.dispose();
793
+ h = next;
794
+ intermediates[i] = shared;
795
+ }
796
+ perLayer?.dispose();
797
+ // dispose donor-fetched KV (each donor's shared entry exactly once)
798
+ for (let i = 0; i < this.numDonors; i++) {
799
+ const s = intermediates[i];
800
+ if (!s) continue;
801
+ if (s.kind === "plain") {
802
+ s.keys.dispose();
803
+ s.values.dispose();
804
+ } else {
805
+ for (const t of [s.keys, s.values])
806
+ for (const a of [t.packed, t.scales, t.biases]) a.dispose();
807
+ }
808
+ }
809
+ for (const m of masks.values()) m.arr?.dispose();
810
+
811
+ return disposing(h, this.finalNorm.forward(h));
812
+ }
813
+
814
+ // --- Segmented-backward support (docs/design/segmented-backward-training.md
815
+ // §4 Phase B). These are ADDITIVE — forwardLayers is untouched. The segmented
816
+ // driver builds masks + per-layer inputs ONCE, then drives runLayerRange per
817
+ // segment, threading the KV-shared donor K/V across segment boundaries.
818
+
819
+ /** Donor layer indices that KV-sharing sharers reuse (e4b: {22,23} — the last
820
+ * donor of each attention type; empty for dense models). Their fetched K/V
821
+ * must survive across segment boundaries (saved as detached boundaries). */
822
+ get reusedDonors(): Set<number> {
823
+ return new Set(this.previousKvs.filter((d, i) => d !== i));
824
+ }
825
+
826
+ /** Per-layer-type training masks (offset-0, full-sequence, no bidir) — built
827
+ * once, shared across all segments. Mirrors forwardLayers' mask construction.
828
+ * Caller owns the returned arrays (dispose each mask.arr). */
829
+ makeTrainingMasks(cache: Cache[], L: number): Map<string, Mask> {
830
+ const masks = new Map<string, Mask>();
831
+ for (let i = 0; i < this.numDonors; i++) {
832
+ const type = this.layers[i]!.layerType;
833
+ if (!masks.has(type)) {
834
+ const window = type === "sliding_attention" ? this.windowSize : null;
835
+ masks.set(type, cache[i]!.makeMask(L, window));
836
+ }
837
+ }
838
+ return masks;
839
+ }
840
+
841
+ /** e2b/e4b training entry: the SCALED input embedding (the first segment
842
+ * boundary) plus the per-layer-input tensor [B,L,nLayers,width] (a detached
843
+ * boundary, sliced per layer inside runLayerRange). perLayer is null for dense
844
+ * models. Caller owns both. */
845
+ embedForSegmented(ids: MlxArray): { hScaled: MlxArray; perLayer: MlxArray | null } {
846
+ let h = this.embed.encode(ids);
847
+ h = disposing(h, ops.mulScalar(h, this.embedScale));
848
+ const perLayer = this.perLayerWidth > 0 ? this.computePerLayerInputs(ids, h) : null;
849
+ return { hScaled: h, perLayer };
850
+ }
851
+
852
+ /** Dispose a fetched donor K/V (plain or quantized) — mirrors forwardLayers'
853
+ * donor cleanup. */
854
+ private static disposeSharedKv(s: SharedKv): void {
855
+ if (s.kind === "plain") {
856
+ s.keys.dispose();
857
+ s.values.dispose();
858
+ } else {
859
+ for (const t of [s.keys, s.values])
860
+ for (const a of [t.packed, t.scales, t.biases]) a.dispose();
861
+ }
862
+ }
863
+
864
+ /** Run decoder layers `[aIdx, bIdx)` on hidden `h`, returning the residual
865
+ * stream after the last layer in the range (NO finalNorm) plus the fetched
866
+ * K/V of any reused-donor layers in the range (`donorKvOut`, for later
867
+ * segments). The input `h` is NEVER disposed (the caller / vjp owns the
868
+ * boundary leaf); intra-range hidden intermediates and per-layer slices ARE
869
+ * disposed. Non-reused donors' K/V are disposed; reused donors' K/V are
870
+ * returned (caller threads + disposes). NOT gradient-checkpointed — the
871
+ * segmentation IS the memory strategy.
872
+ *
873
+ * @param masks per-layer-type masks (makeTrainingMasks), shared, caller-owned.
874
+ * @param perLayer the [B,L,nLayers,width] tensor (caller-owned) or null.
875
+ * @param donorKvIn fetched K/V of reused donors from EARLIER segments, keyed
876
+ * by donor layer index (caller-owned; not disposed here). */
877
+ runLayerRange(
878
+ h: MlxArray, aIdx: number, bIdx: number, cache: Cache[],
879
+ masks: Map<string, Mask>, perLayer: MlxArray | null,
880
+ donorKvIn: Map<number, SharedKv>,
881
+ ): { h: MlxArray; donorKvOut: Map<number, SharedKv> } {
882
+ const donorKvOut = new Map<number, SharedKv>();
883
+ if (aIdx >= bIdx) return { h, donorKvOut };
884
+ const B = h.shape[0]!;
885
+ const L = h.shape[1]!;
886
+ const reused = this.reusedDonors;
887
+ let cur = h;
888
+ for (let i = aIdx; i < bIdx; i++) {
889
+ const layer = this.layers[i]!;
890
+ const ci = this.cacheIndex[i]!;
891
+ const isDonor = ci !== -1;
892
+ let sharedIn: SharedKv | null = null;
893
+ if (!isDonor) {
894
+ const donor = this.previousKvs[i]!;
895
+ sharedIn = donorKvOut.get(donor) ?? donorKvIn.get(donor) ?? null;
896
+ if (!sharedIn) throw new Error(`runLayerRange: donor ${donor} K/V not available for sharer ${i}`);
897
+ }
898
+ let pls: MlxArray | null = null;
899
+ if (perLayer) {
900
+ const sl = perLayer.slice([0, 0, i, 0], [B, L, i + 1, this.perLayerWidth]);
901
+ pls = ops.reshape(sl, [B, L, this.perLayerWidth]);
902
+ sl.dispose();
903
+ }
904
+ const mask = masks.get(layer.layerType)!;
905
+ const { h: next, shared } = layer.forward(cur, mask, isDonor ? cache[ci]! : null, sharedIn, pls);
906
+ pls?.dispose();
907
+ if (i > aIdx) cur.dispose(); // keep the input boundary leaf; drop interiors
908
+ cur = next;
909
+ if (isDonor) {
910
+ if (reused.has(i)) donorKvOut.set(i, shared);
911
+ else Gemma4Model.disposeSharedKv(shared);
912
+ }
913
+ // sharer: `shared` === sharedIn (owned by donorKvIn/Out) — do not dispose.
914
+ }
915
+ return { h: cur, donorKvOut };
916
+ }
917
+
918
+ /** Run one layer under gradient checkpointing: its interior activations are
919
+ * dropped and recomputed in the backward pass. The layer's LoRA params, h,
920
+ * the mask array (if any), pls, and a sharer's donor KV are threaded as
921
+ * explicit checkpoint inputs (the closure must be pure and self-contained —
922
+ * it re-runs during the backward, and captured autograd primals would be
923
+ * stale). A donor layer also emits its fetched K/V as checkpoint outputs so
924
+ * the (retained, small) tensors reach the sharer layers downstream. */
925
+ private runCheckpointedLayer(
926
+ i: number, layer: DecoderLayer, h: MlxArray, mask: Mask,
927
+ layerCache: Cache | null, sharedIn: SharedKv | null, pls: MlxArray | null,
928
+ ): { h: MlxArray; shared: SharedKv | null } {
929
+ const ctx = this.gradCkpt!;
930
+ const lws = ctx.byLayer.get(i) ?? [];
931
+ const isSharer = layerCache === null;
932
+ const sIn = isSharer && sharedIn && sharedIn.kind === "plain" ? sharedIn : null;
933
+ const hasMaskArr = mask.arr !== null;
934
+ const hasPls = pls !== null;
935
+ const maskMode = mask.mode;
936
+
937
+ // Input order: [h, maskArr?, pls?, sharedInK?, sharedInV?, (lora_a, lora_b)*]
938
+ const inputs: MlxArray[] = [h];
939
+ if (hasMaskArr) inputs.push(mask.arr!);
940
+ if (hasPls) inputs.push(pls!);
941
+ if (sIn) inputs.push(sIn.keys, sIn.values);
942
+ for (const lw of lws) inputs.push(lw.a, lw.b);
943
+
944
+ const ck = new Checkpoint((ins) => {
945
+ let k = 0;
946
+ const hIn = ins[k++]!;
947
+ const mArr = hasMaskArr ? ins[k++]! : null;
948
+ const plsIn = hasPls ? ins[k++]! : null;
949
+ const sharedInRebuilt: SharedKv | null = sIn
950
+ ? { kind: "plain", keys: ins[k++]!, values: ins[k++]!, offset: 0 }
951
+ : null;
952
+ const saved = lws.map((lw) => [lw.a, lw.b] as [MlxArray, MlxArray]);
953
+ for (const lw of lws) { lw.a = ins[k++]!; lw.b = ins[k++]!; }
954
+ try {
955
+ const out = layer.forward(hIn, { mode: maskMode, arr: mArr }, layerCache, sharedInRebuilt, plsIn);
956
+ if (!isSharer && out.shared && out.shared.kind === "plain")
957
+ return [out.h, out.shared.keys, out.shared.values];
958
+ return [out.h];
959
+ } finally {
960
+ lws.forEach((lw, j) => { lw.a = saved[j]![0]; lw.b = saved[j]![1]; });
961
+ }
962
+ });
963
+ ctx.keepAlive.push(ck);
964
+
965
+ const outs = ck.apply(inputs);
966
+ const shared: SharedKv | null =
967
+ !isSharer && outs.length === 3
968
+ ? { kind: "plain", keys: outs[1]!, values: outs[2]!, offset: 0 }
969
+ : null;
970
+ return { h: outs[0]!, shared };
971
+ }
972
+
973
+ /** hidden [1, L, hidden] → softcapped logits [1, L, vocab]. */
974
+ logitsFromHidden(h: MlxArray): MlxArray {
975
+ let logits = this.embed.asLinear(h);
976
+ const softcap = this.config.text.finalLogitSoftcapping;
977
+ if (softcap !== null) logits = disposing(logits, logitSoftcap(logits, softcap));
978
+ return logits;
979
+ }
980
+
981
+ /** tokens → logits [1, L, vocab] (compat path used by parity tests). */
982
+ forward(tokens: number[] | MlxArray, cache: Cache[]): MlxArray {
983
+ const ids = Array.isArray(tokens)
984
+ ? ops.fromInt32(tokens, [1, tokens.length])
985
+ : tokens;
986
+ const h = this.forwardHidden(ids, cache);
987
+ if (Array.isArray(tokens)) ids.dispose();
988
+ const logits = this.logitsFromHidden(h);
989
+ h.dispose();
990
+ return logits;
991
+ }
992
+
993
+ /** Greedy generation (parity harness); returns generated token ids. */
994
+ generate(promptTokens: number[], maxTokens: number, eosIds: number[] = []): number[] {
995
+ const cache = this.makeCache();
996
+ const out: number[] = [];
997
+ try {
998
+ let tokens = promptTokens;
999
+ for (let step = 0; step < maxTokens; step++) {
1000
+ const logits = this.forward(tokens, cache);
1001
+ const next = argmaxLastPosition(logits);
1002
+ logits.dispose();
1003
+ if (eosIds.includes(next)) break;
1004
+ out.push(next);
1005
+ tokens = [next];
1006
+ }
1007
+ } finally {
1008
+ for (const c of cache) c.dispose();
1009
+ }
1010
+ return out;
1011
+ }
1012
+ }