mlx-bun 0.0.9 → 0.0.10

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.
@@ -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();
@@ -388,6 +388,16 @@ export async function trainLora(
388
388
  }
389
389
  }
390
390
 
391
+ // ---------------------------------------------------------------------------
392
+ // Segment planning: uniform planSegmentsBySize only. A full-attention
393
+ // ISOLATION planner (kernel-review backlog #2 / segmented-backward-training.md
394
+ // §5) was built and A/B-measured 2026-07-02: ZERO peak win — mlx's sdpa
395
+ // backward is O(L²) for EVERY layer (~3.5 GB/layer @8K e4b), sliding included,
396
+ // so the worst segment is set by layer count alone and segment_size is the
397
+ // whole knob (seg1 = 14.59 GB @8K, +3% step time — fits the 24 GB M4 Pro).
398
+ // Evidence: scripts/experiments/seg-isolation-smoke.ts; note in segmented.ts.
399
+ // ---------------------------------------------------------------------------
400
+
391
401
  // ---------------------------------------------------------------------------
392
402
  // SFT loop
393
403
  // ---------------------------------------------------------------------------
@@ -421,14 +431,14 @@ async function sftLoop(
421
431
  const useSegmented = cfg.segmentSize > 0;
422
432
  let segmented: SegmentedBackward | SegmentedBackwardGemma4 | null = null;
423
433
  if (useSegmented) {
424
- if (model instanceof MiniCPM5Model)
425
- segmented = new SegmentedBackward(model, lora, planSegmentsBySize(model.layers.length, cfg.segmentSize));
426
- else if (model instanceof Gemma4Model)
427
- segmented = new SegmentedBackwardGemma4(model, lora, planSegmentsBySize(model.layers.length, cfg.segmentSize));
428
- else
434
+ if (!(model instanceof MiniCPM5Model) && !(model instanceof Gemma4Model))
429
435
  throw new Error("segmented backward (segmentSize > 0) is only wired for MiniCPM5 and Gemma4");
436
+ const ranges = planSegmentsBySize(model.layers.length, cfg.segmentSize);
437
+ segmented = model instanceof MiniCPM5Model
438
+ ? new SegmentedBackward(model, lora, ranges)
439
+ : new SegmentedBackwardGemma4(model, lora, ranges);
430
440
  emit({ type: "stage", stage: "setup", progress: 0.04,
431
- message: `segmented backward: ${Math.ceil(model.layers.length / cfg.segmentSize)} segments of <=${cfg.segmentSize} layers` });
441
+ message: `segmented backward: ${ranges.length} segments of <=${cfg.segmentSize} layers` });
432
442
  }
433
443
 
434
444
  // The value_and_grad loss closure: temporarily swap the differentiated
@@ -832,6 +842,8 @@ async function orpoLoop(
832
842
  let segmentedOrpo: SegmentedBackwardOrpo | SegmentedBackwardOrpoGemma4 | null = null;
833
843
  let segmentedOrpoPrefix: SegmentedBackwardOrpoPrefix | SegmentedBackwardOrpoPrefixGemma4 | null = null;
834
844
  if (useSegmented) {
845
+ if (!(model instanceof MiniCPM5Model) && !(model instanceof Gemma4Model))
846
+ throw new Error("segmented backward (segmentSize > 0) for ORPO is only wired for MiniCPM5 and Gemma4");
835
847
  const ranges = planSegmentsBySize(model.layers.length, cfg.segmentSize);
836
848
  if (useSegmentedPrefix) {
837
849
  // The composed prefix-shared segmented backward (MiniCPM5 or Gemma4/e4b), plus a
@@ -845,15 +857,13 @@ async function orpoLoop(
845
857
  segmentedOrpo = new SegmentedBackwardOrpoGemma4(model as Gemma4Model, lora, ranges, cfg.orpoLambda, segFusedChunk, cfg.sftScope);
846
858
  }
847
859
  emit({ type: "stage", stage: "setup", progress: 0.04,
848
- message: `orpo segmented + prefix-share (${model instanceof Gemma4Model ? "e4b" : "MiniCPM5"}): single concat forward streamed over ${Math.ceil(model.layers.length / cfg.segmentSize)} segments of <=${cfg.segmentSize} layers (two-forward fallback on prompt mismatch)` });
860
+ message: `orpo segmented + prefix-share (${model instanceof Gemma4Model ? "e4b" : "MiniCPM5"}): single concat forward streamed over ${ranges.length} segments of <=${cfg.segmentSize} layers (two-forward fallback on prompt mismatch)` });
849
861
  } else if (model instanceof MiniCPM5Model)
850
862
  segmentedOrpo = new SegmentedBackwardOrpo(model, lora, ranges, cfg.orpoLambda, segFusedChunk, cfg.sftScope);
851
- else if (model instanceof Gemma4Model)
852
- segmentedOrpo = new SegmentedBackwardOrpoGemma4(model, lora, ranges, cfg.orpoLambda, segFusedChunk, cfg.sftScope);
853
863
  else
854
- throw new Error("segmented backward (segmentSize > 0) for ORPO is only wired for MiniCPM5 and Gemma4");
864
+ segmentedOrpo = new SegmentedBackwardOrpoGemma4(model as Gemma4Model, lora, ranges, cfg.orpoLambda, segFusedChunk, cfg.sftScope);
855
865
  if (!useSegmentedPrefix) emit({ type: "stage", stage: "setup", progress: 0.04,
856
- message: `orpo segmented backward: ${Math.ceil(model.layers.length / cfg.segmentSize)} segments of <=${cfg.segmentSize} layers${segFusedChunk > 0 ? ` + fused linear-CE head (${segFusedChunk}/chunk)` : ""}` });
866
+ message: `orpo segmented backward: ${ranges.length} segments of <=${cfg.segmentSize} layers${segFusedChunk > 0 ? ` + fused linear-CE head (${segFusedChunk}/chunk)` : ""}` });
857
867
  }
858
868
 
859
869
  // Token-chunked head (non-segmented path only): the per-chunk checkpoints are