mlx-bun 0.0.9 → 0.0.11

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 (179) hide show
  1. package/README.md +66 -21
  2. package/THIRD_PARTY_LICENSES.md +1 -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 +255 -0
  7. package/docs/design/batching-v2-plan.md +23 -9
  8. package/docs/design/bucketing-stage.md +2 -1
  9. package/docs/design/compat-cli-surface-design.md +4 -3
  10. package/docs/design/decode-speed-program.md +172 -0
  11. package/docs/design/docs-reorg-plan.md +1 -1
  12. package/docs/design/dspark-serving-program.md +371 -0
  13. package/docs/design/dspark-speculative-decoding.md +167 -5
  14. package/docs/design/faithful-l1-consolidation.md +310 -0
  15. package/docs/design/generic-model-support.md +8 -5
  16. package/docs/design/grammar-spec-batching-integration.md +301 -0
  17. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  18. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  19. package/docs/design/omlx-adoption-map.md +98 -0
  20. package/docs/design/orpo-training.md +34 -30
  21. package/docs/design/paged-kv-cache.md +214 -0
  22. package/docs/design/parallel-slots.md +82 -49
  23. package/docs/design/parity-tier-dag.md +43 -33
  24. package/docs/design/repo-cleanup-plan.md +189 -0
  25. package/docs/design/runtime-isolation.md +19 -3
  26. package/docs/design/segmented-backward-training.md +18 -2
  27. package/docs/design/ssd-kv-cold-tier.md +288 -0
  28. package/docs/design/structured-output.md +484 -0
  29. package/docs/design/the-dreaming-master-plan.md +2 -3
  30. package/docs/design/turboquant-kv.md +235 -0
  31. package/docs/design/unified-engine-frontier-plan.md +774 -0
  32. package/docs/design/web-chat-beat-matrix.md +315 -0
  33. package/docs/design/web-chat-redesign.md +1112 -0
  34. package/docs/investigations/decode-roofline-lookagain.md +92 -6
  35. package/docs/investigations/dspark-handoff.md +182 -16
  36. package/docs/investigations/kernel-perf-review-2026-07.md +105 -26
  37. package/docs/investigations/lab-build-journal.md +2 -2
  38. package/docs/investigations/openwiki-evaluation.md +249 -0
  39. package/docs/planning/IDEAS.md +14 -0
  40. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  41. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  42. package/docs/planning/chat-ui-vision.md +50 -0
  43. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  44. package/docs/planning/release-notes-v0.0.10.md +57 -0
  45. package/docs/planning/release-notes-v0.0.11.md +239 -0
  46. package/docs/reference/cli.md +35 -16
  47. package/docs/reference/distribution.md +13 -6
  48. package/docs/reference/embedding.md +35 -14
  49. package/docs/reference/features-matrix.md +97 -0
  50. package/docs/reference/library-api.md +30 -9
  51. package/docs/reference/memory.md +26 -6
  52. package/docs/reference/models.md +11 -1
  53. package/docs/reference/orpo-quickstart.md +3 -4
  54. package/docs/reference/server-api.md +634 -22
  55. package/docs/reference/server-config.md +251 -119
  56. package/docs/reference/training.md +20 -16
  57. package/package.json +5 -3
  58. package/src/anthropic.ts +13 -0
  59. package/src/audio/conformer.ts +647 -0
  60. package/src/audio/decode.ts +133 -0
  61. package/src/audio/features.ts +202 -0
  62. package/src/audio/transcode.ts +67 -0
  63. package/src/cli.ts +402 -107
  64. package/src/config.ts +49 -2
  65. package/src/download.ts +40 -0
  66. package/src/eval/README.md +7 -6
  67. package/src/eval/kl.ts +59 -3
  68. package/src/eval/quality-db.ts +2 -2
  69. package/src/eval/runner.ts +3 -3
  70. package/src/expert-offload.ts +15 -2
  71. package/src/flags.ts +13 -0
  72. package/src/generate.ts +330 -16
  73. package/src/grammar.ts +585 -0
  74. package/src/hub-rest.ts +242 -0
  75. package/src/kv-store.ts +618 -122
  76. package/src/lora.ts +8 -0
  77. package/src/md-modules.d.ts +42 -0
  78. package/src/media-fetch.ts +284 -0
  79. package/src/memory/rest.ts +245 -0
  80. package/src/memory/vault.ts +74 -0
  81. package/src/mlx/array.ts +127 -19
  82. package/src/mlx/autograd.ts +2 -2
  83. package/src/mlx/ffi.ts +27 -3
  84. package/src/mlx/metal-kernel.ts +7 -4
  85. package/src/mlx/ops.ts +179 -23
  86. package/src/mlx/turboquant-ops.ts +831 -0
  87. package/src/mlx/turboquant-tables.ts +237 -0
  88. package/src/model/batched-mask.ts +62 -1
  89. package/src/model/batched-quant.ts +206 -0
  90. package/src/model/batched-rotating-quant.ts +346 -0
  91. package/src/model/batched-rotating.ts +46 -1
  92. package/src/model/compiled-decode.ts +4 -15
  93. package/src/model/factory.ts +10 -4
  94. package/src/model/gemma4-base.ts +391 -34
  95. package/src/model/gemma4.ts +122 -33
  96. package/src/model/generated/gemma4-12b.ts +9 -13
  97. package/src/model/generated/gemma4-26b.ts +10 -5
  98. package/src/model/generated/gemma4-e4b.ts +14 -9
  99. package/src/model/minicpm5.ts +58 -102
  100. package/src/model/paged-kv.ts +253 -0
  101. package/src/model/qwen3-delta.ts +123 -15
  102. package/src/model/qwen3-moe.ts +404 -0
  103. package/src/model/qwen3.ts +9 -8
  104. package/src/model/qwen3_5.ts +99 -25
  105. package/src/model/support.ts +12 -1
  106. package/src/model/universal/dense.ts +40 -5
  107. package/src/model/universal/rope.ts +36 -21
  108. package/src/pi-web.ts +1299 -82
  109. package/src/preflight.ts +27 -8
  110. package/src/prompt-cache.ts +338 -32
  111. package/src/quantize/quantizer.ts +16 -1
  112. package/src/registry.ts +61 -3
  113. package/src/serve/batch-scheduler.ts +1001 -76
  114. package/src/serve/generation-gateway.ts +292 -30
  115. package/src/serve/isolate.ts +417 -0
  116. package/src/serve/lane-registry.ts +44 -0
  117. package/src/serve/session-search.ts +326 -0
  118. package/src/server.ts +1460 -105
  119. package/src/spec/assistant-source.ts +150 -0
  120. package/src/spec/deepspec-source.ts +143 -0
  121. package/src/spec/dflash-source.ts +124 -0
  122. package/src/spec/dspark/ab-stats.ts +136 -0
  123. package/src/spec/dspark/calibration.ts +112 -0
  124. package/src/spec/dspark/deepspec-module.ts +801 -0
  125. package/src/spec/dspark/generate-dflash.ts +72 -20
  126. package/src/spec/dspark/loader.ts +23 -0
  127. package/src/spec/dspark/module-dflash.ts +278 -36
  128. package/src/spec/dspark/quantize-drafter.ts +91 -0
  129. package/src/spec/generate.ts +59 -25
  130. package/src/spec/ngram-source.ts +132 -0
  131. package/src/spec/serve-loop.ts +536 -0
  132. package/src/spec/source.ts +93 -0
  133. package/src/spec/two-model.ts +171 -0
  134. package/src/ssd-cache.ts +285 -0
  135. package/src/tokenizer.ts +288 -2
  136. package/src/tool-approvals.ts +131 -0
  137. package/src/tool-call.ts +408 -20
  138. package/src/train/flash-cce.ts +29 -26
  139. package/src/train/job.ts +0 -2
  140. package/src/train/loss.ts +53 -11
  141. package/src/train/prefix-shared.ts +30 -7
  142. package/src/train/segmented.ts +150 -46
  143. package/src/train/trainer.ts +22 -15
  144. package/src/vision/prompt.ts +217 -48
  145. package/src/web/app.html +1184 -1789
  146. package/src/web/app.js +5667 -0
  147. package/src/web/icon.svg +13 -0
  148. package/src/web/manifest.webmanifest +24 -0
  149. package/src/web/src/adapters-panel.ts +288 -0
  150. package/src/web/src/api.ts +57 -0
  151. package/src/web/src/assistant.ts +485 -0
  152. package/src/web/src/chat.ts +1050 -0
  153. package/src/web/src/composer.ts +959 -0
  154. package/src/web/src/dataset.ts +132 -0
  155. package/src/web/src/finetune.ts +267 -0
  156. package/src/web/src/hub.ts +428 -0
  157. package/src/web/src/main.ts +98 -0
  158. package/src/web/src/markdown.ts +0 -0
  159. package/src/web/src/memory-panel.ts +572 -0
  160. package/src/web/src/model-picker.ts +159 -0
  161. package/src/web/src/palette.ts +346 -0
  162. package/src/web/src/protocol.ts +62 -0
  163. package/src/web/src/quantize.ts +167 -0
  164. package/src/web/src/rag.ts +274 -0
  165. package/src/web/src/sessions.ts +469 -0
  166. package/src/web/src/shell.ts +804 -0
  167. package/src/web/src/status.ts +252 -0
  168. package/src/web/src/ui-catalog.ts +92 -0
  169. package/src/web/sw.js +71 -0
  170. package/src/web/vendor/README.md +61 -0
  171. package/src/web/vendor/hljs-LICENSE +29 -0
  172. package/src/web/vendor/hljs-theme.css +59 -0
  173. package/src/web/vendor/hljs.js +26 -0
  174. package/src/model/fused-decode-kernel.ts +0 -312
  175. package/src/model/fused-geglu-kernel.ts +0 -198
  176. package/src/model/fused-mlp-kernel.ts +0 -169
  177. package/src/model/fused-swiglu-kernel.ts +0 -240
  178. package/src/model/steel-linear-kernel.ts +0 -96
  179. package/src/status-page.html +0 -497
