mlx-bun 0.0.4 → 0.0.5

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 (50) hide show
  1. package/README.md +166 -93
  2. package/docs/design/adapters-end-to-end.md +50 -46
  3. package/docs/design/compat-cli-surface-design.md +10 -8
  4. package/docs/design/docs-reorg-plan.md +14 -1
  5. package/docs/design/hlg-sampling.md +12 -2
  6. package/docs/design/memory-system.md +1 -1
  7. package/docs/design/optimization_plan.md +4 -2
  8. package/docs/design/orpo-future-enhancements.md +298 -0
  9. package/docs/design/orpo-training.md +1199 -0
  10. package/docs/design/parallel-slots.md +34 -26
  11. package/docs/design/runtime-isolation.md +1 -1
  12. package/docs/design/segmented-backward-training.md +58 -39
  13. package/docs/design/tauri-desktop-app.md +127 -0
  14. package/docs/{segmented-backward-handoff.md → investigations/segmented-backward-handoff.md} +1 -1
  15. package/docs/investigations/starter-model-port-handoff.md +32 -13
  16. package/docs/investigations/steel-flash-cce-handoff.md +431 -0
  17. package/docs/planning/IDEAS.md +29 -0
  18. package/docs/planning/PRODUCT_ROADMAP.md +147 -27
  19. package/docs/planning/chunk-task-roadmap.md +1 -1
  20. package/docs/planning/journal.md +161 -0
  21. package/docs/reference/distribution.md +61 -5
  22. package/docs/reference/library-api.md +29 -14
  23. package/docs/reference/orpo-quickstart.md +91 -0
  24. package/docs/reference/server-api.md +105 -7
  25. package/docs/reference/server-config.md +12 -5
  26. package/docs/reference/training.md +292 -0
  27. package/package.json +1 -1
  28. package/src/eval/ifeval.ts +138 -0
  29. package/src/eval/ultrafeedback.ts +60 -0
  30. package/src/lora.ts +12 -7
  31. package/src/mlx/metal-kernel.ts +18 -4
  32. package/src/model/flash-attention.ts +32 -16
  33. package/src/model/fused-geglu-kernel.ts +111 -0
  34. package/src/model/gemma4-base.ts +39 -1
  35. package/src/model/gemma4.ts +196 -21
  36. package/src/model/minicpm5.ts +44 -3
  37. package/src/server.ts +3 -3
  38. package/src/train/dataset.ts +18 -3
  39. package/src/train/flash-cce.ts +1162 -0
  40. package/src/train/job.ts +26 -2
  41. package/src/train/lora-params.ts +53 -4
  42. package/src/train/loss.ts +941 -0
  43. package/src/train/optimizer.ts +10 -3
  44. package/src/train/prefix-shared.ts +435 -0
  45. package/src/train/rank.ts +36 -0
  46. package/src/train/segmented.ts +1148 -3
  47. package/src/train/steel-qmm-header.ts +1461 -0
  48. package/src/train/trainer.ts +509 -52
  49. package/src/web/app.html +21 -5
  50. /package/{docs → src/assets}/curve-designer.html +0 -0
@@ -37,13 +37,31 @@
37
37
  import { MlxArray } from "../mlx/array";
38
38
  import * as ops from "../mlx/ops";
39
39
  import { Vjp } from "../mlx/autograd";
40
- import type { MiniCPM5Model } from "../model/minicpm5";
40
+ import { MiniCPM5Model, setMiniCpmPrefixPlan } from "../model/minicpm5";
41
41
  import type { Gemma4Model } from "../model/gemma4";
42
+ import { setGemmaPrefixPlan } from "../model/gemma4";
42
43
  import type { Cache, Mask, SharedKv } from "../model/gemma4-base";
43
44
  import { TrainingCache } from "./forward";
44
- import { responseOnlyCe } from "./loss";
45
+ import { responseOnlyCe, responseOnlyLogpMean, fusedLogpMeanFromHidden, chunkedLogpMeanFromHidden, orpoLossFromLogps, type ChunkCtx } from "./loss";
46
+ import { PrefixSharedCache, prefixGatherIdx, branchLogpMeanGathered, blockSparsePrefixMaskGemma } from "./prefix-shared";
47
+
48
+ // Experiment toggle: which bounded head to use inside the segmented mlx_vjp.
49
+ // "checkpoint" = per-chunk Checkpoint recompute (bounds memory under nesting);
50
+ // anything else (default) = the analytic fused CustomVjp. The fused head bounds
51
+ // memory at the TOP level but not when nested in the segmented mlx_vjp (its
52
+ // per-chunk graph isn't freed incrementally there); Checkpoint does. See
53
+ // docs/design/orpo-training.md.
54
+ const SEG_HEAD = process.env.MLX_BUN_SEG_HEAD ?? "checkpoint";
55
+ function boundedHeadFromHidden(
56
+ model: MiniCPM5Model | Gemma4Model, h: MlxArray, ids: number[], mask: number[],
57
+ chunkSize: number, sink: Array<{ dispose(): void }>,
58
+ ): MlxArray {
59
+ return SEG_HEAD === "fused"
60
+ ? fusedLogpMeanFromHidden(model, h, ids, mask, chunkSize, sink)
61
+ : chunkedLogpMeanFromHidden(model, h, ids, mask, chunkSize, sink);
62
+ }
45
63
  import type { TrainableLora } from "./lora-params";
46
- import { type SftBatch } from "./dataset";
64
+ import { type SftBatch, type DpoBatch } from "./dataset";
47
65
 
