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.
- package/README.md +166 -93
- package/docs/design/adapters-end-to-end.md +50 -46
- package/docs/design/compat-cli-surface-design.md +10 -8
- package/docs/design/docs-reorg-plan.md +14 -1
- package/docs/design/hlg-sampling.md +12 -2
- package/docs/design/memory-system.md +1 -1
- package/docs/design/optimization_plan.md +4 -2
- package/docs/design/orpo-future-enhancements.md +298 -0
- package/docs/design/orpo-training.md +1199 -0
- package/docs/design/parallel-slots.md +34 -26
- package/docs/design/runtime-isolation.md +1 -1
- package/docs/design/segmented-backward-training.md +58 -39
- package/docs/design/tauri-desktop-app.md +127 -0
- package/docs/{segmented-backward-handoff.md → investigations/segmented-backward-handoff.md} +1 -1
- package/docs/investigations/starter-model-port-handoff.md +32 -13
- package/docs/investigations/steel-flash-cce-handoff.md +431 -0
- package/docs/planning/IDEAS.md +29 -0
- package/docs/planning/PRODUCT_ROADMAP.md +147 -27
- package/docs/planning/chunk-task-roadmap.md +1 -1
- package/docs/planning/journal.md +161 -0
- package/docs/reference/distribution.md +61 -5
- package/docs/reference/library-api.md +29 -14
- package/docs/reference/orpo-quickstart.md +91 -0
- package/docs/reference/server-api.md +105 -7
- package/docs/reference/server-config.md +12 -5
- package/docs/reference/training.md +292 -0
- package/package.json +1 -1
- package/src/eval/ifeval.ts +138 -0
- package/src/eval/ultrafeedback.ts +60 -0
- package/src/lora.ts +12 -7
- package/src/mlx/metal-kernel.ts +18 -4
- package/src/model/flash-attention.ts +32 -16
- package/src/model/fused-geglu-kernel.ts +111 -0
- package/src/model/gemma4-base.ts +39 -1
- package/src/model/gemma4.ts +196 -21
- package/src/model/minicpm5.ts +44 -3
- package/src/server.ts +3 -3
- package/src/train/dataset.ts +18 -3
- package/src/train/flash-cce.ts +1162 -0
- package/src/train/job.ts +26 -2
- package/src/train/lora-params.ts +53 -4
- package/src/train/loss.ts +941 -0
- package/src/train/optimizer.ts +10 -3
- package/src/train/prefix-shared.ts +435 -0
- package/src/train/rank.ts +36 -0
- package/src/train/segmented.ts +1148 -3
- package/src/train/steel-qmm-header.ts +1461 -0
- package/src/train/trainer.ts +509 -52
- package/src/web/app.html +21 -5
- /package/{docs → src/assets}/curve-designer.html +0 -0
package/src/train/segmented.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|