package/src/train/loss.ts CHANGED
@@ -138,6 +138,36 @@ export function sftLoss(model: RuntimeModel, batch: SftBatch): MlxArray {
138
138
  return scalar;
139
139
  }
140
140
 
141
+ /** model.logitsFromHidden for TRAINING heads, with the upstream mlx
142
+ * quantized_matmul bug worked around: transpose=false returns WRONG values at
143
+ * row count 2 or 3 (reproduced on stock mlx 0.31.2; M=1 and M>=4 correct,
144
+ * transpose=true correct at every M). mlx's autograd computes dh for a
145
+ * transpose=true head qmm via an INTERNAL transpose=false call at the
146
+ * cotangent's row count — so any differentiated [M,V] logits with M in {2,3}
147
+ * (2-3-token responses, 2-3-token tail chunks, tiny prompt spans) got a
148
+ * silently wrong dh (measured relnorm ~0.35-0.99 vs the f32 ground truth).
149
+ * Fix: pad the span to 4 rows with zeros and slice the logits back — the pad
150
+ * rows' cotangents are exactly zero (slice adjoint), and the internal vjp
151
+ * then runs at M=4 where the kernel is correct. */
152
+ export function logitsFromHiddenPadM(
153
+ model: { logitsFromHidden(h: MlxArray): MlxArray },
154
+ hSpan: MlxArray, // [1, M, hidden]
155
+ ): MlxArray {
156
+ const M = hSpan.shape[1]!;
157
+ if (M !== 2 && M !== 3) return model.logitsFromHidden(hSpan);
158
+ const [B, , H] = hSpan.shape as [number, number, number];
159
+ const pad = ops.zeros([B, 4 - M, H], hSpan.dtype);
160
+ const hPad = ops.concatAxis([hSpan, pad], 1);
161
+ pad.dispose();
162
+ const lgPad = model.logitsFromHidden(hPad); // [1, 4, V]
163
+ hPad.dispose();
164
+ const start = MlxArray.fromInt32(new Int32Array([0]), [1]);
165
+ const lg = ops.sliceDynamic(lgPad, start, [1], [B, M, lgPad.shape[2]!]);
166
+ start.dispose();
167
+ lgPad.dispose();
168
+ return lg;
169
+ }
170
+
141
171
  /** B=1 masked CE that applies the LM head only at the supervised (response)
142
172
  * span. The supervised input positions are the contiguous range
143
173
  * [promptLen-1, len-1) (each predicts the next, response, token). The LM head
@@ -158,7 +188,7 @@ export function responseOnlyCe(model: RuntimeModel, h: MlxArray, batch: SftBatch
158
188
  const hidden = h.shape[2]!;
159
189
  const start = MlxArray.fromInt32(new Int32Array([startT]), [1]);
160
190
  const hResp = ops.sliceDynamic(h, start, [1], [1, M, hidden]);
161
- const logits = model.logitsFromHidden(hResp); // [1, M, V]
191
+ const logits = logitsFromHiddenPadM(model, hResp); // [1, M, V] (M in {2,3} padded — mlx qmm bug)
162
192
  const V = logits.shape[2]!;
163
193
  const logits2d = ops.reshape(logits, [M, V]);
164
194
 
@@ -198,7 +228,7 @@ export function responseOnlyLogpMean(
198
228
  const hidden = h.shape[2]!;
199
229
  const start = MlxArray.fromInt32(new Int32Array([startT]), [1]);
200
230
  const hResp = ops.sliceDynamic(h, start, [1], [1, M, hidden]); // [1, M, hidden]
201
- const logits = model.logitsFromHidden(hResp); // [1, M, V]
231
+ const logits = logitsFromHiddenPadM(model, hResp); // [1, M, V] (M in {2,3} padded — mlx qmm bug)
202
232
  const V = logits.shape[2]!;
203
233
  const logits2d = ops.reshape(logits, [M, V]);
204
234
 
@@ -504,7 +534,7 @@ export function spanLogpMeanFromHidden(
504
534
  const hidden = h.shape[2]!;
505
535
  const start = MlxArray.fromInt32(new Int32Array([s0]), [1]);
506
536
  const hSpan = ops.sliceDynamic(h, start, [1], [1, n, hidden]); // [1, n, hidden]
507
- const logits = model.logitsFromHidden(hSpan); // [1, n, V]
537
+ const logits = logitsFromHiddenPadM(model, hSpan); // [1, n, V] (n in {2,3} padded — mlx qmm bug)
508
538
  const V = logits.shape[2]!;
509
539
  const logits2d = ops.reshape(logits, [n, V]);
510
540
 
@@ -624,7 +654,7 @@ export function branchLogpMeanB1(
624
654
  const hidden = h.shape[2]!;
625
655
  const start = MlxArray.fromInt32(new Int32Array([startT]), [1]);
626
656
  const hResp = ops.sliceDynamic(h, start, [1], [1, M, hidden]); // [1, M, hidden]
627
- const logits = model.logitsFromHidden(hResp); // [1, M, V] — head only on the response span
657
+ const logits = logitsFromHiddenPadM(model, hResp); // [1, M, V] — head only on the response span (M in {2,3} padded — mlx qmm bug)
628
658
  const V = logits.shape[2]!;
629
659
  const logits2d = ops.reshape(logits, [M, V]);
630
660
 
@@ -698,7 +728,7 @@ function chunkedLogpMeanB1(
698
728
 
699
729
  const ckpt = new Checkpoint((inp) => {
700
730
  const hc = inp[0]!;
701
- const logits = model.logitsFromHidden(hc); // [1, Cc, V] — recomputed in backward
731
+ const logits = logitsFromHiddenPadM(model, hc); // [1, Cc, V] — recomputed in backward (Cc in {2,3} padded — mlx qmm bug)
702
732
  const V = logits.shape[2]!;
703
733
  const l2 = ops.reshape(logits, [Cc, V]);
704
734
  const targets = MlxArray.fromInt32(tHost, [Cc, 1]);
@@ -765,7 +795,7 @@ export function chunkedLogpMeanFromHidden(
765
795
  for (let i = 0; i < Cc; i++) tHost[i] = ids[c0 + 1 + i]!;
766
796
  const ckpt = new Checkpoint((inp) => {
767
797
  const hc = inp[0]!;
768
- const logits = model.logitsFromHidden(hc); // [1, Cc, V] — recomputed in backward
798
+ const logits = logitsFromHiddenPadM(model, hc); // [1, Cc, V] — recomputed in backward (Cc in {2,3} padded — mlx qmm bug)
769
799
  const V = logits.shape[2]!;
770
800
  const l2 = ops.reshape(logits, [Cc, V]);
771
801
  const targets = MlxArray.fromInt32(tHost, [Cc, 1]);
@@ -1093,8 +1123,9 @@ function makeVocabBlockedHeadVjp(
1093
1123
  /** flash-CCE head as a CustomVjp: forward runs the Metal kernel → Σ logp (stashing
1094
1124
  * `lse` for the backward, avoiding a forward recompute); backward runs the kernel's
1095
1125
  * `dh` for unit cotangent then scales by the scalar cotangent `c` (loss = Σ logp →
1096
- * ∂loss/∂logp_t = c for all t). The coeff filter + vocab-block skip are OFF by
1097
- * default (env-gated MLX_BUN_CCE_BWD_*_EPS) — the backward computes the EXACT dh. */
1126
+ * ∂loss/∂logp_t = c for all t). The coeff filter + vocab-block skip default ON at
1127
+ * 1e-5 (2026-07-02 flip; real-data gates in flash-cce.ts) — set
1128
+ * MLX_BUN_CCE_BWD_{FILTER,BLOCK}_EPS=0 for the EXACT dh. */
1098
1129
  function makeFlashCceHeadVjp(
1099
1130
  head: HeadQuant, targetsHost: Int32Array, M: number,
1100
1131
  sink: Array<{ dispose(): void }>,
@@ -1125,8 +1156,8 @@ function makeFlashCceHeadVjp(
1125
1156
  const c = cots[0]!; // [1] = ∂loss/∂(Σ logp); uniform over tokens
1126
1157
  // dh for unit cotangent, then × c (avoids a host read of the cotangent).
1127
1158
  const ones = new Array(M).fill(1) as number[];
1128
- // filter + block-skip are OFF by default (env-gated) → EXACT dh. savedBlockMax is
1129
- // passed so the lossless vocab-block skip can be enabled via env without re-plumbing.
1159
+ // filter + block-skip default ON at 1e-5 (env-overridable; =0 → exact dh).
1160
+ // savedBlockMax feeds the ~lossless vocab-block skip.
1130
1161
  const dhUnit = flashCceBackward(hR, fch, targets, savedLse!, ones, undefined, savedBlockMax!); // [M, hidden] f32
1131
1162
  const cM = c.dtype === dhUnit.dtype ? c : c.astype(dhUnit.dtype);
1132
1163
  const dh = ops.mul(dhUnit, cM); // broadcast [M,hidden]·[1]
@@ -1157,8 +1188,19 @@ export function fusedRespLogpMean(
1157
1188
  // Vocab-blocked (full CCE) path: even [chunk,V] never materializes — only
1158
1189
  // [chunk,vocabBlock]. Otherwise the whole-vocab analytic head below.
1159
1190
  const vb = vocabBlock > 0 && vocabBlock < V ? vocabBlock : 0;
1191
+ // Auto-dispatch by M (kernel backlog #3; measured 2026-07-02 on the M1 Max,
1192
+ // scripts/experiments/head-dispatch-sweep.ts): the fused QM head is FASTER
1193
+ // at short M (e4b M=64 1.6×, M=512 1.13×; CPM5 faster at EVERY M) — the
1194
+ // flash head's win is residency, plus time at long M on e4b (M≥2048 after
1195
+ // the 2026-07-02 filter/blockMax flip; 8K: 10.7 vs 13.2 s). So a
1196
+ // requested-flash batch with M below MLX_BUN_FLASH_MIN_M (default 1024, the
1197
+ // e4b time-tie point where flash's memory win takes over) takes the EXACT
1198
+ // fused head instead — short rows get the faster exact head for free.
1199
+ // MLX_BUN_FLASH_MIN_M=0 always honors flash (the pre-dispatch behavior).
1200
+ const flashMinM = Number(process.env.MLX_BUN_FLASH_MIN_M ?? "1024");
1201
+ const useFlash = flash && (flashMinM <= 0 || M >= flashMinM);
1160
1202
  let op: CustomVjp;
1161
- if (flash) {
1203
+ if (useFlash) {
1162
1204
  op = makeFlashCceHeadVjp(head, targetsHost, M, sink);
1163
1205
  } else if (vb > 0) {
1164
1206
  op = makeVocabBlockedHeadVjp(head, targetsHost, M, hidden, chunk, vb);
@@ -28,7 +28,7 @@ import * as ops from "../mlx/ops";
28
28
  import { createCausalMask, type Cache, type Mask } from "../model/gemma4-base";
29
29
  import { MiniCPM5Model, setMiniCpmPrefixPlan } from "../model/minicpm5";
30
30
  import { Gemma4Model, setGemmaPrefixPlan } from "../model/gemma4";
31
- import { orpoLossFromLogps, fusedRespLogpMean, combineFullNll, type ChunkCtx, type SftScope } from "./loss";
31
+ import { orpoLossFromLogps, fusedRespLogpMean, combineFullNll, logitsFromHiddenPadM, type ChunkCtx, type SftScope } from "./loss";
32
32
  import type { DpoBatch } from "./dataset";
33
33
 
34
34
  /** A model that can project final-norm hidden states to vocab logits — the only
@@ -63,17 +63,40 @@ export function blockSparseMask(P: number, Rc: number, Rr: number): MlxArray {
63
63
  * MiniCPM5's runLayerRange calls makeMask once and disposes the returned arr. */
64
64
  export class PrefixSharedCache implements Cache {
65
65
  offset = 0;
66
- constructor(private readonly P: number, private readonly Rc: number, private readonly Rr: number) {}
66
+ constructor(
67
+ private readonly P: number, private readonly Rc: number, private readonly Rr: number,
68
+ // Step-shared mask memo (kernel-review backlog #9): the [T,T] block-sparse
69
+ // mask is IDENTICAL for every layer, segment forward, and backward
70
+ // recompute of a step, but each runLayerRange call rebuilt it from its
71
+ // elementwise ops (2·nSeg+ rebuilds per step in the segmented prefix
72
+ // class). With a memo it is built once; makeMask hands out cheap slice
73
+ // VIEWS (consumers dispose their view — runLayerRange does), and the
74
+ // first cache disposed frees the memo. Use prefixSharedCaches() to build
75
+ // a memo-sharing batch.
76
+ private readonly memo?: { mask: MlxArray | null },
77
+ ) {}
67
78
  updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
68
79
  return [k.slice([0, 0, 0, 0], k.shape), v.slice([0, 0, 0, 0], v.shape)];
69
80
  }
70
81
  makeMask(_N: number, _windowSize: number | null): Mask {
71
- return { mode: "array", arr: blockSparseMask(this.P, this.Rc, this.Rr) };
82
+ if (!this.memo) return { mode: "array", arr: blockSparseMask(this.P, this.Rc, this.Rr) };
83
+ if (!this.memo.mask) this.memo.mask = blockSparseMask(this.P, this.Rc, this.Rr);
84
+ const m = this.memo.mask;
85
+ return { mode: "array", arr: m.slice([0, 0], m.shape) };
72
86
  }
73
87
  state(): MlxArray[] { return []; }
74
88
  isTrimmable(): boolean { return true; }
75
89
  trim(_n: number): void { /* offset pinned at 0 */ }
76
- dispose(): void { /* owns no arrays */ }
90
+ dispose(): void {
91
+ if (this.memo?.mask) { this.memo.mask.dispose(); this.memo.mask = null; }
92
+ }
93
+ }
94
+
95
+ /** One PrefixSharedCache per layer, all sharing a single block-sparse-mask
96
+ * memo for the step (see the constructor note). */
97
+ export function prefixSharedCaches(nLayers: number, P: number, Rc: number, Rr: number): Cache[] {
98
+ const memo: { mask: MlxArray | null } = { mask: null };
99
+ return Array.from({ length: nLayers }, () => new PrefixSharedCache(P, Rc, Rr, memo));
77
100
  }
78
101
 
79
102
  /** Length-normalized mean log-prob over the predictions at hidden positions
@@ -87,7 +110,7 @@ function gatheredLogpMean(model: LogitProjector, h: MlxArray, gatherIdx: number[
87
110
  const hSel = ops.takeAxis(h, idxArr, 1); // [1, M, hidden]
88
111
  idxArr.dispose();
89
112
  if (hSel.shape[2] !== hidden) throw new Error("gatheredLogpMean: hidden mismatch");
90
- const logits = model.logitsFromHidden(hSel); // [1, M, V]
113
+ const logits = logitsFromHiddenPadM(model, hSel); // [1, M, V] (M in {2,3} padded — mlx qmm bug)
91
114
  hSel.dispose();
92
115
  const V = logits.shape[2]!;
93
116
  const logits2d = ops.reshape(logits, [M, V]);
@@ -185,7 +208,7 @@ export function orpoLossPrefixShared(
185
208
  concat.set(chosenResp, P);
186
209
  concat.set(rejectedResp, P + Rc);
187
210
  const ids = MlxArray.fromInt32(concat, [1, T]);
188
- const caches: Cache[] = model.layers.map(() => new PrefixSharedCache(P, Rc, Rr));
211
+ const caches: Cache[] = prefixSharedCaches(model.layers.length, P, Rc, Rr);
189
212
 
190
213
  let h: MlxArray;
191
214
  setMiniCpmPrefixPlan({ P, Rc, Rr });
@@ -230,7 +253,7 @@ export function prefixSharedLogps(
230
253
  const concat = new Int32Array(T);
231
254
  concat.set(promptIds, 0); concat.set(chosenResp, P); concat.set(rejectedResp, P + Rc);
232
255
  const ids = MlxArray.fromInt32(concat, [1, T]);
233
- const caches: Cache[] = model.layers.map(() => new PrefixSharedCache(P, Rc, Rr));
256
+ const caches: Cache[] = prefixSharedCaches(model.layers.length, P, Rc, Rr);
234
257
  setMiniCpmPrefixPlan({ P, Rc, Rr });
235
258
  let h: MlxArray;
236
259
  try { h = model.forwardHidden(ids, caches); }
@@ -36,18 +36,31 @@
36
36
 
37
37
  import { MlxArray } from "../mlx/array";
38
38
  import * as ops from "../mlx/ops";
39
+ import { peakMemory } from "../mlx/ffi";
39
40
  import { Vjp } from "../mlx/autograd";
41
+
42
+ // MLX_BUN_SEG_MEM_LOG=1: log the within-step peak after each phase of the
43
+ // segmented step (forward+boundaries / head vjp / each segment backward).
44
+ // The trainer resets the peak at step start (MLX_BUN_MEM_LOG=1), so the phase
45
+ // where the monotone peak JUMPS is where the step's memory actually lives.
46
+ // (This is how the backlog-#2 refutation below was measured. An inter-segment
47
+ // clearCache was also tried and had NO effect — the watermark is live memory,
48
+ // not allocator cache retention.)
49
+ const SEG_MEM = process.env.MLX_BUN_SEG_MEM_LOG === "1";
50
+ const segMem = (label: string): void => {
51
+ if (SEG_MEM) console.log(` [seg-mem] ${label}: peak=${(peakMemory() / 1e9).toFixed(2)} GB`);
52
+ };
40
53
  import { MiniCPM5Model, setMiniCpmPrefixPlan } from "../model/minicpm5";
41
54
  import type { Gemma4Model } from "../model/gemma4";
42
55
  import { setGemmaPrefixPlan } from "../model/gemma4";
43
56
  import type { Cache, Mask, SharedKv } from "../model/gemma4-base";
44
57
  import { TrainingCache } from "./forward";
45
58
  import {
46
- responseOnlyCe, responseOnlyLogpMean, fusedLogpMeanFromHidden, chunkedLogpMeanFromHidden,
59
+ responseOnlyLogpMean, fusedLogpMeanFromHidden, chunkedLogpMeanFromHidden,
47
60
  orpoLossFromLogps, spanLogpMeanFromHidden, respSpanFromMask, combineFullNll,
48
61
  type ChunkCtx, type SftScope, type HeadSpan,
49
62
  } from "./loss";
50
- import { PrefixSharedCache, prefixGatherIdx, branchLogpMeanGathered, blockSparsePrefixMaskGemma, prefixFullNll } from "./prefix-shared";
63
+ import { prefixSharedCaches, prefixGatherIdx, branchLogpMeanGathered, blockSparsePrefixMaskGemma, prefixFullNll } from "./prefix-shared";
51
64
 
52
65
  // Experiment toggle: which bounded head to use inside the segmented mlx_vjp.
53
66
  // "checkpoint" = per-chunk Checkpoint recompute (bounds memory under nesting);
@@ -65,7 +78,36 @@ function boundedHeadFromHidden(
65
78
  : chunkedLogpMeanFromHidden(model, h, ids, mask, chunkSize, sink, span);
66
79
  }
67
80
  import type { TrainableLora } from "./lora-params";
68
- import { type SftBatch, type DpoBatch } from "./dataset";
81
+ import { rowLength, type SftBatch, type DpoBatch } from "./dataset";
82
+
83
+ // Token-chunk size for the bounded SFT head (the ORPO paths take theirs from
84
+ // cfg.orpoChunkSize; SFT has no per-run knob, 512 matches the ORPO default).
85
+ const SFT_HEAD_CHUNK = Number(process.env.MLX_BUN_SEG_HEAD_CHUNK ?? "512");
86
+
87
+ /** [M,V]-bounded response-only CE from post-finalNorm hidden — the SFT
88
+ * segmented head (kernel-review backlog #8). Same span arithmetic and
89
+ * token-mean reduction as loss.ts responseOnlyCe, but the logits only ever
90
+ * exist per token-chunk ([chunk,V] Checkpoint recompute, or the fused
91
+ * CustomVjp head via MLX_BUN_SEG_HEAD) instead of the full [1,M,V] + its
92
+ * cotangent inside the head vjp. CE = −(mean response logp); per-position
93
+ * grads are bit-exact vs the whole-vocab head (chunking is over TOKEN
94
+ * positions; each position's full-V lse is computed unchanged — only the
95
+ * scalar loss VALUE can differ in fp association across chunks). */
96
+ export function boundedSftCe(
97
+ model: MiniCPM5Model | Gemma4Model, hn: MlxArray, batch: SftBatch,
98
+ sink: Array<{ dispose(): void }>, chunkSize = SFT_HEAD_CHUNK,
99
+ ): MlxArray {
100
+ const L = batch.ids[0]!.length;
101
+ const startT = Math.max(0, batch.promptLens[0]! - 1); // first supervised input position
102
+ const M = Math.min(rowLength(batch, 0), L) - 1 - startT;
103
+ if (M <= 0) throw new Error("sftLoss: no response tokens to supervise");
104
+ const mean = boundedHeadFromHidden(model, hn, batch.ids[0]!, [], chunkSize, sink, { startT, M });
105
+ const neg = ops.mulScalar(mean, -1); // CE = −(mean logp); exact sign flip
106
+ mean.dispose();
107
+ const loss = ops.sumAxis(neg, 0, false); // [1] -> scalar, matches the vjp cotangent `one`
108
+ neg.dispose();
109
+ return loss;
110
+ }
69
111
 
70
112
  /** Split `nLayers` into contiguous `[lo, hi)` ranges of at most `segSize`
71
113
  * layers each (the last range may be shorter). `segSize` is the memory knob:
@@ -77,24 +119,50 @@ export function planSegmentsBySize(nLayers: number, segSize: number): [number, n
77
119
  return ranges;
78
120
  }
79
121
 
122
+ // NOTE (2026-07-02, kernel-review backlog #2 REFUTED BY MEASUREMENT): a
123
+ // "full-attention isolation" planner (singleton segments for e4b's 7 full
124
+ // layers) was built and A/B'd @8K — ZERO peak win (18.09 vs 18.02 GB). The
125
+ // design §5 premise is false: mlx's sdpa BACKWARD materializes the O(L²)
126
+ // scores for EVERY layer (~3.5 GB/layer @8K on e4b — sliding's window is just
127
+ // an additive mask; measured: sliding pair +7.1 GB ≈ sliding+full +7.15). The
128
+ // worst segment is set by LAYER COUNT alone, so segSize IS the whole knob:
129
+ // segment_size 1 measures 14.59 GB @8K (+3% step time) vs 17.5 @seg2. Below
130
+ // that needs the [M,V]-bounded SFT head (backlog #8, ~3 GB head-vjp spike)
131
+ // and/or an O(L)-memory attention backward. Evidence:
132
+ // scripts/experiments/seg-isolation-smoke.ts + MLX_BUN_SEG_MEM_LOG=1 probes.
133
+
80
134
  /** Detach `a` into a graph-free LEAF holding its exact (evaluated) bytes, then
81
135
  * dispose `a`. mlx retains the upstream graph after `eval`, so a segment
82
136
  * boundary (or a value_and_grad output) would otherwise keep its whole layer
83
137
  * stack's activations alive. Copying the bytes into a fresh leaf frees that
84
138
  * graph. The leaf is a valid differentiable input for the per-segment vag. */
85
139
  function detachLeaf(a: MlxArray): MlxArray {
86
- // Force a row-major copy first: `rawBytes` reads the underlying buffer
87
- // linearly, so a NON-contiguous input (e.g. a transposed donor K/V view) would
88
- // be byte-scrambled. `ops.contiguous` is a no-op for already-contiguous arrays
140
+ // Force a row-major copy first: the buffer is read linearly, so a
141
+ // NON-contiguous input (e.g. a transposed donor K/V view) would be
142
+ // byte-scrambled. `ops.contiguous` is a no-op for already-contiguous arrays
89
143
  // (hidden boundaries) and materializes row-major for strided views.
144
+ // detachCopy = ONE mlx→mlx copy (backlog #9: the old
145
+ // fromBytesCopy(rawBytes()) route was two copies via the JS heap).
90
146
  const c = ops.contiguous(a);
91
- c.eval();
92
- const leaf = MlxArray.fromBytesCopy(c.rawBytes(), c.shape, c.dtype);
147
+ const leaf = c.detachCopy();
93
148
  c.dispose();
94
149
  a.dispose();
95
150
  return leaf;
96
151
  }
97
152
 
153
+ /** Batch detach: ONE evalAll barrier for the whole set (backlog #9 — each
154
+ * per-array eval was a separate blocking GPU drain; a segment's vjp returns
155
+ * dh + 2·consumed-donors + 2·targets arrays, so the per-segment sync count
156
+ * collapses from ~2+2n to 1), then one mlx→mlx copy each. Disposes inputs. */
157
+ function detachLeaves(arrs: MlxArray[]): MlxArray[] {
158
+ const cs = arrs.map((a) => ops.contiguous(a));
159
+ ops.evalAll(cs);
160
+ const leaves = cs.map((c) => c.detachCopy());
161
+ for (const c of cs) c.dispose();
162
+ for (const a of arrs) a.dispose();
163
+ return leaves;
164
+ }
165
+
98
166
  /** Layer index a target's modulePath belongs to, or -1 (e.g. embedding/head). */
99
167
  function targetLayer(modulePath: string): number {
100
168
  const m = modulePath.match(/\.layers\.(\d+)\./);
@@ -120,6 +188,10 @@ export class SegmentedBackward {
120
188
  private readonly one: MlxArray; // scalar cotangent 1.0 for the (scalar) head loss
121
189
  // Per-step context the (reused) closures read.
122
190
  private curBatch: SftBatch | null = null;
191
+ // Bounded-head per-step transients (chunk slices + Checkpoint/CustomVjp
192
+ // objects): the head backward recomputes through them, so they are disposed
193
+ // only AFTER the head vjp's outputs are detached (which forces the eval).
194
+ private headSink: Array<{ dispose(): void }> = [];
123
195
  private disposed = false;
124
196
 
125
197
  constructor(
@@ -140,11 +212,17 @@ export class SegmentedBackward {
140
212
  this.caches = Array.from({ length: this.nLayers }, () => new TrainingCache());
141
213
  this.one = MlxArray.fromFloat32(new Float32Array([1]), []); // d(loss)/d(loss) = 1
142
214
 
143
- // Loss head: vjp of finalNorm + responseOnlyCe over the last boundary leaf.
215
+ // Loss head: vjp of finalNorm + the [M,V]-BOUNDED response CE over the last
216
+ // boundary leaf (kernel-review backlog #8: the old responseOnlyCe here
217
+ // materialized [1,M,V] logits + cotangents inside the head vjp — measured
218
+ // ~3 GB @8K on e4b, the exact transient the segmentation exists to kill).
219
+ // CE = −(mean response logp); span and reduction identical to
220
+ // responseOnlyCe, per-position grads bit-exact (chunking is over token
221
+ // positions — each position's full-V lse/softmax is computed unchanged).
144
222
  // outputs[0] = loss scalar; vjps[0] = dLoss/d(last residual stream) = dh.
145
223
  this.headVjp = new Vjp((p) => {
146
224
  const hn = model.finalNorm.forward(p[0]!);
147
- const loss = responseOnlyCe(model, hn, this.curBatch!);
225
+ const loss = boundedSftCe(model, hn, this.curBatch!, this.headSink);
148
226
  hn.dispose();
149
227
  return [loss];
150
228
  }, 1);
@@ -204,9 +282,17 @@ export class SegmentedBackward {
204
282
  }
205
283
 
206
284
  // --- 2. Loss head over the last boundary (vjp with cotangent 1.0). ---
285
+ // The bounded head pushes per-step chunk slices + Checkpoints into
286
+ // headSink; the head BACKWARD recomputes through them, so materialize
287
+ // the full head-vjp graph BEFORE freeing the sink (lazy-eval UAF
288
+ // discipline, same as the ORPO classes).
289
+ this.headSink = [];
207
290
  const head = this.headVjp.apply([boundaries[nSeg]!], [this.one]);
291
+ ops.evalAll([head.outputs[0]!, head.vjps[0]!]);
208
292
  value = detachLeaf(head.outputs[0]!); // scalar loss, detached from the head graph (frees logits)
209
293
  dhOut = detachLeaf(head.vjps[0]!); // dLoss / d(last residual stream), graph-free
294
+ for (const d of this.headSink) d.dispose();
295
+ this.headSink = [];
210
296
  boundaries[nSeg]!.dispose();
211
297
  boundaries[nSeg] = null;
212
298
 
@@ -220,13 +306,15 @@ export class SegmentedBackward {
220
306
  res.outputs[0]!.dispose(); // segment output — unused (only its vjp matters)
221
307
  // vjps[0] = dh_in (grad w.r.t. this segment's input boundary) -> dh_out
222
308
  // for segment k-1; the rest scatter into the global flat vector. Detach
223
- // each to a graph-free leaf (eval doesn't detach; keeps peak bounded).
309
+ // to graph-free leaves (eval doesn't detach; keeps peak bounded) — ONE
310
+ // barrier for the segment's whole vjp set.
311
+ const leaves = detachLeaves(res.vjps);
224
312
  idxs.forEach((j, s) => {
225
- grads[j] = detachLeaf(res.vjps[1 + 2 * s]!); // dA
226
- grads[n + j] = detachLeaf(res.vjps[1 + 2 * s + 1]!); // dB
313
+ grads[j] = leaves[1 + 2 * s]!; // dA
314
+ grads[n + j] = leaves[1 + 2 * s + 1]!; // dB
227
315
  });
228
316
  dhOut!.dispose(); // consumed as this segment's cotangent
229
- dhOut = detachLeaf(res.vjps[0]!);
317
+ dhOut = leaves[0]!;
230
318
  boundaries[k]!.dispose();
231
319
  boundaries[k] = null;
232
320
  }
@@ -238,6 +326,8 @@ export class SegmentedBackward {
238
326
  } finally {
239
327
  inputIds.dispose();
240
328
  this.curBatch = null;
329
+ for (const d of this.headSink) d.dispose(); // no-op unless the head threw mid-step
330
+ this.headSink = [];
241
331
  dhOut?.dispose();
242
332
  for (const b of boundaries) b?.dispose();
243
333
  if (!transferred) {
@@ -295,6 +385,8 @@ export class SegmentedBackwardGemma4 {
295
385
  private curBatch: SftBatch | null = null;
296
386
  private curMasks: Map<string, Mask> | null = null;
297
387
  private curPerLayer: MlxArray | null = null;
388
+ // Bounded-head per-step transients — see SegmentedBackward.headSink.
389
+ private headSink: Array<{ dispose(): void }> = [];
298
390
  private disposed = false;
299
391
 
300
392
  constructor(
@@ -337,11 +429,11 @@ export class SegmentedBackwardGemma4 {
337
429
  this.caches = Array.from({ length: model.numDonors }, () => new TrainingCache());
338
430
  this.one = MlxArray.fromFloat32(new Float32Array([1]), []);
339
431
 
340
- // Loss head (identical to MiniCPM5): finalNorm + responseOnlyCe over the last
432
+ // Loss head (identical to MiniCPM5): finalNorm + bounded response CE over the last
341
433
  // boundary, vjp with cotangent 1.0 -> [loss], [dh].
342
434
  this.headVjp = new Vjp((p) => {
343
435
  const hn = model.finalNorm.forward(p[0]!);
344
- const loss = responseOnlyCe(model, hn, this.curBatch!);
436
+ const loss = boundedSftCe(model, hn, this.curBatch!, this.headSink);
345
437
  hn.dispose();
346
438
  return [loss];
347
439
  }, 1);
@@ -437,14 +529,22 @@ export class SegmentedBackwardGemma4 {
437
529
  for (const [d, kv] of donorKvOut)
438
530
  if (!produces[k]!.includes(d) && kv.kind === "plain") { kv.keys.dispose(); kv.values.dispose(); }
439
531
  boundaries.push(detachLeaf(h));
532
+ segMem(`fwd seg ${k} [${lo},${hi})`);
440
533
  }
441
534
 
442
- // --- 2. Loss head (vjp, cotangent 1.0). ---
535
+ // --- 2. Loss head (vjp, cotangent 1.0). Bounded-head sink discipline:
536
+ // materialize the head-vjp graph BEFORE freeing the sink (see the
537
+ // MiniCPM5 class / ORPO classes — lazy-eval UAF). ---
538
+ this.headSink = [];
443
539
  const head = this.headVjp.apply([boundaries[nSeg]!], [this.one]);
540
+ ops.evalAll([head.outputs[0]!, head.vjps[0]!]);
444
541
  value = detachLeaf(head.outputs[0]!);
445
542
  dhOut = detachLeaf(head.vjps[0]!);
543
+ for (const d of this.headSink) d.dispose();
544
+ this.headSink = [];
446
545
  boundaries[nSeg]!.dispose();
447
546
  boundaries[nSeg] = null;
547
+ segMem("head vjp");
448
548
 
449
549
  // --- 3. Backward, reverse over segments (vjp, cotangents dh + dKV). ---
450
550
  for (let k = nSeg - 1; k >= 0; k--) {
@@ -466,17 +566,17 @@ export class SegmentedBackwardGemma4 {
466
566
  const res = this.segVjps[k]!.apply(primals, cots);
467
567
  for (const o of res.outputs) o.dispose();
468
568
 
469
- // vjps[0] = dh_in; then (dk,dv) per consumed donor; then (da,db) per LoRA.
470
- const newDh = detachLeaf(res.vjps[0]!);
569
+ // vjps[0] = dh_in; then (dk,dv) per consumed donor; then (da,db) per
570
+ // LoRA — detached in ONE barrier (backlog #9).
571
+ const leaves = detachLeaves(res.vjps);
572
+ const newDh = leaves[0]!;
471
573
  cons.forEach((d, ci) => {
472
- const dk = detachLeaf(res.vjps[1 + 2 * ci]!);
473
- const dv = detachLeaf(res.vjps[1 + 2 * ci + 1]!);
474
- this.accumulateDKV(dKV, d, dk, dv);
574
+ this.accumulateDKV(dKV, d, leaves[1 + 2 * ci]!, leaves[1 + 2 * ci + 1]!);
475
575
  });
476
576
  const base = 1 + 2 * cons.length;
477
577
  idxs.forEach((j, s) => {
478
- grads[j] = detachLeaf(res.vjps[base + 2 * s]!);
479
- grads[n + j] = detachLeaf(res.vjps[base + 2 * s + 1]!);
578
+ grads[j] = leaves[base + 2 * s]!;
579
+ grads[n + j] = leaves[base + 2 * s + 1]!;
480
580
  });
481
581
 
482
582
  dh.dispose();
@@ -488,6 +588,7 @@ export class SegmentedBackwardGemma4 {
488
588
  }
489
589
  boundaries[k]!.dispose();
490
590
  boundaries[k] = null;
591
+ segMem(`bwd seg ${k} [${ranges[k]![0]},${ranges[k]![1]})`);
491
592
  }
492
593
 
493
594
  transferred = true;
@@ -498,6 +599,8 @@ export class SegmentedBackwardGemma4 {
498
599
  this.curPerLayer?.dispose();
499
600
  this.curPerLayer = null;
500
601
  if (this.curMasks) { for (const m of this.curMasks.values()) m.arr?.dispose(); this.curMasks = null; }
602
+ for (const d of this.headSink) d.dispose(); // no-op unless the head threw mid-step
603
+ this.headSink = [];
501
604
  dhOut?.dispose();
502
605
  for (const b of boundaries) b?.dispose();
503
606
  for (const kv of donorBnd.values()) { kv.keys.dispose(); kv.values.dispose(); }
@@ -725,12 +828,13 @@ export class SegmentedBackwardOrpo {
725
828
  for (const j of idxs) params.push(lora.targets[j]!.lw.a, lora.targets[j]!.lw.b);
726
829
  const res = this.segVjps[k]!.apply(params, [dhC!]);
727
830
  res.outputs[0]!.dispose();
831
+ const leaves = detachLeaves(res.vjps); // one barrier (backlog #9)
728
832
  idxs.forEach((j, s) => {
729
- gradsC[j] = detachLeaf(res.vjps[1 + 2 * s]!); // dA
730
- gradsC[n + j] = detachLeaf(res.vjps[1 + 2 * s + 1]!); // dB
833
+ gradsC[j] = leaves[1 + 2 * s]!; // dA
834
+ gradsC[n + j] = leaves[1 + 2 * s + 1]!; // dB
731
835
  });
732
836
  dhC!.dispose();
733
- dhC = detachLeaf(res.vjps[0]!);
837
+ dhC = leaves[0]!;
734
838
  bndC[k]!.dispose(); bndC[k] = null;
735
839
  }
736
840
  dhC!.dispose(); dhC = null; // embedding grad, discarded (no LoRA on embed)
@@ -742,12 +846,13 @@ export class SegmentedBackwardOrpo {
742
846
  for (const j of idxs) params.push(lora.targets[j]!.lw.a, lora.targets[j]!.lw.b);
743
847
  const res = this.segVjps[k]!.apply(params, [dhR!]);
744
848
  res.outputs[0]!.dispose();
849
+ const leaves = detachLeaves(res.vjps); // one barrier (backlog #9)
745
850
  idxs.forEach((j, s) => {
746
- gradsR[j] = detachLeaf(res.vjps[1 + 2 * s]!); // dA
747
- gradsR[n + j] = detachLeaf(res.vjps[1 + 2 * s + 1]!); // dB
851
+ gradsR[j] = leaves[1 + 2 * s]!; // dA
852
+ gradsR[n + j] = leaves[1 + 2 * s + 1]!; // dB
748
853
  });
749
854
  dhR!.dispose();
750
- dhR = detachLeaf(res.vjps[0]!);
855
+ dhR = leaves[0]!;
751
856
  bndR[k]!.dispose(); bndR[k] = null;
752
857
  }
753
858
  dhR!.dispose(); dhR = null; // embedding grad, discarded
@@ -912,7 +1017,7 @@ export class SegmentedBackwardOrpoPrefix {
912
1017
  this.headSink = [];
913
1018
  this.curChunk = this.chunkCtx ? { ...this.chunkCtx, sink: this.headSink } : undefined;
914
1019
  // One prefix cache per layer (captures P/Rc/Rr; makeMask -> block-sparse mask).
915
- this.curCaches = model.layers.map(() => new PrefixSharedCache(P, Rc, Rr));
1020
+ this.curCaches = prefixSharedCaches(model.layers.length, P, Rc, Rr);
916
1021
 
917
1022
  const nSeg = ranges.length;
918
1023
  const concat = new Int32Array(T);
@@ -957,12 +1062,13 @@ export class SegmentedBackwardOrpoPrefix {
957
1062
  for (const j of idxs) params.push(lora.targets[j]!.lw.a, lora.targets[j]!.lw.b);
958
1063
  const res = this.segVjps[k]!.apply(params, [dhOut!]);
959
1064
  res.outputs[0]!.dispose();
1065
+ const leaves = detachLeaves(res.vjps); // one barrier (backlog #9)
960
1066
  idxs.forEach((j, s) => {
961
- grads[j] = detachLeaf(res.vjps[1 + 2 * s]!); // dA
962
- grads[n + j] = detachLeaf(res.vjps[1 + 2 * s + 1]!); // dB
1067
+ grads[j] = leaves[1 + 2 * s]!; // dA
1068
+ grads[n + j] = leaves[1 + 2 * s + 1]!; // dB
963
1069
  });
964
1070
  dhOut!.dispose();
965
- dhOut = detachLeaf(res.vjps[0]!);
1071
+ dhOut = leaves[0]!;
966
1072
  boundaries[k]!.dispose(); boundaries[k] = null;
967
1073
  }
968
1074
  // Segment-0 dh_in = grad w.r.t. the embedding output (no LoRA on embed) — drop.
@@ -1232,16 +1338,15 @@ export class SegmentedBackwardOrpoGemma4 {
1232
1338
  const res = this.segVjps[k]!.apply(primals, cots);
1233
1339
  for (const o of res.outputs) o.dispose();
1234
1340
 
1235
- const newDh = detachLeaf(res.vjps[0]!);
1341
+ const leaves = detachLeaves(res.vjps); // one barrier (backlog #9)
1342
+ const newDh = leaves[0]!;
1236
1343
  cons.forEach((d, ci) => {
1237
- const dk = detachLeaf(res.vjps[1 + 2 * ci]!);
1238
- const dv = detachLeaf(res.vjps[1 + 2 * ci + 1]!);
1239
- this.accumulateDKV(dKV, d, dk, dv);
1344
+ this.accumulateDKV(dKV, d, leaves[1 + 2 * ci]!, leaves[1 + 2 * ci + 1]!);
1240
1345
  });
1241
1346
  const base = 1 + 2 * cons.length;
1242
1347
  idxs.forEach((j, s) => {
1243
- grads[j] = detachLeaf(res.vjps[base + 2 * s]!);
1244
- grads[n + j] = detachLeaf(res.vjps[base + 2 * s + 1]!);
1348
+ grads[j] = leaves[base + 2 * s]!;
1349
+ grads[n + j] = leaves[base + 2 * s + 1]!;
1245
1350
  });
1246
1351
 
1247
1352
  dh.dispose();
@@ -1663,16 +1768,15 @@ export class SegmentedBackwardOrpoPrefixGemma4 {
1663
1768
  const res = this.segVjps[k]!.apply(primals, cots);
1664
1769
  for (const o of res.outputs) o.dispose();
1665
1770
 
1666
- const newDh = detachLeaf(res.vjps[0]!);
1771
+ const leaves = detachLeaves(res.vjps); // one barrier (backlog #9)
1772
+ const newDh = leaves[0]!;
1667
1773
  cons.forEach((d, ci) => {
1668
- const dk = detachLeaf(res.vjps[1 + 2 * ci]!);
1669
- const dv = detachLeaf(res.vjps[1 + 2 * ci + 1]!);
1670
- this.accumulateDKV(dKV, d, dk, dv);
1774
+ this.accumulateDKV(dKV, d, leaves[1 + 2 * ci]!, leaves[1 + 2 * ci + 1]!);
1671
1775
  });
1672
1776
  const base = 1 + 2 * cons.length;
1673
1777
  idxs.forEach((j, s) => {
1674
- grads[j] = detachLeaf(res.vjps[base + 2 * s]!);
1675
- grads[n + j] = detachLeaf(res.vjps[base + 2 * s + 1]!);
1778
+ grads[j] = leaves[base + 2 * s]!;
1779
+ grads[n + j] = leaves[base + 2 * s + 1]!;
1676
1780
  });
1677
1781
 
1678
1782
  dh.dispose();