48
66
  /** Split `nLayers` into contiguous `[lo, hi)` ranges of at most `segSize`
49
67
  * layers each (the last range may be shorter). `segSize` is the memory knob:
@@ -518,3 +536,1130 @@ export class SegmentedBackwardGemma4 {
518
536
  this.one.dispose();
519
537
  }
520
538
  }
539
+
540
+ // ===========================================================================
541
+ // SegmentedBackwardOrpo: segmented backward for ORPO (MiniCPM5 only, B=1).
542
+ // ===========================================================================
543
+ //
544
+ // Same skeleton as SegmentedBackward, but operates over TWO sequences
545
+ // (chosen + rejected) per step, accumulating LoRA gradients from both branches.
546
+ // Memory profile: TWO sets of boundary buffers ([1,T,hidden] per segment edge)
547
+ // are live during the forward phase; only one segment's activations are live per
548
+ // backward sub-step (one branch at a time). Grads from chosen and rejected are
549
+ // summed per parameter before being returned.
550
+ //
551
+ // The head VJP takes [h_chosen_last, h_rejected_last] → [loss scalar].
552
+ // Its cotangents [dh_chosen, dh_rejected] seed the two backward passes separately.
553
+
554
+ /** Segmented backward for ORPO (MiniCPM5 only, B=1). Runs chosen+rejected forwards
555
+ * segment-by-segment, accumulating LoRA gradients from both branches. */
556
+ export class SegmentedBackwardOrpo {
557
+ private readonly nLayers: number;
558
+ private readonly n: number;
559
+ private readonly segIdxs: number[][];
560
+ private readonly caches: Cache[];
561
+ private readonly headVjp: Vjp;
562
+ private readonly segVjps: Vjp[];
563
+ private readonly one: MlxArray;
564
+ // Per-step mutable context
565
+ private curChosenIds: number[] | null = null;
566
+ private curChosenMask: number[] | null = null;
567
+ private curRejectedIds: number[] | null = null;
568
+ private curRejectedMask: number[] | null = null;
569
+ private disposed = false;
570
+
571
+ // When > 0, the loss head runs the FUSED linear-CE path (fusedLogpMeanFromHidden:
572
+ // token-chunked analytic CustomVjp) instead of the full-[M,V] responseOnlyLogpMean
573
+ // — so the head term is bounded to [chunk,V] INSIDE the segmented backward,
574
+ // compounding with the per-segment layer savings (ORPO pressure = layers +
575
+ // response length + head). Per-step disposables (the CustomVjps + response slices)
576
+ // live in headSink, disposed after the head vjp is eval'd.
577
+ private headSink: Array<{ dispose(): void }> = [];
578
+
579
+ constructor(
580
+ private readonly model: MiniCPM5Model,
581
+ private readonly lora: TrainableLora,
582
+ private readonly ranges: [number, number][],
583
+ private readonly lambda: number,
584
+ private readonly fusedChunkSize = 0,
585
+ ) {
586
+ this.nLayers = model.layers.length;
587
+ const lastHi = ranges[ranges.length - 1]?.[1] ?? 0;
588
+ if (ranges[0]?.[0] !== 0 || lastHi !== this.nLayers)
589
+ throw new Error(`SegmentedBackwardOrpo: ranges must cover [0, ${this.nLayers})`);
590
+ this.n = lora.targets.length;
591
+ const layerOf = lora.targets.map((t) => targetLayer(t.modulePath));
592
+ this.segIdxs = ranges.map(([lo, hi]) =>
593
+ layerOf.map((li, j) => ({ li, j })).filter((x) => x.li >= lo && x.li < hi).map((x) => x.j),
594
+ );
595
+ this.caches = Array.from({ length: this.nLayers }, () => new TrainingCache());
596
+ this.one = MlxArray.fromFloat32(new Float32Array([1]), []);
597
+
598
+ // Head VJP: inputs = [h_chosen_last, h_rejected_last] (pre-finalNorm at last boundary).
599
+ // Applies finalNorm to each, computes ℓw and ℓr, returns scalar orpo loss.
600
+ // nOut = 1 (one scalar loss output, one cotangent needed from caller).
601
+ // apply([hC, hR], [one]) returns vjps[0] = dh_chosen, vjps[1] = dh_rejected.
602
+ this.headVjp = new Vjp((p) => {
603
+ const hnC = model.finalNorm.forward(p[0]!); // [1, T, hidden]
604
+ const hnR = model.finalNorm.forward(p[1]!);
605
+ const cs = this.fusedChunkSize;
606
+ const lw = cs > 0 // [1]
607
+ ? boundedHeadFromHidden(model, hnC, this.curChosenIds!, this.curChosenMask!, cs, this.headSink)
608
+ : responseOnlyLogpMean(model, hnC, this.curChosenIds!, this.curChosenMask!);
609
+ const lr = cs > 0 // [1]
610
+ ? boundedHeadFromHidden(model, hnR, this.curRejectedIds!, this.curRejectedMask!, cs, this.headSink)
611
+ : responseOnlyLogpMean(model, hnR, this.curRejectedIds!, this.curRejectedMask!);
612
+ hnC.dispose(); hnR.dispose();
613
+ const loss = orpoLossFromLogps(lw, lr, this.lambda);
614
+ lw.dispose(); lr.dispose();
615
+ return [loss];
616
+ }, 1);
617
+
618
+ // One reused vjp per segment — same as SegmentedBackward; reused across both branches.
619
+ this.segVjps = ranges.map(([lo, hi], k) => {
620
+ const idxs = this.segIdxs[k]!;
621
+ return new Vjp((p) => {
622
+ const saved: [MlxArray, MlxArray][] = idxs.map((j) => [lora.targets[j]!.lw.a, lora.targets[j]!.lw.b]);
623
+ idxs.forEach((j, s) => { lora.targets[j]!.lw.a = p[1 + 2 * s]!; lora.targets[j]!.lw.b = p[1 + 2 * s + 1]!; });
624
+ try {
625
+ return [model.runLayerRange(p[0]!, lo, hi, this.caches)];
626
+ } finally {
627
+ idxs.forEach((j, s) => { lora.targets[j]!.lw.a = saved[s]![0]; lora.targets[j]!.lw.b = saved[s]![1]; });
628
+ }
629
+ }, 1);
630
+ });
631
+ }
632
+
633
+ step(batch: DpoBatch): { value: MlxArray; grads: MlxArray[] } {
634
+ if (this.disposed) throw new Error("SegmentedBackwardOrpo used after dispose");
635
+ const { model, lora, ranges, caches, segIdxs, n } = this;
636
+ if (batch.chosenIds.length !== 1) throw new Error("SegmentedBackwardOrpo: only B=1 is supported");
637
+ // Store per-step batch for the head VJP closure
638
+ this.curChosenIds = batch.chosenIds[0]!;
639
+ this.curChosenMask = batch.chosenMask[0]!;
640
+ this.curRejectedIds = batch.rejectedIds[0]!;
641
+ this.curRejectedMask = batch.rejectedMask[0]!;
642
+
643
+ const Lc = batch.chosenIds[0]!.length;
644
+ const Lr = batch.rejectedIds[0]!.length;
645
+ if (Lc < 2 || Lr < 2) throw new Error("SegmentedBackwardOrpo: sequences too short");
646
+ const nSeg = ranges.length;
647
+ const gradsC: (MlxArray | null)[] = new Array(2 * n).fill(null);
648
+ const gradsR: (MlxArray | null)[] = new Array(2 * n).fill(null);
649
+ const bndC: (MlxArray | null)[] = [];
650
+ const bndR: (MlxArray | null)[] = [];
651
+ let value: MlxArray | null = null;
652
+ let dhC: MlxArray | null = null;
653
+ let dhR: MlxArray | null = null;
654
+ let transferred = false;
655
+
656
+ try {
657
+ // --- 1. Forward chosen ---
658
+ const Tc = Lc - 1;
659
+ const chosenInput = new Int32Array(Tc);
660
+ for (let t = 0; t < Tc; t++) chosenInput[t] = batch.chosenIds[0]![t]!;
661
+ const chosenIds = MlxArray.fromInt32(chosenInput, [1, Tc]);
662
+ bndC.push(detachLeaf(model.embed.encode(chosenIds)));
663
+ chosenIds.dispose();
664
+ for (let k = 0; k < nSeg; k++) {
665
+ const [lo, hi] = ranges[k]!;
666
+ bndC.push(detachLeaf(model.runLayerRange(bndC[k]!, lo, hi, caches)));
667
+ }
668
+
669
+ // --- 2. Forward rejected ---
670
+ const Tr = Lr - 1;
671
+ const rejInput = new Int32Array(Tr);
672
+ for (let t = 0; t < Tr; t++) rejInput[t] = batch.rejectedIds[0]![t]!;
673
+ const rejIds = MlxArray.fromInt32(rejInput, [1, Tr]);
674
+ bndR.push(detachLeaf(model.embed.encode(rejIds)));
675
+ rejIds.dispose();
676
+ for (let k = 0; k < nSeg; k++) {
677
+ const [lo, hi] = ranges[k]!;
678
+ bndR.push(detachLeaf(model.runLayerRange(bndR[k]!, lo, hi, caches)));
679
+ }
680
+
681
+ // --- 3. Head VJP: [last_chosen, last_rejected] → [loss], cotangent 1.0 → [dh_c, dh_r] ---
682
+ // The fused head pushes per-step CustomVjps + response slices into headSink;
683
+ // they recompute during this apply's backward, so dispose only AFTER the
684
+ // outputs/vjps are detached to leaves (which forces the eval).
685
+ this.headSink = [];
686
+ const head = this.headVjp.apply([bndC[nSeg]!, bndR[nSeg]!], [this.one]);
687
+ // Materialize the full head-VJP graph (incl. the nested CustomVjp backward that
688
+ // reads savedLse/savedBlockMax) BEFORE freeing headSink — avoids the lazy-eval
689
+ // use-after-free segfault (see the segmented-prefix crash diagnosis).
690
+ ops.evalAll([head.outputs[0]!, head.vjps[0]!, head.vjps[1]!]);
691
+ value = detachLeaf(head.outputs[0]!);
692
+ dhC = detachLeaf(head.vjps[0]!);
693
+ dhR = detachLeaf(head.vjps[1]!);
694
+ for (const d of this.headSink) d.dispose();
695
+ this.headSink = [];
696
+ bndC[nSeg]!.dispose(); bndC[nSeg] = null;
697
+ bndR[nSeg]!.dispose(); bndR[nSeg] = null;
698
+
699
+ // --- 4. Backward chosen ---
700
+ for (let k = nSeg - 1; k >= 0; k--) {
701
+ const idxs = segIdxs[k]!;
702
+ const params: MlxArray[] = [bndC[k]!];
703
+ for (const j of idxs) params.push(lora.targets[j]!.lw.a, lora.targets[j]!.lw.b);
704
+ const res = this.segVjps[k]!.apply(params, [dhC!]);
705
+ res.outputs[0]!.dispose();
706
+ idxs.forEach((j, s) => {
707
+ gradsC[j] = detachLeaf(res.vjps[1 + 2 * s]!); // dA
708
+ gradsC[n + j] = detachLeaf(res.vjps[1 + 2 * s + 1]!); // dB
709
+ });
710
+ dhC!.dispose();
711
+ dhC = detachLeaf(res.vjps[0]!);
712
+ bndC[k]!.dispose(); bndC[k] = null;
713
+ }
714
+ dhC!.dispose(); dhC = null; // embedding grad, discarded (no LoRA on embed)
715
+
716
+ // --- 5. Backward rejected ---
717
+ for (let k = nSeg - 1; k >= 0; k--) {
718
+ const idxs = segIdxs[k]!;
719
+ const params: MlxArray[] = [bndR[k]!];
720
+ for (const j of idxs) params.push(lora.targets[j]!.lw.a, lora.targets[j]!.lw.b);
721
+ const res = this.segVjps[k]!.apply(params, [dhR!]);
722
+ res.outputs[0]!.dispose();
723
+ idxs.forEach((j, s) => {
724
+ gradsR[j] = detachLeaf(res.vjps[1 + 2 * s]!); // dA
725
+ gradsR[n + j] = detachLeaf(res.vjps[1 + 2 * s + 1]!); // dB
726
+ });
727
+ dhR!.dispose();
728
+ dhR = detachLeaf(res.vjps[0]!);
729
+ bndR[k]!.dispose(); bndR[k] = null;
730
+ }
731
+ dhR!.dispose(); dhR = null; // embedding grad, discarded
732
+
733
+ // --- 6. Sum LoRA grads from both branches ---
734
+ const grads: MlxArray[] = gradsC.map((gc, i) => {
735
+ const gr = gradsR[i]!;
736
+ const sum = ops.add(gc!, gr);
737
+ gc!.dispose(); gr.dispose();
738
+ gradsC[i] = null; gradsR[i] = null;
739
+ return detachLeaf(sum);
740
+ });
741
+
742
+ transferred = true;
743
+ return { value: value!, grads };
744
+ } finally {
745
+ this.curChosenIds = this.curChosenMask = this.curRejectedIds = this.curRejectedMask = null;
746
+ dhC?.dispose(); dhR?.dispose();
747
+ for (const b of bndC) b?.dispose();
748
+ for (const b of bndR) b?.dispose();
749
+ if (!transferred) {
750
+ value?.dispose();
751
+ for (const g of gradsC) g?.dispose();
752
+ for (const g of gradsR) g?.dispose();
753
+ }
754
+ }
755
+ }
756
+
757
+ dispose(): void {
758
+ if (this.disposed) return;
759
+ this.disposed = true;
760
+ this.headVjp.dispose();
761
+ for (const v of this.segVjps) v.dispose();
762
+ for (const c of this.caches) c.dispose();
763
+ this.one.dispose();
764
+ }
765
+ }
766
+
767
+ // ===========================================================================
768
+ // SegmentedBackwardOrpoPrefix: segmented backward + PREFIX-SHARING (MiniCPM5, B=1).
769
+ // ===========================================================================
770
+ //
771
+ // The composition of SegmentedBackwardOrpo (layer-streaming backward) with
772
+ // prefix-sharing (one forward over [prompt; chosen; rejected], block-sparse mask
773
+ // + block-wise RoPE). Instead of TWO separate sequences, this streams ONE concat
774
+ // sequence segment-by-segment, so the shared prompt is encoded exactly once even
775
+ // at long sequence length — the prefix-share saving now holds AT seq 8192.
776
+ //
777
+ // Differences from SegmentedBackwardOrpo:
778
+ // - input = the single concat [prompt(P); chosen(Rc); rejected(Rr)] (T = P+Rc+Rr),
779
+ // NOT two branches. One boundary stream, one backward walk.
780
+ // - per-layer cache = PrefixSharedCache (its makeMask returns the block-sparse
781
+ // mask: chosen & rejected each attend the prompt but NOT each other). Flows
782
+ // through runLayerRange automatically (it calls cache[aIdx].makeMask).
783
+ // - setMiniCpmPrefixPlan({P,Rc,Rr}) is ACTIVE for the whole step (set in step(),
784
+ // cleared in finally) so block-wise RoPE rides through BOTH the streaming
785
+ // forward AND every segment's recompute inside the per-segment vjp.
786
+ // - the head gathers the chosen/rejected response hiddens from the final
787
+ // [1,T,hidden] (via branchLogpMeanGathered — the same [M,V]-free per-branch
788
+ // flash head the non-segmented prefix path uses) and ORPO-combines them. Its
789
+ // vjp w.r.t. the last boundary is the full [1,T,hidden] dh (the gather's
790
+ // takeAxis vjp scatter-adds each branch's response-position cotangent back
791
+ // into the full sequence), which seeds the single segmented backward.
792
+
793
+ export class SegmentedBackwardOrpoPrefix {
794
+ private readonly nLayers: number;
795
+ private readonly n: number;
796
+ private readonly segIdxs: number[][];
797
+ private readonly headVjp: Vjp;
798
+ private readonly segVjps: Vjp[];
799
+ private readonly one: MlxArray;
800
+ // Per-step mutable context the (reused) closures read.
801
+ private curP = 0;
802
+ private curRc = 0;
803
+ private curRr = 0;
804
+ private curChosenResp: number[] | null = null;
805
+ private curRejectedResp: number[] | null = null;
806
+ private curChunk: ChunkCtx | undefined = undefined;
807
+ // Per-step prefix caches (rebuilt each step — they capture P/Rc/Rr). The seg
808
+ // vjps read them via this.curCaches so makeMask returns the block-sparse mask.
809
+ private curCaches: Cache[] = [];
810
+ private headSink: Array<{ dispose(): void }> = [];
811
+ private disposed = false;
812
+
813
+ constructor(
814
+ private readonly model: MiniCPM5Model,
815
+ private readonly lora: TrainableLora,
816
+ private readonly ranges: [number, number][],
817
+ private readonly lambda: number,
818
+ private readonly chunkCtx?: ChunkCtx,
819
+ ) {
820
+ this.nLayers = model.layers.length;
821
+ const lastHi = ranges[ranges.length - 1]?.[1] ?? 0;
822
+ if (ranges[0]?.[0] !== 0 || lastHi !== this.nLayers)
823
+ throw new Error(`SegmentedBackwardOrpoPrefix: ranges must cover [0, ${this.nLayers})`);
824
+ this.n = lora.targets.length;
825
+ const layerOf = lora.targets.map((t) => targetLayer(t.modulePath));
826
+ this.segIdxs = ranges.map(([lo, hi]) =>
827
+ layerOf.map((li, j) => ({ li, j })).filter((x) => x.li >= lo && x.li < hi).map((x) => x.j),
828
+ );
829
+ this.one = MlxArray.fromFloat32(new Float32Array([1]), []);
830
+
831
+ // Head VJP: input = the last boundary [1, T, hidden] (pre-finalNorm). Applies
832
+ // finalNorm, gathers each branch's response hiddens, computes ℓw/ℓr through the
833
+ // [M,V]-free per-branch head (branchLogpMeanGathered), returns the scalar orpo
834
+ // loss. nOut = 1; apply([h_last], [one]) returns vjps[0] = dh [1, T, hidden]
835
+ // (the gather's takeAxis vjp scatter-adds the per-branch dh into the full seq).
836
+ this.headVjp = new Vjp((p) => {
837
+ const hn = model.finalNorm.forward(p[0]!); // [1, T, hidden]
838
+ const { chosenIdx, rejectedIdx } = prefixGatherIdx(this.curP, this.curRc, this.curRr);
839
+ const lw = branchLogpMeanGathered(model, hn, chosenIdx, this.curChosenResp!, this.curChunk);
840
+ const lr = branchLogpMeanGathered(model, hn, rejectedIdx, this.curRejectedResp!, this.curChunk);
841
+ hn.dispose();
842
+ const loss = orpoLossFromLogps(lw, lr, this.lambda);
843
+ lw.dispose(); lr.dispose();
844
+ return [loss];
845
+ }, 1);
846
+
847
+ // One reused vjp per segment — same shape as SegmentedBackwardOrpo, but the
848
+ // forward calls runLayerRange with the PREFIX caches (block-sparse mask). The
849
+ // prefix RoPE plan is active for the whole step (set in step), so the recompute
850
+ // here ropes block-wise too. (branchLogpMeanGathered handles the head; these
851
+ // only stream the transformer layers.)
852
+ this.segVjps = ranges.map(([lo, hi], k) => {
853
+ const idxs = this.segIdxs[k]!;
854
+ return new Vjp((p) => {
855
+ const saved: [MlxArray, MlxArray][] = idxs.map((j) => [lora.targets[j]!.lw.a, lora.targets[j]!.lw.b]);
856
+ idxs.forEach((j, s) => { lora.targets[j]!.lw.a = p[1 + 2 * s]!; lora.targets[j]!.lw.b = p[1 + 2 * s + 1]!; });
857
+ try {
858
+ return [model.runLayerRange(p[0]!, lo, hi, this.curCaches)];
859
+ } finally {
860
+ idxs.forEach((j, s) => { lora.targets[j]!.lw.a = saved[s]![0]; lora.targets[j]!.lw.b = saved[s]![1]; });
861
+ }
862
+ }, 1);
863
+ });
864
+ }
865
+
866
+ /** One ORPO step over the prefix-shared concat. `promptIds`/`chosenResp`/
867
+ * `rejectedResp` come from splitPrefixBatch. Returns the loss + LoRA grads in
868
+ * flatParams(lora) order (caller owns). */
869
+ stepPrefix(promptIds: number[], chosenResp: number[], rejectedResp: number[]): { value: MlxArray; grads: MlxArray[] } {
870
+ if (this.disposed) throw new Error("SegmentedBackwardOrpoPrefix used after dispose");
871
+ const { model, lora, ranges, segIdxs, n } = this;
872
+ const P = promptIds.length, Rc = chosenResp.length, Rr = rejectedResp.length;
873
+ if (P < 1 || Rc < 1 || Rr < 1) throw new Error("SegmentedBackwardOrpoPrefix: need P,Rc,Rr >= 1");
874
+ const T = P + Rc + Rr;
875
+ this.curP = P; this.curRc = Rc; this.curRr = Rr;
876
+ this.curChosenResp = chosenResp; this.curRejectedResp = rejectedResp;
877
+ // Per-step chunk ctx pointing the head's CustomVjp/slice disposables at this
878
+ // step's headSink (the flash/fused head recompute runs during headVjp.apply;
879
+ // we dispose headSink only AFTER the outputs/vjps are detached -> eval forced).
880
+ this.headSink = [];
881
+ this.curChunk = this.chunkCtx ? { ...this.chunkCtx, sink: this.headSink } : undefined;
882
+ // One prefix cache per layer (captures P/Rc/Rr; makeMask -> block-sparse mask).
883
+ this.curCaches = model.layers.map(() => new PrefixSharedCache(P, Rc, Rr));
884
+
885
+ const nSeg = ranges.length;
886
+ const concat = new Int32Array(T);
887
+ concat.set(promptIds, 0);
888
+ concat.set(chosenResp, P);
889
+ concat.set(rejectedResp, P + Rc);
890
+ const inputIds = MlxArray.fromInt32(concat, [1, T]);
891
+
892
+ const boundaries: (MlxArray | null)[] = [];
893
+ let value: MlxArray | null = null;
894
+ let dhOut: MlxArray | null = null;
895
+ let transferred = false;
896
+ const grads: (MlxArray | null)[] = new Array(2 * n).fill(null);
897
+
898
+ // Block-wise RoPE active for the WHOLE step (forward + every segment recompute).
899
+ setMiniCpmPrefixPlan({ P, Rc, Rr });
900
+ try {
901
+ // --- 1. Forward over the concat, saving detached boundaries at seg edges. ---
902
+ boundaries.push(detachLeaf(model.embed.encode(inputIds))); // [1, T, hidden]
903
+ for (let k = 0; k < nSeg; k++) {
904
+ const [lo, hi] = ranges[k]!;
905
+ boundaries.push(detachLeaf(model.runLayerRange(boundaries[k]!, lo, hi, this.curCaches)));
906
+ }
907
+
908
+ // --- 2. Head over the last boundary (vjp, cotangent 1.0) -> [dh 1,T,hidden]. ---
909
+ const head = this.headVjp.apply([boundaries[nSeg]!], [this.one]);
910
+ // Force the FULL head-VJP graph (incl. the nested flash-CCE CustomVjp backward,
911
+ // which reads savedLse/savedBlockMax) to materialize BEFORE the headSink dispose
912
+ // below — else that deferred backward can run AFTER its lse/blockMax are freed
913
+ // under MLX lazy eval (a use-after-free → segfault at a tiny address, ~step 100).
914
+ ops.evalAll([head.outputs[0]!, head.vjps[0]!]);
915
+ value = detachLeaf(head.outputs[0]!);
916
+ dhOut = detachLeaf(head.vjps[0]!); // dLoss / d(last residual stream), [1,T,hidden]
917
+ for (const d of this.headSink) d.dispose();
918
+ this.headSink = [];
919
+ boundaries[nSeg]!.dispose(); boundaries[nSeg] = null;
920
+
921
+ // --- 3. Backward, reverse over segments (single stream, cotangent = dh). ---
922
+ for (let k = nSeg - 1; k >= 0; k--) {
923
+ const idxs = segIdxs[k]!;
924
+ const params: MlxArray[] = [boundaries[k]!];
925
+ for (const j of idxs) params.push(lora.targets[j]!.lw.a, lora.targets[j]!.lw.b);
926
+ const res = this.segVjps[k]!.apply(params, [dhOut!]);
927
+ res.outputs[0]!.dispose();
928
+ idxs.forEach((j, s) => {
929
+ grads[j] = detachLeaf(res.vjps[1 + 2 * s]!); // dA
930
+ grads[n + j] = detachLeaf(res.vjps[1 + 2 * s + 1]!); // dB
931
+ });
932
+ dhOut!.dispose();
933
+ dhOut = detachLeaf(res.vjps[0]!);
934
+ boundaries[k]!.dispose(); boundaries[k] = null;
935
+ }
936
+ // Segment-0 dh_in = grad w.r.t. the embedding output (no LoRA on embed) — drop.
937
+
938
+ transferred = true;
939
+ return { value: value!, grads: grads as MlxArray[] };
940
+ } finally {
941
+ setMiniCpmPrefixPlan(null);
942
+ inputIds.dispose();
943
+ for (const c of this.curCaches) c.dispose();
944
+ this.curCaches = [];
945
+ this.curChosenResp = this.curRejectedResp = null;
946
+ for (const d of this.headSink) d.dispose();
947
+ this.headSink = [];
948
+ dhOut?.dispose();
949
+ for (const b of boundaries) b?.dispose();
950
+ if (!transferred) {
951
+ value?.dispose();
952
+ for (const g of grads) g?.dispose();
953
+ }
954
+ }
955
+ }
956
+
957
+ dispose(): void {
958
+ if (this.disposed) return;
959
+ this.disposed = true;
960
+ this.headVjp.dispose();
961
+ for (const v of this.segVjps) v.dispose();
962
+ this.one.dispose();
963
+ }
964
+ }
965
+
966
+ // ===========================================================================
967
+ // SegmentedBackwardOrpoGemma4: segmented backward for ORPO on e4b (B=1).
968
+ // ===========================================================================
969
+ //
970
+ // The cross of SegmentedBackwardGemma4 (KV-shared donor threading + per-layer-
971
+ // input boundary + per-segment vjp with consumes/produces) and
972
+ // SegmentedBackwardOrpo (TWO sequences — chosen + rejected — and the ORPO head).
973
+ //
974
+ // Per step: forward each branch segment-by-segment (saving detached hidden +
975
+ // reused-donor K/V boundaries, the branch's per-layer-input constant, and the
976
+ // branch's masks); the ORPO head vjp over [last_chosen, last_rejected] -> loss
977
+ // yields [dh_chosen, dh_rejected]; then each branch is walked backward in
978
+ // reverse over its segments (donor cotangents accumulated in dKV exactly as in
979
+ // SegmentedBackwardGemma4). LoRA grads from the two branches are summed.
980
+ //
981
+ // The donor consumes/produces TOPOLOGY is a function of the architecture and the
982
+ // segmentation only (not the data), so it is computed ONCE and reused for both
983
+ // branches. The masks and per-layer-input DIFFER per branch (different lengths),
984
+ // so the reused per-segment vjp closures read the active branch's masks/perLayer
985
+ // off curMasks/curPerLayer, swapped per backward pass.
986
+
987
+ /** Per-branch forward state: the detached boundaries + reused-donor K/V
988
+ * boundaries, plus the masks and per-layer-input constant for THAT branch
989
+ * (held across its backward; the reused seg vjps read them via curMasks/
990
+ * curPerLayer). The step's finally disposes whatever survives. */
991
+ interface BranchState {
992
+ boundaries: (MlxArray | null)[];
993
+ donorBnd: Map<number, KvPair>;
994
+ masks: Map<string, Mask> | null;
995
+ perLayer: MlxArray | null;
996
+ }
997
+
998
+ export class SegmentedBackwardOrpoGemma4 {
999
+ private readonly nLayers: number;
1000
+ private readonly n: number;
1001
+ private readonly segIdxs: number[][];
1002
+ private readonly consumes: number[][];
1003
+ private readonly produces: number[][];
1004
+ private readonly caches: Cache[];
1005
+ private readonly headVjp: Vjp;
1006
+ private readonly segVjps: Vjp[];
1007
+ private readonly one: MlxArray;
1008
+ // Per-step / per-branch mutable context the (reused) closures read.
1009
+ private curChosenIds: number[] | null = null;
1010
+ private curChosenMask: number[] | null = null;
1011
+ private curRejectedIds: number[] | null = null;
1012
+ private curRejectedMask: number[] | null = null;
1013
+ private curMasks: Map<string, Mask> | null = null; // active branch (set per backward pass)
1014
+ private curPerLayer: MlxArray | null = null;
1015
+ // Fused linear-CE head sink (see SegmentedBackwardOrpo): per-step CustomVjps +
1016
+ // response slices, disposed after the head vjp is eval'd. Bounds the head term
1017
+ // to [chunk,V] inside the e4b segmented backward.
1018
+ private headSink: Array<{ dispose(): void }> = [];
1019
+ private disposed = false;
1020
+
1021
+ constructor(
1022
+ private readonly model: Gemma4Model,
1023
+ private readonly lora: TrainableLora,
1024
+ private readonly ranges: [number, number][],
1025
+ private readonly lambda: number,
1026
+ private readonly fusedChunkSize = 0,
1027
+ ) {
1028
+ this.nLayers = model.layers.length;
1029
+ const lastHi = ranges[ranges.length - 1]?.[1] ?? 0;
1030
+ if (ranges[0]?.[0] !== 0 || lastHi !== this.nLayers)
1031
+ throw new Error(`SegmentedBackwardOrpoGemma4: ranges must cover [0, ${this.nLayers})`);
1032
+ this.n = lora.targets.length;
1033
+
1034
+ const layerOf = lora.targets.map((t) => targetLayer(t.modulePath));
1035
+ this.segIdxs = ranges.map(([lo, hi]) =>
1036
+ layerOf.map((li, j) => ({ li, j })).filter((x) => x.li >= lo && x.li < hi).map((x) => x.j),
1037
+ );
1038
+
1039
+ // Donor/sharer KV threading topology (identical to SegmentedBackwardGemma4):
1040
+ // architecture + segmentation only, so shared across both branches.
1041
+ const segOfLayer = new Map<number, number>();
1042
+ ranges.forEach(([lo, hi], k) => { for (let i = lo; i < hi; i++) segOfLayer.set(i, k); });
1043
+ const reused = model.reusedDonors;
1044
+ const producerSeg = new Map<number, number>();
1045
+ for (const d of reused) producerSeg.set(d, segOfLayer.get(d)!);
1046
+ this.consumes = ranges.map(() => []);
1047
+ this.produces = ranges.map(() => []);
1048
+ for (let i = 0; i < this.nLayers; i++) {
1049
+ if (model.cacheIndex[i] !== -1) continue;
1050
+ const d = model.previousKvs[i]!;
1051
+ const sSeg = segOfLayer.get(i)!;
1052
+ const pSeg = producerSeg.get(d)!;
1053
+ if (sSeg > pSeg) {
1054
+ if (!this.consumes[sSeg]!.includes(d)) this.consumes[sSeg]!.push(d);
1055
+ if (!this.produces[pSeg]!.includes(d)) this.produces[pSeg]!.push(d);
1056
+ }
1057
+ }
1058
+
1059
+ this.caches = Array.from({ length: model.numDonors }, () => new TrainingCache());
1060
+ this.one = MlxArray.fromFloat32(new Float32Array([1]), []);
1061
+
1062
+ // ORPO head (identical to SegmentedBackwardOrpo): inputs = [last_chosen,
1063
+ // last_rejected] pre-finalNorm; finalNorm each, length-normalized response
1064
+ // logp, orpo loss. vjp with cotangent 1.0 -> [dh_chosen, dh_rejected].
1065
+ this.headVjp = new Vjp((p) => {
1066
+ const hnC = this.model.finalNorm.forward(p[0]!);
1067
+ const hnR = this.model.finalNorm.forward(p[1]!);
1068
+ const cs = this.fusedChunkSize;
1069
+ const lw = cs > 0
1070
+ ? boundedHeadFromHidden(this.model, hnC, this.curChosenIds!, this.curChosenMask!, cs, this.headSink)
1071
+ : responseOnlyLogpMean(this.model, hnC, this.curChosenIds!, this.curChosenMask!);
1072
+ const lr = cs > 0
1073
+ ? boundedHeadFromHidden(this.model, hnR, this.curRejectedIds!, this.curRejectedMask!, cs, this.headSink)
1074
+ : responseOnlyLogpMean(this.model, hnR, this.curRejectedIds!, this.curRejectedMask!);
1075
+ hnC.dispose(); hnR.dispose();
1076
+ const loss = orpoLossFromLogps(lw, lr, this.lambda);
1077
+ lw.dispose(); lr.dispose();
1078
+ return [loss];
1079
+ }, 1);
1080
+
1081
+ // One reused vjp per segment (identical to SegmentedBackwardGemma4); reused
1082
+ // across both branches. Inputs: [boundary, (k,v)*consumes, (a,b)*lora].
1083
+ // Outputs: [h, (k,v)*produces]. Reads curMasks/curPerLayer (active branch).
1084
+ this.segVjps = ranges.map(([lo, hi], k) => {
1085
+ const idxs = this.segIdxs[k]!;
1086
+ const cons = this.consumes[k]!;
1087
+ const prod = this.produces[k]!;
1088
+ const base = 1 + 2 * cons.length;
1089
+ const nOut = 1 + 2 * prod.length;
1090
+ return new Vjp((p) => {
1091
+ const boundary = p[0]!;
1092
+ const kvIn = new Map<number, SharedKv>();
1093
+ cons.forEach((d, ci) => kvIn.set(d, { kind: "plain", keys: p[1 + 2 * ci]!, values: p[1 + 2 * ci + 1]!, offset: 0 }));
1094
+ const saved: [MlxArray, MlxArray][] = idxs.map((j) => [lora.targets[j]!.lw.a, lora.targets[j]!.lw.b]);
1095
+ idxs.forEach((j, s) => {
1096
+ lora.targets[j]!.lw.a = p[base + 2 * s]!;
1097
+ lora.targets[j]!.lw.b = p[base + 2 * s + 1]!;
1098
+ });
1099
+ try {
1100
+ const { h, donorKvOut } = this.model.runLayerRange(
1101
+ boundary, lo, hi, this.caches, this.curMasks!, this.curPerLayer, kvIn,
1102
+ );
1103
+ const outs: MlxArray[] = [h];
1104
+ for (const d of prod) {
1105
+ const kv = donorKvOut.get(d)!;
1106
+ if (kv.kind !== "plain") throw new Error("segmented e4b orpo: quantized donor KV unsupported in training");
1107
+ const ck = ops.contiguous(kv.keys), cv = ops.contiguous(kv.values);
1108
+ kv.keys.dispose(); kv.values.dispose();
1109
+ outs.push(ck, cv);
1110
+ }
1111
+ for (const [d, kv] of donorKvOut)
1112
+ if (!prod.includes(d) && kv.kind === "plain") { kv.keys.dispose(); kv.values.dispose(); }
1113
+ return outs;
1114
+ } finally {
1115
+ idxs.forEach((j, s) => { lora.targets[j]!.lw.a = saved[s]![0]; lora.targets[j]!.lw.b = saved[s]![1]; });
1116
+ }
1117
+ }, nOut);
1118
+ });
1119
+ }
1120
+
1121
+ /** Forward one branch segment-by-segment, populating `st` (boundaries +
1122
+ * reused-donor K/V boundaries + this branch's masks + per-layer constant).
1123
+ * Mutates `st` incrementally so the step's finally always sees partial state
1124
+ * on throw. The last boundary (st.boundaries[nSeg]) feeds the ORPO head. */
1125
+ private forwardBranch(ids: number[], st: BranchState): void {
1126
+ const { model, ranges, caches, consumes, produces } = this;
1127
+ const T = ids.length - 1;
1128
+ const inputHost = new Int32Array(T);
1129
+ for (let t = 0; t < T; t++) inputHost[t] = ids[t]!;
1130
+ const inputIds = MlxArray.fromInt32(inputHost, [1, T]);
1131
+ try {
1132
+ const { hScaled, perLayer } = model.embedForSegmented(inputIds);
1133
+ st.boundaries.push(detachLeaf(hScaled));
1134
+ st.perLayer = perLayer ? detachLeaf(perLayer) : null;
1135
+ st.masks = model.makeTrainingMasks(caches, T);
1136
+
1137
+ for (let k = 0; k < ranges.length; k++) {
1138
+ const [lo, hi] = ranges[k]!;
1139
+ const kvIn = new Map<number, SharedKv>();
1140
+ for (const d of consumes[k]!) {
1141
+ const b = st.donorBnd.get(d)!;
1142
+ kvIn.set(d, { kind: "plain", keys: b.keys, values: b.values, offset: 0 });
1143
+ }
1144
+ const { h, donorKvOut } = model.runLayerRange(st.boundaries[k]!, lo, hi, caches, st.masks, st.perLayer, kvIn);
1145
+ for (const d of produces[k]!) {
1146
+ const kv = donorKvOut.get(d)!;
1147
+ if (kv.kind !== "plain") throw new Error("segmented e4b orpo: quantized donor KV unsupported in training");
1148
+ st.donorBnd.set(d, { keys: detachLeaf(kv.keys), values: detachLeaf(kv.values) });
1149
+ }
1150
+ for (const [d, kv] of donorKvOut)
1151
+ if (!produces[k]!.includes(d) && kv.kind === "plain") { kv.keys.dispose(); kv.values.dispose(); }
1152
+ st.boundaries.push(detachLeaf(h));
1153
+ }
1154
+ } finally {
1155
+ inputIds.dispose();
1156
+ }
1157
+ }
1158
+
1159
+ /** Backward one branch in reverse over segments. Takes ownership of `dhSeed`
1160
+ * (the head's cotangent for this branch's last boundary) and fills `grads`
1161
+ * (length 2n) in place with the branch's LoRA gradients. Reused-donor
1162
+ * cotangents are accumulated in a per-branch dKV (bf16, as in
1163
+ * SegmentedBackwardGemma4). Sets curMasks/curPerLayer to this branch for the
1164
+ * duration so the reused seg vjps see the right constants. */
1165
+ private backwardBranch(st: BranchState, dhSeed: MlxArray, grads: (MlxArray | null)[]): void {
1166
+ const { lora, ranges, segIdxs, consumes, produces, n } = this;
1167
+ this.curMasks = st.masks;
1168
+ this.curPerLayer = st.perLayer;
1169
+ const dKV = new Map<number, KvPair>();
1170
+ let dhOut: MlxArray | null = dhSeed;
1171
+ try {
1172
+ for (let k = ranges.length - 1; k >= 0; k--) {
1173
+ const idxs = segIdxs[k]!;
1174
+ const cons = consumes[k]!;
1175
+ const prod = produces[k]!;
1176
+ const dh = dhOut!;
1177
+
1178
+ const primals: MlxArray[] = [st.boundaries[k]!];
1179
+ for (const d of cons) { const b = st.donorBnd.get(d)!; primals.push(b.keys, b.values); }
1180
+ for (const j of idxs) primals.push(lora.targets[j]!.lw.a, lora.targets[j]!.lw.b);
1181
+
1182
+ const cots: MlxArray[] = [dh];
1183
+ for (const d of prod) { const acc = dKV.get(d)!; cots.push(acc.keys, acc.values); }
1184
+
1185
+ const res = this.segVjps[k]!.apply(primals, cots);
1186
+ for (const o of res.outputs) o.dispose();
1187
+
1188
+ const newDh = detachLeaf(res.vjps[0]!);
1189
+ cons.forEach((d, ci) => {
1190
+ const dk = detachLeaf(res.vjps[1 + 2 * ci]!);
1191
+ const dv = detachLeaf(res.vjps[1 + 2 * ci + 1]!);
1192
+ this.accumulateDKV(dKV, d, dk, dv);
1193
+ });
1194
+ const base = 1 + 2 * cons.length;
1195
+ idxs.forEach((j, s) => {
1196
+ grads[j] = detachLeaf(res.vjps[base + 2 * s]!);
1197
+ grads[n + j] = detachLeaf(res.vjps[base + 2 * s + 1]!);
1198
+ });
1199
+
1200
+ dh.dispose();
1201
+ dhOut = newDh;
1202
+ for (const d of prod) {
1203
+ const acc = dKV.get(d)!; acc.keys.dispose(); acc.values.dispose(); dKV.delete(d);
1204
+ const b = st.donorBnd.get(d)!; b.keys.dispose(); b.values.dispose(); st.donorBnd.delete(d);
1205
+ }
1206
+ st.boundaries[k]!.dispose();
1207
+ st.boundaries[k] = null;
1208
+ }
1209
+ } finally {
1210
+ dhOut?.dispose(); // segment-0 dh_in (embed / per-layer grad) — no LoRA there
1211
+ for (const kv of dKV.values()) { kv.keys.dispose(); kv.values.dispose(); }
1212
+ this.curMasks = null;
1213
+ this.curPerLayer = null;
1214
+ }
1215
+ }
1216
+
1217
+ /** Accumulate a reused-donor K/V cotangent (sum across consuming segments) —
1218
+ * identical to SegmentedBackwardGemma4 (bf16, non-associative ~0.5% class). */
1219
+ private accumulateDKV(dKV: Map<number, KvPair>, d: number, dk: MlxArray, dv: MlxArray): void {
1220
+ const prev = dKV.get(d);
1221
+ if (!prev) { dKV.set(d, { keys: dk, values: dv }); return; }
1222
+ const sk = detachLeaf(ops.add(prev.keys, dk));
1223
+ const sv = detachLeaf(ops.add(prev.values, dv));
1224
+ prev.keys.dispose(); prev.values.dispose(); dk.dispose(); dv.dispose();
1225
+ dKV.set(d, { keys: sk, values: sv });
1226
+ }
1227
+
1228
+ private disposeBranch(st: BranchState | null): void {
1229
+ if (!st) return;
1230
+ for (const b of st.boundaries) b?.dispose();
1231
+ for (const kv of st.donorBnd.values()) { kv.keys.dispose(); kv.values.dispose(); }
1232
+ st.perLayer?.dispose();
1233
+ if (st.masks) for (const m of st.masks.values()) m.arr?.dispose();
1234
+ }
1235
+
1236
+ step(batch: DpoBatch): { value: MlxArray; grads: MlxArray[] } {
1237
+ if (this.disposed) throw new Error("SegmentedBackwardOrpoGemma4 used after dispose");
1238
+ const { n } = this;
1239
+ if (batch.chosenIds.length !== 1) throw new Error("SegmentedBackwardOrpoGemma4: only B=1 is supported");
1240
+ this.curChosenIds = batch.chosenIds[0]!;
1241
+ this.curChosenMask = batch.chosenMask[0]!;
1242
+ this.curRejectedIds = batch.rejectedIds[0]!;
1243
+ this.curRejectedMask = batch.rejectedMask[0]!;
1244
+ const Lc = batch.chosenIds[0]!.length;
1245
+ const Lr = batch.rejectedIds[0]!.length;
1246
+ if (Lc < 2 || Lr < 2) throw new Error("SegmentedBackwardOrpoGemma4: sequences too short");
1247
+
1248
+ const nSeg = this.ranges.length;
1249
+ const stC: BranchState = { boundaries: [], donorBnd: new Map(), masks: null, perLayer: null };
1250
+ const stR: BranchState = { boundaries: [], donorBnd: new Map(), masks: null, perLayer: null };
1251
+ const gradsC: (MlxArray | null)[] = new Array(2 * n).fill(null);
1252
+ const gradsR: (MlxArray | null)[] = new Array(2 * n).fill(null);
1253
+ let value: MlxArray | null = null;
1254
+ let dhC: MlxArray | null = null;
1255
+ let dhR: MlxArray | null = null;
1256
+ let transferred = false;
1257
+
1258
+ try {
1259
+ // --- 1. Forward both branches (boundaries + donor K/V + masks/perLayer). ---
1260
+ this.forwardBranch(batch.chosenIds[0]!, stC);
1261
+ this.forwardBranch(batch.rejectedIds[0]!, stR);
1262
+
1263
+ // --- 2. ORPO head over the two last boundaries (cotangent 1.0). The fused
1264
+ // head pushes per-step CustomVjps + slices into headSink; dispose only after
1265
+ // outputs/vjps are detached to leaves (which forces the head eval). ---
1266
+ this.headSink = [];
1267
+ const head = this.headVjp.apply([stC.boundaries[nSeg]!, stR.boundaries[nSeg]!], [this.one]);
1268
+ // Materialize the full head-VJP graph (incl. the nested CustomVjp backward that
1269
+ // reads savedLse/savedBlockMax) BEFORE freeing headSink — avoids the lazy-eval
1270
+ // use-after-free segfault (see the segmented-prefix crash diagnosis).
1271
+ ops.evalAll([head.outputs[0]!, head.vjps[0]!, head.vjps[1]!]);
1272
+ value = detachLeaf(head.outputs[0]!);
1273
+ dhC = detachLeaf(head.vjps[0]!);
1274
+ dhR = detachLeaf(head.vjps[1]!);
1275
+ for (const d of this.headSink) d.dispose();
1276
+ this.headSink = [];
1277
+ stC.boundaries[nSeg]!.dispose(); stC.boundaries[nSeg] = null;
1278
+ stR.boundaries[nSeg]!.dispose(); stR.boundaries[nSeg] = null;
1279
+
1280
+ // --- 3. Backward each branch (ownership of dh transfers to backwardBranch). ---
1281
+ const seedC = dhC; dhC = null;
1282
+ this.backwardBranch(stC, seedC, gradsC);
1283
+ const seedR = dhR; dhR = null;
1284
+ this.backwardBranch(stR, seedR, gradsR);
1285
+
1286
+ // --- 4. Sum LoRA grads from both branches. ---
1287
+ const grads: MlxArray[] = gradsC.map((gc, i) => {
1288
+ const gr = gradsR[i]!;
1289
+ const sum = ops.add(gc!, gr);
1290
+ gc!.dispose(); gr.dispose();
1291
+ gradsC[i] = null; gradsR[i] = null;
1292
+ return detachLeaf(sum);
1293
+ });
1294
+
1295
+ transferred = true;
1296
+ return { value: value!, grads };
1297
+ } finally {
1298
+ this.curChosenIds = this.curChosenMask = this.curRejectedIds = this.curRejectedMask = null;
1299
+ dhC?.dispose(); dhR?.dispose();
1300
+ this.disposeBranch(stC);
1301
+ this.disposeBranch(stR);
1302
+ if (!transferred) {
1303
+ value?.dispose();
1304
+ for (const g of gradsC) g?.dispose();
1305
+ for (const g of gradsR) g?.dispose();
1306
+ }
1307
+ }
1308
+ }
1309
+
1310
+ dispose(): void {
1311
+ if (this.disposed) return;
1312
+ this.disposed = true;
1313
+ this.headVjp.dispose();
1314
+ for (const v of this.segVjps) v.dispose();
1315
+ for (const c of this.caches) c.dispose();
1316
+ this.one.dispose();
1317
+ }
1318
+ }
1319
+
1320
+ // ===========================================================================
1321
+ // SegmentedBackwardOrpoPrefixGemma4: segmented backward + PREFIX-SHARING on e4b.
1322
+ // ===========================================================================
1323
+ //
1324
+ // The e4b (Gemma4) analogue of SegmentedBackwardOrpoPrefix (MiniCPM5): the
1325
+ // composition of SegmentedBackwardOrpoGemma4's SEGMENT machinery (donor-KV
1326
+ // threading across segment boundaries, per-layer-input boundary, sliding-window
1327
+ // vs full-attention per-layer-type masks) with prefix-sharing (one forward over
1328
+ // the single concat [prompt; chosen; rejected]).
1329
+ //
1330
+ // It is SegmentedBackwardOrpoGemma4 reduced to ONE stream (not two branches),
1331
+ // with three substitutions — exactly the MiniCPM5 prefix recipe lifted to e4b:
1332
+ // - the streamed sequence is the single concat [prompt(P); chosen(Rc); rejected(Rr)]
1333
+ // (T = P+Rc+Rr), so one boundary stream, one backward walk, one donor-KV thread.
1334
+ // - the per-(donor-)layer caches are Gemma4PrefixSharedSegCache (a LOCAL copy of
1335
+ // the private Gemma4PrefixSharedCache in prefix-shared.ts): makeMask(T, window)
1336
+ // returns the LOGICAL-position block-sparse + sliding-window prefix mask.
1337
+ // makeTrainingMasks(caches,T) builds one such mask per layer-type (sliding gets
1338
+ // the window AND; full gets the plain block-sparse causal), exactly like
1339
+ // orpoLossPrefixSharedGemma's forward.
1340
+ // - setGemmaPrefixPlan({P,Rc,Rr}) is ACTIVE for the whole step (set in stepPrefix,
1341
+ // cleared in finally) so block-wise RoPE rides through BOTH the streaming forward
1342
+ // AND every segment's mlx_vjp recompute, through donor AND sharer layers
1343
+ // identically to the non-segmented orpoLossPrefixSharedGemma.
1344
+ // - the head gathers the chosen/rejected response hiddens from the final
1345
+ // [1,T,hidden] (branchLogpMeanGathered — the [M,V]-free per-branch flash head)
1346
+ // and ORPO-combines; its vjp w.r.t. the last boundary is the full [1,T,hidden] dh
1347
+ // (the gather's takeAxis vjp scatter-adds each branch's response-position
1348
+ // cotangent back into the full sequence), seeding the single segmented backward.
1349
+ //
1350
+ // The caches count = model.numDonors (like orpoLossPrefixSharedGemma), NOT
1351
+ // model.layers.length: sharers reuse the donors' fetched K/V. The donor
1352
+ // consumes/produces topology is architecture+segmentation only (data-independent),
1353
+ // computed ONCE — identical to SegmentedBackwardOrpoGemma4. All the hard-won
1354
+ // lessons hold: mlx_vjp per segment (NOT surrogate value_and_grad), detachLeaf at
1355
+ // boundaries, reused vjp objects, head disposables into a per-step sink, the
1356
+ // CONTIGUOUS-donor-K/V layout matching between producer output and consumer
1357
+ // cotangent, and the bf16-class accumulateDKV cross-segment donor cotangent sum.
1358
+
1359
+ /** Pass-through cache (offset 0) for the e4b prefix-shared SEGMENTED forward whose
1360
+ * makeMask returns the block-sparse + logical-window mask. A LOCAL copy of the
1361
+ * (private) Gemma4PrefixSharedCache in prefix-shared.ts — replicated here rather
1362
+ * than exporting it (file-ownership boundary). makeTrainingMasks calls
1363
+ * makeMask(T, window) with window = the model's sliding window for sliding layers
1364
+ * and null for full layers; the window arg selects the sliding term. One per DONOR
1365
+ * layer (sharers reuse donors' fetched KV inside runLayerRange). */
1366
+ class Gemma4PrefixSharedSegCache implements Cache {
1367
+ offset = 0;
1368
+ constructor(private readonly P: number, private readonly Rc: number, private readonly Rr: number) {}
1369
+ updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
1370
+ return [k.slice([0, 0, 0, 0], k.shape), v.slice([0, 0, 0, 0], v.shape)];
1371
+ }
1372
+ makeMask(N: number, windowSize: number | null): Mask {
1373
+ if (N !== this.P + this.Rc + this.Rr)
1374
+ throw new Error(`Gemma4PrefixSharedSegCache: N=${N} != P+Rc+Rr=${this.P + this.Rc + this.Rr}`);
1375
+ return { mode: "array", arr: blockSparsePrefixMaskGemma(this.P, this.Rc, this.Rr, windowSize) };
1376
+ }
1377
+ state(): MlxArray[] { return []; }
1378
+ isTrimmable(): boolean { return true; }
1379
+ trim(_n: number): void { /* offset pinned at 0 */ }
1380
+ dispose(): void { /* owns no arrays */ }
1381
+ }
1382
+
1383
+ export class SegmentedBackwardOrpoPrefixGemma4 {
1384
+ private readonly nLayers: number;
1385
+ private readonly n: number;
1386
+ private readonly segIdxs: number[][];
1387
+ private readonly consumes: number[][];
1388
+ private readonly produces: number[][];
1389
+ private readonly caches: Cache[]; // numDonors TrainingCache (the running KV the seg vjps fetch into)
1390
+ private readonly headVjp: Vjp;
1391
+ private readonly segVjps: Vjp[];
1392
+ private readonly one: MlxArray;
1393
+ // Per-step mutable context the (reused) closures read.
1394
+ private curP = 0;
1395
+ private curRc = 0;
1396
+ private curRr = 0;
1397
+ private curChosenResp: number[] | null = null;
1398
+ private curRejectedResp: number[] | null = null;
1399
+ private curChunk: ChunkCtx | undefined = undefined;
1400
+ private curMasks: Map<string, Mask> | null = null; // prefix masks for this step
1401
+ private curPerLayer: MlxArray | null = null;
1402
+ private headSink: Array<{ dispose(): void }> = [];
1403
+ private disposed = false;
1404
+
1405
+ constructor(
1406
+ private readonly model: Gemma4Model,
1407
+ private readonly lora: TrainableLora,
1408
+ private readonly ranges: [number, number][],
1409
+ private readonly lambda: number,
1410
+ private readonly chunkCtx?: ChunkCtx,
1411
+ ) {
1412
+ this.nLayers = model.layers.length;
1413
+ const lastHi = ranges[ranges.length - 1]?.[1] ?? 0;
1414
+ if (ranges[0]?.[0] !== 0 || lastHi !== this.nLayers)
1415
+ throw new Error(`SegmentedBackwardOrpoPrefixGemma4: ranges must cover [0, ${this.nLayers})`);
1416
+ this.n = lora.targets.length;
1417
+
1418
+ const layerOf = lora.targets.map((t) => targetLayer(t.modulePath));
1419
+ this.segIdxs = ranges.map(([lo, hi]) =>
1420
+ layerOf.map((li, j) => ({ li, j })).filter((x) => x.li >= lo && x.li < hi).map((x) => x.j),
1421
+ );
1422
+
1423
+ // Donor/sharer KV threading topology (identical to SegmentedBackwardGemma4 /
1424
+ // SegmentedBackwardOrpoGemma4): architecture + segmentation only — independent
1425
+ // of the data AND of prefix-sharing (the donor structure is the same concat).
1426
+ const segOfLayer = new Map<number, number>();
1427
+ ranges.forEach(([lo, hi], k) => { for (let i = lo; i < hi; i++) segOfLayer.set(i, k); });
1428
+ const reused = model.reusedDonors;
1429
+ const producerSeg = new Map<number, number>();
1430
+ for (const d of reused) producerSeg.set(d, segOfLayer.get(d)!);
1431
+ this.consumes = ranges.map(() => []);
1432
+ this.produces = ranges.map(() => []);
1433
+ for (let i = 0; i < this.nLayers; i++) {
1434
+ if (model.cacheIndex[i] !== -1) continue;
1435
+ const d = model.previousKvs[i]!;
1436
+ const sSeg = segOfLayer.get(i)!;
1437
+ const pSeg = producerSeg.get(d)!;
1438
+ if (sSeg > pSeg) {
1439
+ if (!this.consumes[sSeg]!.includes(d)) this.consumes[sSeg]!.push(d);
1440
+ if (!this.produces[pSeg]!.includes(d)) this.produces[pSeg]!.push(d);
1441
+ }
1442
+ }
1443
+
1444
+ this.caches = Array.from({ length: model.numDonors }, () => new TrainingCache());
1445
+ this.one = MlxArray.fromFloat32(new Float32Array([1]), []);
1446
+
1447
+ // Head VJP: input = the last boundary [1, T, hidden] (pre-finalNorm). Applies
1448
+ // finalNorm, gathers each branch's response hiddens (prefixGatherIdx layout),
1449
+ // computes ℓw/ℓr through the [M,V]-free per-branch head (branchLogpMeanGathered),
1450
+ // returns the scalar orpo loss. nOut = 1; apply([h_last],[one]) returns
1451
+ // vjps[0] = dh [1,T,hidden] (the gather's takeAxis vjp scatter-adds the
1452
+ // per-branch dh into the full sequence) — same as the MiniCPM5 prefix head.
1453
+ this.headVjp = new Vjp((p) => {
1454
+ const hn = model.finalNorm.forward(p[0]!); // [1, T, hidden]
1455
+ const { chosenIdx, rejectedIdx } = prefixGatherIdx(this.curP, this.curRc, this.curRr);
1456
+ const lw = branchLogpMeanGathered(model, hn, chosenIdx, this.curChosenResp!, this.curChunk);
1457
+ const lr = branchLogpMeanGathered(model, hn, rejectedIdx, this.curRejectedResp!, this.curChunk);
1458
+ hn.dispose();
1459
+ const loss = orpoLossFromLogps(lw, lr, this.lambda);
1460
+ lw.dispose(); lr.dispose();
1461
+ return [loss];
1462
+ }, 1);
1463
+
1464
+ // One reused vjp per segment — same SHAPE as SegmentedBackwardOrpoGemma4's
1465
+ // (Inputs: [boundary, (k,v)*consumes, (a,b)*lora]; Outputs: [h, (k,v)*produces]),
1466
+ // reading the active step's PREFIX masks/perLayer off curMasks/curPerLayer. The
1467
+ // prefix RoPE plan is active for the whole step (set in stepPrefix), so the
1468
+ // recompute here ropes block-wise too. (branchLogpMeanGathered handles the head;
1469
+ // these only stream the transformer layers + thread the donor KV.)
1470
+ this.segVjps = ranges.map(([lo, hi], k) => {
1471
+ const idxs = this.segIdxs[k]!;
1472
+ const cons = this.consumes[k]!;
1473
+ const prod = this.produces[k]!;
1474
+ const base = 1 + 2 * cons.length;
1475
+ const nOut = 1 + 2 * prod.length;
1476
+ return new Vjp((p) => {
1477
+ const boundary = p[0]!;
1478
+ const kvIn = new Map<number, SharedKv>();
1479
+ cons.forEach((d, ci) => kvIn.set(d, { kind: "plain", keys: p[1 + 2 * ci]!, values: p[1 + 2 * ci + 1]!, offset: 0 }));
1480
+ const saved: [MlxArray, MlxArray][] = idxs.map((j) => [lora.targets[j]!.lw.a, lora.targets[j]!.lw.b]);
1481
+ idxs.forEach((j, s) => {
1482
+ lora.targets[j]!.lw.a = p[base + 2 * s]!;
1483
+ lora.targets[j]!.lw.b = p[base + 2 * s + 1]!;
1484
+ });
1485
+ try {
1486
+ const { h, donorKvOut } = model.runLayerRange(
1487
+ boundary, lo, hi, this.caches, this.curMasks!, this.curPerLayer, kvIn,
1488
+ );
1489
+ const outs: MlxArray[] = [h];
1490
+ for (const d of prod) {
1491
+ const kv = donorKvOut.get(d)!;
1492
+ if (kv.kind !== "plain") throw new Error("segmented e4b prefix: quantized donor KV unsupported in training");
1493
+ const ck = ops.contiguous(kv.keys), cv = ops.contiguous(kv.values);
1494
+ kv.keys.dispose(); kv.values.dispose();
1495
+ outs.push(ck, cv);
1496
+ }
1497
+ for (const [d, kv] of donorKvOut)
1498
+ if (!prod.includes(d) && kv.kind === "plain") { kv.keys.dispose(); kv.values.dispose(); }
1499
+ return outs;
1500
+ } finally {
1501
+ idxs.forEach((j, s) => { lora.targets[j]!.lw.a = saved[s]![0]; lora.targets[j]!.lw.b = saved[s]![1]; });
1502
+ }
1503
+ }, nOut);
1504
+ });
1505
+ }
1506
+
1507
+ /** Accumulate a reused-donor K/V cotangent (sum across consuming segments) —
1508
+ * identical to SegmentedBackwardGemma4 (bf16, non-associative ~0.5% class). */
1509
+ private accumulateDKV(dKV: Map<number, KvPair>, d: number, dk: MlxArray, dv: MlxArray): void {
1510
+ const prev = dKV.get(d);
1511
+ if (!prev) { dKV.set(d, { keys: dk, values: dv }); return; }
1512
+ const sk = detachLeaf(ops.add(prev.keys, dk));
1513
+ const sv = detachLeaf(ops.add(prev.values, dv));
1514
+ prev.keys.dispose(); prev.values.dispose(); dk.dispose(); dv.dispose();
1515
+ dKV.set(d, { keys: sk, values: sv });
1516
+ }
1517
+
1518
+ /** One ORPO step over the prefix-shared concat. `promptIds`/`chosenResp`/
1519
+ * `rejectedResp` come from splitPrefixBatch. Returns the loss + LoRA grads in
1520
+ * flatParams(lora) order (caller owns). Mirrors SegmentedBackwardOrpoPrefix.stepPrefix. */
1521
+ stepPrefix(promptIds: number[], chosenResp: number[], rejectedResp: number[]): { value: MlxArray; grads: MlxArray[] } {
1522
+ if (this.disposed) throw new Error("SegmentedBackwardOrpoPrefixGemma4 used after dispose");
1523
+ const { model, lora, ranges, caches, segIdxs, consumes, produces, n } = this;
1524
+ const P = promptIds.length, Rc = chosenResp.length, Rr = rejectedResp.length;
1525
+ if (P < 1 || Rc < 1 || Rr < 1) throw new Error("SegmentedBackwardOrpoPrefixGemma4: need P,Rc,Rr >= 1");
1526
+ const T = P + Rc + Rr;
1527
+ this.curP = P; this.curRc = Rc; this.curRr = Rr;
1528
+ this.curChosenResp = chosenResp; this.curRejectedResp = rejectedResp;
1529
+ this.headSink = [];
1530
+ this.curChunk = this.chunkCtx ? { ...this.chunkCtx, sink: this.headSink } : undefined;
1531
+
1532
+ const nSeg = ranges.length;
1533
+ const concat = new Int32Array(T);
1534
+ concat.set(promptIds, 0);
1535
+ concat.set(chosenResp, P);
1536
+ concat.set(rejectedResp, P + Rc);
1537
+ const inputIds = MlxArray.fromInt32(concat, [1, T]);
1538
+
1539
+ // Per-step prefix caches (one per DONOR layer): makeMask -> block-sparse +
1540
+ // logical-window mask. makeTrainingMasks calls makeMask(T, window) per layer-type.
1541
+ const prefixCaches: Cache[] = Array.from({ length: model.numDonors }, () => new Gemma4PrefixSharedSegCache(P, Rc, Rr));
1542
+
1543
+ const boundaries: (MlxArray | null)[] = [];
1544
+ const donorBnd = new Map<number, KvPair>(); // detached reused-donor K/V boundaries
1545
+ const dKV = new Map<number, KvPair>(); // accumulated reused-donor cotangents
1546
+ let value: MlxArray | null = null;
1547
+ let dhOut: MlxArray | null = null;
1548
+ let transferred = false;
1549
+ const grads: (MlxArray | null)[] = new Array(2 * n).fill(null);
1550
+
1551
+ // Block-wise RoPE active for the WHOLE step (forward + every segment recompute).
1552
+ setGemmaPrefixPlan({ P, Rc, Rr });
1553
+ try {
1554
+ // --- 1. Forward over the concat, saving detached boundaries (hidden + donor K/V). ---
1555
+ const { hScaled, perLayer } = model.embedForSegmented(inputIds);
1556
+ boundaries.push(detachLeaf(hScaled));
1557
+ this.curPerLayer = perLayer ? detachLeaf(perLayer) : null;
1558
+ // Prefix masks (block-sparse + logical-window) for this step, built from the
1559
+ // prefix caches (NOT model.makeTrainingMasks' offset-0 causal masks).
1560
+ this.curMasks = model.makeTrainingMasks(prefixCaches, T);
1561
+
1562
+ for (let k = 0; k < nSeg; k++) {
1563
+ const [lo, hi] = ranges[k]!;
1564
+ const kvIn = new Map<number, SharedKv>();
1565
+ for (const d of consumes[k]!) {
1566
+ const b = donorBnd.get(d)!;
1567
+ kvIn.set(d, { kind: "plain", keys: b.keys, values: b.values, offset: 0 });
1568
+ }
1569
+ const { h, donorKvOut } = model.runLayerRange(boundaries[k]!, lo, hi, caches, this.curMasks, this.curPerLayer, kvIn);
1570
+ for (const d of produces[k]!) {
1571
+ const kv = donorKvOut.get(d)!;
1572
+ if (kv.kind !== "plain") throw new Error("segmented e4b prefix: quantized donor KV unsupported in training");
1573
+ donorBnd.set(d, { keys: detachLeaf(kv.keys), values: detachLeaf(kv.values) });
1574
+ }
1575
+ for (const [d, kv] of donorKvOut)
1576
+ if (!produces[k]!.includes(d) && kv.kind === "plain") { kv.keys.dispose(); kv.values.dispose(); }
1577
+ boundaries.push(detachLeaf(h));
1578
+ }
1579
+
1580
+ // --- 2. Head over the last boundary (vjp, cotangent 1.0) -> [dh 1,T,hidden]. ---
1581
+ const head = this.headVjp.apply([boundaries[nSeg]!], [this.one]);
1582
+ // Force the FULL head-VJP graph (incl. the nested flash-CCE CustomVjp backward,
1583
+ // which reads savedLse/savedBlockMax) to materialize BEFORE the headSink dispose
1584
+ // below — else that deferred backward can run AFTER its lse/blockMax are freed
1585
+ // under MLX lazy eval (a use-after-free → segfault at a tiny address, ~step 100).
1586
+ ops.evalAll([head.outputs[0]!, head.vjps[0]!]);
1587
+ value = detachLeaf(head.outputs[0]!);
1588
+ dhOut = detachLeaf(head.vjps[0]!); // dLoss / d(last residual stream), [1,T,hidden]
1589
+ for (const d of this.headSink) d.dispose();
1590
+ this.headSink = [];
1591
+ boundaries[nSeg]!.dispose(); boundaries[nSeg] = null;
1592
+
1593
+ // --- 3. Backward, reverse over segments (single stream; cotangents dh + dKV). ---
1594
+ for (let k = nSeg - 1; k >= 0; k--) {
1595
+ const idxs = segIdxs[k]!;
1596
+ const cons = consumes[k]!;
1597
+ const prod = produces[k]!;
1598
+ const dh = dhOut!;
1599
+
1600
+ const primals: MlxArray[] = [boundaries[k]!];
1601
+ for (const d of cons) { const b = donorBnd.get(d)!; primals.push(b.keys, b.values); }
1602
+ for (const j of idxs) primals.push(lora.targets[j]!.lw.a, lora.targets[j]!.lw.b);
1603
+
1604
+ const cots: MlxArray[] = [dh];
1605
+ for (const d of prod) { const acc = dKV.get(d)!; cots.push(acc.keys, acc.values); }
1606
+
1607
+ const res = this.segVjps[k]!.apply(primals, cots);
1608
+ for (const o of res.outputs) o.dispose();
1609
+
1610
+ const newDh = detachLeaf(res.vjps[0]!);
1611
+ cons.forEach((d, ci) => {
1612
+ const dk = detachLeaf(res.vjps[1 + 2 * ci]!);
1613
+ const dv = detachLeaf(res.vjps[1 + 2 * ci + 1]!);
1614
+ this.accumulateDKV(dKV, d, dk, dv);
1615
+ });
1616
+ const base = 1 + 2 * cons.length;
1617
+ idxs.forEach((j, s) => {
1618
+ grads[j] = detachLeaf(res.vjps[base + 2 * s]!);
1619
+ grads[n + j] = detachLeaf(res.vjps[base + 2 * s + 1]!);
1620
+ });
1621
+
1622
+ dh.dispose();
1623
+ dhOut = newDh;
1624
+ for (const d of prod) {
1625
+ const acc = dKV.get(d)!; acc.keys.dispose(); acc.values.dispose(); dKV.delete(d);
1626
+ const b = donorBnd.get(d)!; b.keys.dispose(); b.values.dispose(); donorBnd.delete(d);
1627
+ }
1628
+ boundaries[k]!.dispose(); boundaries[k] = null;
1629
+ }
1630
+ // Segment-0 dh_in = grad w.r.t. the scaled embedding / per-layer-input (no LoRA
1631
+ // target there: per_layer_input_gate/projection are differentiated INSIDE the
1632
+ // segments) — dropped (disposed in finally).
1633
+
1634
+ transferred = true;
1635
+ return { value: value!, grads: grads as MlxArray[] };
1636
+ } finally {
1637
+ setGemmaPrefixPlan(null);
1638
+ inputIds.dispose();
1639
+ for (const c of prefixCaches) c.dispose();
1640
+ this.curPerLayer?.dispose();
1641
+ this.curPerLayer = null;
1642
+ if (this.curMasks) { for (const m of this.curMasks.values()) m.arr?.dispose(); this.curMasks = null; }
1643
+ this.curChosenResp = this.curRejectedResp = null;
1644
+ for (const d of this.headSink) d.dispose();
1645
+ this.headSink = [];
1646
+ dhOut?.dispose();
1647
+ for (const b of boundaries) b?.dispose();
1648
+ for (const kv of donorBnd.values()) { kv.keys.dispose(); kv.values.dispose(); }
1649
+ for (const kv of dKV.values()) { kv.keys.dispose(); kv.values.dispose(); }
1650
+ if (!transferred) {
1651
+ value?.dispose();
1652
+ for (const g of grads) g?.dispose();
1653
+ }
1654
+ }
1655
+ }
1656
+
1657
+ dispose(): void {
1658
+ if (this.disposed) return;
1659
+ this.disposed = true;
1660
+ this.headVjp.dispose();
1661
+ for (const v of this.segVjps) v.dispose();
1662
+ for (const c of this.caches) c.dispose();
1663
+ this.one.dispose();
1664
+ }
1665
+ }