mlx-bun 0.0.9 → 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/design/batching-perf-path.md +232 -0
- package/docs/design/omlx-adoption-map.md +90 -0
- package/docs/design/segmented-backward-training.md +15 -0
- package/docs/design/ssd-kv-cold-tier.md +158 -0
- package/docs/investigations/decode-roofline-lookagain.md +92 -6
- package/docs/investigations/kernel-perf-review-2026-07.md +105 -26
- package/docs/planning/release-notes-v0.0.10.md +57 -0
- package/docs/reference/server-config.md +3 -0
- package/docs/reference/training.md +6 -1
- package/package.json +1 -1
- package/src/cli.ts +49 -5
- package/src/generate.ts +8 -0
- package/src/kv-store.ts +240 -98
- package/src/mlx/array.ts +21 -0
- package/src/mlx/ops.ts +26 -0
- package/src/model/gemma4-base.ts +34 -1
- package/src/model/qwen3-delta.ts +48 -1
- package/src/model/qwen3_5.ts +11 -2
- package/src/prompt-cache.ts +55 -9
- package/src/registry.ts +4 -1
- package/src/serve/batch-scheduler.ts +41 -9
- package/src/serve/generation-gateway.ts +78 -19
- package/src/server.ts +135 -25
- package/src/ssd-cache.ts +213 -0
- package/src/train/flash-cce.ts +27 -24
- package/src/train/loss.ts +53 -11
- package/src/train/prefix-shared.ts +30 -7
- package/src/train/segmented.ts +150 -46
- package/src/train/trainer.ts +21 -11
package/src/train/segmented.ts
CHANGED
|
@@ -36,18 +36,31 @@
|
|
|
36
36
|
|
|
37
37
|
import { MlxArray } from "../mlx/array";
|
|
38
38
|
import * as ops from "../mlx/ops";
|
|
39
|
+
import { peakMemory } from "../mlx/ffi";
|
|
39
40
|
import { Vjp } from "../mlx/autograd";
|
|
41
|
+
|
|
42
|
+
// MLX_BUN_SEG_MEM_LOG=1: log the within-step peak after each phase of the
|
|
43
|
+
// segmented step (forward+boundaries / head vjp / each segment backward).
|
|
44
|
+
// The trainer resets the peak at step start (MLX_BUN_MEM_LOG=1), so the phase
|
|
45
|
+
// where the monotone peak JUMPS is where the step's memory actually lives.
|
|
46
|
+
// (This is how the backlog-#2 refutation below was measured. An inter-segment
|
|
47
|
+
// clearCache was also tried and had NO effect — the watermark is live memory,
|
|
48
|
+
// not allocator cache retention.)
|
|
49
|
+
const SEG_MEM = process.env.MLX_BUN_SEG_MEM_LOG === "1";
|
|
50
|
+
const segMem = (label: string): void => {
|
|
51
|
+
if (SEG_MEM) console.log(` [seg-mem] ${label}: peak=${(peakMemory() / 1e9).toFixed(2)} GB`);
|
|
52
|
+
};
|
|
40
53
|
import { MiniCPM5Model, setMiniCpmPrefixPlan } from "../model/minicpm5";
|
|
41
54
|
import type { Gemma4Model } from "../model/gemma4";
|
|
42
55
|
import { setGemmaPrefixPlan } from "../model/gemma4";
|
|
43
56
|
import type { Cache, Mask, SharedKv } from "../model/gemma4-base";
|
|
44
57
|
import { TrainingCache } from "./forward";
|
|
45
58
|
import {
|
|
46
|
-
|
|
59
|
+
responseOnlyLogpMean, fusedLogpMeanFromHidden, chunkedLogpMeanFromHidden,
|
|
47
60
|
orpoLossFromLogps, spanLogpMeanFromHidden, respSpanFromMask, combineFullNll,
|
|
48
61
|
type ChunkCtx, type SftScope, type HeadSpan,
|
|
49
62
|
} from "./loss";
|
|
50
|
-
import {
|
|
63
|
+
import { prefixSharedCaches, prefixGatherIdx, branchLogpMeanGathered, blockSparsePrefixMaskGemma, prefixFullNll } from "./prefix-shared";
|
|
51
64
|
|
|
52
65
|
// Experiment toggle: which bounded head to use inside the segmented mlx_vjp.
|
|
53
66
|
// "checkpoint" = per-chunk Checkpoint recompute (bounds memory under nesting);
|
|
@@ -65,7 +78,36 @@ function boundedHeadFromHidden(
|
|
|
65
78
|
: chunkedLogpMeanFromHidden(model, h, ids, mask, chunkSize, sink, span);
|
|
66
79
|
}
|
|
67
80
|
import type { TrainableLora } from "./lora-params";
|
|
68
|
-
import { type SftBatch, type DpoBatch } from "./dataset";
|
|
81
|
+
import { rowLength, type SftBatch, type DpoBatch } from "./dataset";
|
|
82
|
+
|
|
83
|
+
// Token-chunk size for the bounded SFT head (the ORPO paths take theirs from
|
|
84
|
+
// cfg.orpoChunkSize; SFT has no per-run knob, 512 matches the ORPO default).
|
|
85
|
+
const SFT_HEAD_CHUNK = Number(process.env.MLX_BUN_SEG_HEAD_CHUNK ?? "512");
|
|
86
|
+
|
|
87
|
+
/** [M,V]-bounded response-only CE from post-finalNorm hidden — the SFT
|
|
88
|
+
* segmented head (kernel-review backlog #8). Same span arithmetic and
|
|
89
|
+
* token-mean reduction as loss.ts responseOnlyCe, but the logits only ever
|
|
90
|
+
* exist per token-chunk ([chunk,V] Checkpoint recompute, or the fused
|
|
91
|
+
* CustomVjp head via MLX_BUN_SEG_HEAD) instead of the full [1,M,V] + its
|
|
92
|
+
* cotangent inside the head vjp. CE = −(mean response logp); per-position
|
|
93
|
+
* grads are bit-exact vs the whole-vocab head (chunking is over TOKEN
|
|
94
|
+
* positions; each position's full-V lse is computed unchanged — only the
|
|
95
|
+
* scalar loss VALUE can differ in fp association across chunks). */
|
|
96
|
+
export function boundedSftCe(
|
|
97
|
+
model: MiniCPM5Model | Gemma4Model, hn: MlxArray, batch: SftBatch,
|
|
98
|
+
sink: Array<{ dispose(): void }>, chunkSize = SFT_HEAD_CHUNK,
|
|
99
|
+
): MlxArray {
|
|
100
|
+
const L = batch.ids[0]!.length;
|
|
101
|
+
const startT = Math.max(0, batch.promptLens[0]! - 1); // first supervised input position
|
|
102
|
+
const M = Math.min(rowLength(batch, 0), L) - 1 - startT;
|
|
103
|
+
if (M <= 0) throw new Error("sftLoss: no response tokens to supervise");
|
|
104
|
+
const mean = boundedHeadFromHidden(model, hn, batch.ids[0]!, [], chunkSize, sink, { startT, M });
|
|
105
|
+
const neg = ops.mulScalar(mean, -1); // CE = −(mean logp); exact sign flip
|
|
106
|
+
mean.dispose();
|
|
107
|
+
const loss = ops.sumAxis(neg, 0, false); // [1] -> scalar, matches the vjp cotangent `one`
|
|
108
|
+
neg.dispose();
|
|
109
|
+
return loss;
|
|
110
|
+
}
|
|
69
111
|
|
|
70
112
|
/** Split `nLayers` into contiguous `[lo, hi)` ranges of at most `segSize`
|
|
71
113
|
* layers each (the last range may be shorter). `segSize` is the memory knob:
|
|
@@ -77,24 +119,50 @@ export function planSegmentsBySize(nLayers: number, segSize: number): [number, n
|
|
|
77
119
|
return ranges;
|
|
78
120
|
}
|
|
79
121
|
|
|
122
|
+
// NOTE (2026-07-02, kernel-review backlog #2 REFUTED BY MEASUREMENT): a
|
|
123
|
+
// "full-attention isolation" planner (singleton segments for e4b's 7 full
|
|
124
|
+
// layers) was built and A/B'd @8K — ZERO peak win (18.09 vs 18.02 GB). The
|
|
125
|
+
// design §5 premise is false: mlx's sdpa BACKWARD materializes the O(L²)
|
|
126
|
+
// scores for EVERY layer (~3.5 GB/layer @8K on e4b — sliding's window is just
|
|
127
|
+
// an additive mask; measured: sliding pair +7.1 GB ≈ sliding+full +7.15). The
|
|
128
|
+
// worst segment is set by LAYER COUNT alone, so segSize IS the whole knob:
|
|
129
|
+
// segment_size 1 measures 14.59 GB @8K (+3% step time) vs 17.5 @seg2. Below
|
|
130
|
+
// that needs the [M,V]-bounded SFT head (backlog #8, ~3 GB head-vjp spike)
|
|
131
|
+
// and/or an O(L)-memory attention backward. Evidence:
|
|
132
|
+
// scripts/experiments/seg-isolation-smoke.ts + MLX_BUN_SEG_MEM_LOG=1 probes.
|
|
133
|
+
|
|
80
134
|
/** Detach `a` into a graph-free LEAF holding its exact (evaluated) bytes, then
|
|
81
135
|
* dispose `a`. mlx retains the upstream graph after `eval`, so a segment
|
|
82
136
|
* boundary (or a value_and_grad output) would otherwise keep its whole layer
|
|
83
137
|
* stack's activations alive. Copying the bytes into a fresh leaf frees that
|
|
84
138
|
* graph. The leaf is a valid differentiable input for the per-segment vag. */
|
|
85
139
|
function detachLeaf(a: MlxArray): MlxArray {
|
|
86
|
-
// Force a row-major copy first:
|
|
87
|
-
//
|
|
88
|
-
//
|
|
140
|
+
// Force a row-major copy first: the buffer is read linearly, so a
|
|
141
|
+
// NON-contiguous input (e.g. a transposed donor K/V view) would be
|
|
142
|
+
// byte-scrambled. `ops.contiguous` is a no-op for already-contiguous arrays
|
|
89
143
|
// (hidden boundaries) and materializes row-major for strided views.
|
|
144
|
+
// detachCopy = ONE mlx→mlx copy (backlog #9: the old
|
|
145
|
+
// fromBytesCopy(rawBytes()) route was two copies via the JS heap).
|
|
90
146
|
const c = ops.contiguous(a);
|
|
91
|
-
c.
|
|
92
|
-
const leaf = MlxArray.fromBytesCopy(c.rawBytes(), c.shape, c.dtype);
|
|
147
|
+
const leaf = c.detachCopy();
|
|
93
148
|
c.dispose();
|
|
94
149
|
a.dispose();
|
|
95
150
|
return leaf;
|
|
96
151
|
}
|
|
97
152
|
|
|
153
|
+
/** Batch detach: ONE evalAll barrier for the whole set (backlog #9 — each
|
|
154
|
+
* per-array eval was a separate blocking GPU drain; a segment's vjp returns
|
|
155
|
+
* dh + 2·consumed-donors + 2·targets arrays, so the per-segment sync count
|
|
156
|
+
* collapses from ~2+2n to 1), then one mlx→mlx copy each. Disposes inputs. */
|
|
157
|
+
function detachLeaves(arrs: MlxArray[]): MlxArray[] {
|
|
158
|
+
const cs = arrs.map((a) => ops.contiguous(a));
|
|
159
|
+
ops.evalAll(cs);
|
|
160
|
+
const leaves = cs.map((c) => c.detachCopy());
|
|
161
|
+
for (const c of cs) c.dispose();
|
|
162
|
+
for (const a of arrs) a.dispose();
|
|
163
|
+
return leaves;
|
|
164
|
+
}
|
|
165
|
+
|
|
98
166
|
/** Layer index a target's modulePath belongs to, or -1 (e.g. embedding/head). */
|
|
99
167
|
function targetLayer(modulePath: string): number {
|
|
100
168
|
const m = modulePath.match(/\.layers\.(\d+)\./);
|
|
@@ -120,6 +188,10 @@ export class SegmentedBackward {
|
|
|
120
188
|
private readonly one: MlxArray; // scalar cotangent 1.0 for the (scalar) head loss
|
|
121
189
|
// Per-step context the (reused) closures read.
|
|
122
190
|
private curBatch: SftBatch | null = null;
|
|
191
|
+
// Bounded-head per-step transients (chunk slices + Checkpoint/CustomVjp
|
|
192
|
+
// objects): the head backward recomputes through them, so they are disposed
|
|
193
|
+
// only AFTER the head vjp's outputs are detached (which forces the eval).
|
|
194
|
+
private headSink: Array<{ dispose(): void }> = [];
|
|
123
195
|
private disposed = false;
|
|
124
196
|
|
|
125
197
|
constructor(
|
|
@@ -140,11 +212,17 @@ export class SegmentedBackward {
|
|
|
140
212
|
this.caches = Array.from({ length: this.nLayers }, () => new TrainingCache());
|
|
141
213
|
this.one = MlxArray.fromFloat32(new Float32Array([1]), []); // d(loss)/d(loss) = 1
|
|
142
214
|
|
|
143
|
-
// Loss head: vjp of finalNorm +
|
|
215
|
+
// Loss head: vjp of finalNorm + the [M,V]-BOUNDED response CE over the last
|
|
216
|
+
// boundary leaf (kernel-review backlog #8: the old responseOnlyCe here
|
|
217
|
+
// materialized [1,M,V] logits + cotangents inside the head vjp — measured
|
|
218
|
+
// ~3 GB @8K on e4b, the exact transient the segmentation exists to kill).
|
|
219
|
+
// CE = −(mean response logp); span and reduction identical to
|
|
220
|
+
// responseOnlyCe, per-position grads bit-exact (chunking is over token
|
|
221
|
+
// positions — each position's full-V lse/softmax is computed unchanged).
|
|
144
222
|
// outputs[0] = loss scalar; vjps[0] = dLoss/d(last residual stream) = dh.
|
|
145
223
|
this.headVjp = new Vjp((p) => {
|
|
146
224
|
const hn = model.finalNorm.forward(p[0]!);
|
|
147
|
-
const loss =
|
|
225
|
+
const loss = boundedSftCe(model, hn, this.curBatch!, this.headSink);
|
|
148
226
|
hn.dispose();
|
|
149
227
|
return [loss];
|
|
150
228
|
}, 1);
|
|
@@ -204,9 +282,17 @@ export class SegmentedBackward {
|
|
|
204
282
|
}
|
|
205
283
|
|
|
206
284
|
// --- 2. Loss head over the last boundary (vjp with cotangent 1.0). ---
|
|
285
|
+
// The bounded head pushes per-step chunk slices + Checkpoints into
|
|
286
|
+
// headSink; the head BACKWARD recomputes through them, so materialize
|
|
287
|
+
// the full head-vjp graph BEFORE freeing the sink (lazy-eval UAF
|
|
288
|
+
// discipline, same as the ORPO classes).
|
|
289
|
+
this.headSink = [];
|
|
207
290
|
const head = this.headVjp.apply([boundaries[nSeg]!], [this.one]);
|
|
291
|
+
ops.evalAll([head.outputs[0]!, head.vjps[0]!]);
|
|
208
292
|
value = detachLeaf(head.outputs[0]!); // scalar loss, detached from the head graph (frees logits)
|
|
209
293
|
dhOut = detachLeaf(head.vjps[0]!); // dLoss / d(last residual stream), graph-free
|
|
294
|
+
for (const d of this.headSink) d.dispose();
|
|
295
|
+
this.headSink = [];
|
|
210
296
|
boundaries[nSeg]!.dispose();
|
|
211
297
|
boundaries[nSeg] = null;
|
|
212
298
|
|
|
@@ -220,13 +306,15 @@ export class SegmentedBackward {
|
|
|
220
306
|
res.outputs[0]!.dispose(); // segment output — unused (only its vjp matters)
|
|
221
307
|
// vjps[0] = dh_in (grad w.r.t. this segment's input boundary) -> dh_out
|
|
222
308
|
// for segment k-1; the rest scatter into the global flat vector. Detach
|
|
223
|
-
//
|
|
309
|
+
// to graph-free leaves (eval doesn't detach; keeps peak bounded) — ONE
|
|
310
|
+
// barrier for the segment's whole vjp set.
|
|
311
|
+
const leaves = detachLeaves(res.vjps);
|
|
224
312
|
idxs.forEach((j, s) => {
|
|
225
|
-
grads[j] =
|
|
226
|
-
grads[n + j] =
|
|
313
|
+
grads[j] = leaves[1 + 2 * s]!; // dA
|
|
314
|
+
grads[n + j] = leaves[1 + 2 * s + 1]!; // dB
|
|
227
315
|
});
|
|
228
316
|
dhOut!.dispose(); // consumed as this segment's cotangent
|
|
229
|
-
dhOut =
|
|
317
|
+
dhOut = leaves[0]!;
|
|
230
318
|
boundaries[k]!.dispose();
|
|
231
319
|
boundaries[k] = null;
|
|
232
320
|
}
|
|
@@ -238,6 +326,8 @@ export class SegmentedBackward {
|
|
|
238
326
|
} finally {
|
|
239
327
|
inputIds.dispose();
|
|
240
328
|
this.curBatch = null;
|
|
329
|
+
for (const d of this.headSink) d.dispose(); // no-op unless the head threw mid-step
|
|
330
|
+
this.headSink = [];
|
|
241
331
|
dhOut?.dispose();
|
|
242
332
|
for (const b of boundaries) b?.dispose();
|
|
243
333
|
if (!transferred) {
|
|
@@ -295,6 +385,8 @@ export class SegmentedBackwardGemma4 {
|
|
|
295
385
|
private curBatch: SftBatch | null = null;
|
|
296
386
|
private curMasks: Map<string, Mask> | null = null;
|
|
297
387
|
private curPerLayer: MlxArray | null = null;
|
|
388
|
+
// Bounded-head per-step transients — see SegmentedBackward.headSink.
|
|
389
|
+
private headSink: Array<{ dispose(): void }> = [];
|
|
298
390
|
private disposed = false;
|
|
299
391
|
|
|
300
392
|
constructor(
|
|
@@ -337,11 +429,11 @@ export class SegmentedBackwardGemma4 {
|
|
|
337
429
|
this.caches = Array.from({ length: model.numDonors }, () => new TrainingCache());
|
|
338
430
|
this.one = MlxArray.fromFloat32(new Float32Array([1]), []);
|
|
339
431
|
|
|
340
|
-
// Loss head (identical to MiniCPM5): finalNorm +
|
|
432
|
+
// Loss head (identical to MiniCPM5): finalNorm + bounded response CE over the last
|
|
341
433
|
// boundary, vjp with cotangent 1.0 -> [loss], [dh].
|
|
342
434
|
this.headVjp = new Vjp((p) => {
|
|
343
435
|
const hn = model.finalNorm.forward(p[0]!);
|
|
344
|
-
const loss =
|
|
436
|
+
const loss = boundedSftCe(model, hn, this.curBatch!, this.headSink);
|
|
345
437
|
hn.dispose();
|
|
346
438
|
return [loss];
|
|
347
439
|
}, 1);
|
|
@@ -437,14 +529,22 @@ export class SegmentedBackwardGemma4 {
|
|
|
437
529
|
for (const [d, kv] of donorKvOut)
|
|
438
530
|
if (!produces[k]!.includes(d) && kv.kind === "plain") { kv.keys.dispose(); kv.values.dispose(); }
|
|
439
531
|
boundaries.push(detachLeaf(h));
|
|
532
|
+
segMem(`fwd seg ${k} [${lo},${hi})`);
|
|
440
533
|
}
|
|
441
534
|
|
|
442
|
-
// --- 2. Loss head (vjp, cotangent 1.0).
|
|
535
|
+
// --- 2. Loss head (vjp, cotangent 1.0). Bounded-head sink discipline:
|
|
536
|
+
// materialize the head-vjp graph BEFORE freeing the sink (see the
|
|
537
|
+
// MiniCPM5 class / ORPO classes — lazy-eval UAF). ---
|
|
538
|
+
this.headSink = [];
|
|
443
539
|
const head = this.headVjp.apply([boundaries[nSeg]!], [this.one]);
|
|
540
|
+
ops.evalAll([head.outputs[0]!, head.vjps[0]!]);
|
|
444
541
|
value = detachLeaf(head.outputs[0]!);
|
|
445
542
|
dhOut = detachLeaf(head.vjps[0]!);
|
|
543
|
+
for (const d of this.headSink) d.dispose();
|
|
544
|
+
this.headSink = [];
|
|
446
545
|
boundaries[nSeg]!.dispose();
|
|
447
546
|
boundaries[nSeg] = null;
|
|
547
|
+
segMem("head vjp");
|
|
448
548
|
|
|
449
549
|
// --- 3. Backward, reverse over segments (vjp, cotangents dh + dKV). ---
|
|
450
550
|
for (let k = nSeg - 1; k >= 0; k--) {
|
|
@@ -466,17 +566,17 @@ export class SegmentedBackwardGemma4 {
|
|
|
466
566
|
const res = this.segVjps[k]!.apply(primals, cots);
|
|
467
567
|
for (const o of res.outputs) o.dispose();
|
|
468
568
|
|
|
469
|
-
// vjps[0] = dh_in; then (dk,dv) per consumed donor; then (da,db) per
|
|
470
|
-
|
|
569
|
+
// vjps[0] = dh_in; then (dk,dv) per consumed donor; then (da,db) per
|
|
570
|
+
// LoRA — detached in ONE barrier (backlog #9).
|
|
571
|
+
const leaves = detachLeaves(res.vjps);
|
|
572
|
+
const newDh = leaves[0]!;
|
|
471
573
|
cons.forEach((d, ci) => {
|
|
472
|
-
|
|
473
|
-
const dv = detachLeaf(res.vjps[1 + 2 * ci + 1]!);
|
|
474
|
-
this.accumulateDKV(dKV, d, dk, dv);
|
|
574
|
+
this.accumulateDKV(dKV, d, leaves[1 + 2 * ci]!, leaves[1 + 2 * ci + 1]!);
|
|
475
575
|
});
|
|
476
576
|
const base = 1 + 2 * cons.length;
|
|
477
577
|
idxs.forEach((j, s) => {
|
|
478
|
-
grads[j] =
|
|
479
|
-
grads[n + j] =
|
|
578
|
+
grads[j] = leaves[base + 2 * s]!;
|
|
579
|
+
grads[n + j] = leaves[base + 2 * s + 1]!;
|
|
480
580
|
});
|
|
481
581
|
|
|
482
582
|
dh.dispose();
|
|
@@ -488,6 +588,7 @@ export class SegmentedBackwardGemma4 {
|
|
|
488
588
|
}
|
|
489
589
|
boundaries[k]!.dispose();
|
|
490
590
|
boundaries[k] = null;
|
|
591
|
+
segMem(`bwd seg ${k} [${ranges[k]![0]},${ranges[k]![1]})`);
|
|
491
592
|
}
|
|
492
593
|
|
|
493
594
|
transferred = true;
|
|
@@ -498,6 +599,8 @@ export class SegmentedBackwardGemma4 {
|
|
|
498
599
|
this.curPerLayer?.dispose();
|
|
499
600
|
this.curPerLayer = null;
|
|
500
601
|
if (this.curMasks) { for (const m of this.curMasks.values()) m.arr?.dispose(); this.curMasks = null; }
|
|
602
|
+
for (const d of this.headSink) d.dispose(); // no-op unless the head threw mid-step
|
|
603
|
+
this.headSink = [];
|
|
501
604
|
dhOut?.dispose();
|
|
502
605
|
for (const b of boundaries) b?.dispose();
|
|
503
606
|
for (const kv of donorBnd.values()) { kv.keys.dispose(); kv.values.dispose(); }
|
|
@@ -725,12 +828,13 @@ export class SegmentedBackwardOrpo {
|
|
|
725
828
|
for (const j of idxs) params.push(lora.targets[j]!.lw.a, lora.targets[j]!.lw.b);
|
|
726
829
|
const res = this.segVjps[k]!.apply(params, [dhC!]);
|
|
727
830
|
res.outputs[0]!.dispose();
|
|
831
|
+
const leaves = detachLeaves(res.vjps); // one barrier (backlog #9)
|
|
728
832
|
idxs.forEach((j, s) => {
|
|
729
|
-
gradsC[j] =
|
|
730
|
-
gradsC[n + j] =
|
|
833
|
+
gradsC[j] = leaves[1 + 2 * s]!; // dA
|
|
834
|
+
gradsC[n + j] = leaves[1 + 2 * s + 1]!; // dB
|
|
731
835
|
});
|
|
732
836
|
dhC!.dispose();
|
|
733
|
-
dhC =
|
|
837
|
+
dhC = leaves[0]!;
|
|
734
838
|
bndC[k]!.dispose(); bndC[k] = null;
|
|
735
839
|
}
|
|
736
840
|
dhC!.dispose(); dhC = null; // embedding grad, discarded (no LoRA on embed)
|
|
@@ -742,12 +846,13 @@ export class SegmentedBackwardOrpo {
|
|
|
742
846
|
for (const j of idxs) params.push(lora.targets[j]!.lw.a, lora.targets[j]!.lw.b);
|
|
743
847
|
const res = this.segVjps[k]!.apply(params, [dhR!]);
|
|
744
848
|
res.outputs[0]!.dispose();
|
|
849
|
+
const leaves = detachLeaves(res.vjps); // one barrier (backlog #9)
|
|
745
850
|
idxs.forEach((j, s) => {
|
|
746
|
-
gradsR[j] =
|
|
747
|
-
gradsR[n + j] =
|
|
851
|
+
gradsR[j] = leaves[1 + 2 * s]!; // dA
|
|
852
|
+
gradsR[n + j] = leaves[1 + 2 * s + 1]!; // dB
|
|
748
853
|
});
|
|
749
854
|
dhR!.dispose();
|
|
750
|
-
dhR =
|
|
855
|
+
dhR = leaves[0]!;
|
|
751
856
|
bndR[k]!.dispose(); bndR[k] = null;
|
|
752
857
|
}
|
|
753
858
|
dhR!.dispose(); dhR = null; // embedding grad, discarded
|
|
@@ -912,7 +1017,7 @@ export class SegmentedBackwardOrpoPrefix {
|
|
|
912
1017
|
this.headSink = [];
|
|
913
1018
|
this.curChunk = this.chunkCtx ? { ...this.chunkCtx, sink: this.headSink } : undefined;
|
|
914
1019
|
// One prefix cache per layer (captures P/Rc/Rr; makeMask -> block-sparse mask).
|
|
915
|
-
this.curCaches = model.layers.
|
|
1020
|
+
this.curCaches = prefixSharedCaches(model.layers.length, P, Rc, Rr);
|
|
916
1021
|
|
|
917
1022
|
const nSeg = ranges.length;
|
|
918
1023
|
const concat = new Int32Array(T);
|
|
@@ -957,12 +1062,13 @@ export class SegmentedBackwardOrpoPrefix {
|
|
|
957
1062
|
for (const j of idxs) params.push(lora.targets[j]!.lw.a, lora.targets[j]!.lw.b);
|
|
958
1063
|
const res = this.segVjps[k]!.apply(params, [dhOut!]);
|
|
959
1064
|
res.outputs[0]!.dispose();
|
|
1065
|
+
const leaves = detachLeaves(res.vjps); // one barrier (backlog #9)
|
|
960
1066
|
idxs.forEach((j, s) => {
|
|
961
|
-
grads[j] =
|
|
962
|
-
grads[n + j] =
|
|
1067
|
+
grads[j] = leaves[1 + 2 * s]!; // dA
|
|
1068
|
+
grads[n + j] = leaves[1 + 2 * s + 1]!; // dB
|
|
963
1069
|
});
|
|
964
1070
|
dhOut!.dispose();
|
|
965
|
-
dhOut =
|
|
1071
|
+
dhOut = leaves[0]!;
|
|
966
1072
|
boundaries[k]!.dispose(); boundaries[k] = null;
|
|
967
1073
|
}
|
|
968
1074
|
// Segment-0 dh_in = grad w.r.t. the embedding output (no LoRA on embed) — drop.
|
|
@@ -1232,16 +1338,15 @@ export class SegmentedBackwardOrpoGemma4 {
|
|
|
1232
1338
|
const res = this.segVjps[k]!.apply(primals, cots);
|
|
1233
1339
|
for (const o of res.outputs) o.dispose();
|
|
1234
1340
|
|
|
1235
|
-
const
|
|
1341
|
+
const leaves = detachLeaves(res.vjps); // one barrier (backlog #9)
|
|
1342
|
+
const newDh = leaves[0]!;
|
|
1236
1343
|
cons.forEach((d, ci) => {
|
|
1237
|
-
|
|
1238
|
-
const dv = detachLeaf(res.vjps[1 + 2 * ci + 1]!);
|
|
1239
|
-
this.accumulateDKV(dKV, d, dk, dv);
|
|
1344
|
+
this.accumulateDKV(dKV, d, leaves[1 + 2 * ci]!, leaves[1 + 2 * ci + 1]!);
|
|
1240
1345
|
});
|
|
1241
1346
|
const base = 1 + 2 * cons.length;
|
|
1242
1347
|
idxs.forEach((j, s) => {
|
|
1243
|
-
grads[j] =
|
|
1244
|
-
grads[n + j] =
|
|
1348
|
+
grads[j] = leaves[base + 2 * s]!;
|
|
1349
|
+
grads[n + j] = leaves[base + 2 * s + 1]!;
|
|
1245
1350
|
});
|
|
1246
1351
|
|
|
1247
1352
|
dh.dispose();
|
|
@@ -1663,16 +1768,15 @@ export class SegmentedBackwardOrpoPrefixGemma4 {
|
|
|
1663
1768
|
const res = this.segVjps[k]!.apply(primals, cots);
|
|
1664
1769
|
for (const o of res.outputs) o.dispose();
|
|
1665
1770
|
|
|
1666
|
-
const
|
|
1771
|
+
const leaves = detachLeaves(res.vjps); // one barrier (backlog #9)
|
|
1772
|
+
const newDh = leaves[0]!;
|
|
1667
1773
|
cons.forEach((d, ci) => {
|
|
1668
|
-
|
|
1669
|
-
const dv = detachLeaf(res.vjps[1 + 2 * ci + 1]!);
|
|
1670
|
-
this.accumulateDKV(dKV, d, dk, dv);
|
|
1774
|
+
this.accumulateDKV(dKV, d, leaves[1 + 2 * ci]!, leaves[1 + 2 * ci + 1]!);
|
|
1671
1775
|
});
|
|
1672
1776
|
const base = 1 + 2 * cons.length;
|
|
1673
1777
|
idxs.forEach((j, s) => {
|
|
1674
|
-
grads[j] =
|
|
1675
|
-
grads[n + j] =
|
|
1778
|
+
grads[j] = leaves[base + 2 * s]!;
|
|
1779
|
+
grads[n + j] = leaves[base + 2 * s + 1]!;
|
|
1676
1780
|
});
|
|
1677
1781
|
|
|
1678
1782
|
dh.dispose();
|
package/src/train/trainer.ts
CHANGED
|
@@ -388,6 +388,16 @@ export async function trainLora(
|
|
|
388
388
|
}
|
|
389
389
|
}
|
|
390
390
|
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
// Segment planning: uniform planSegmentsBySize only. A full-attention
|
|
393
|
+
// ISOLATION planner (kernel-review backlog #2 / segmented-backward-training.md
|
|
394
|
+
// §5) was built and A/B-measured 2026-07-02: ZERO peak win — mlx's sdpa
|
|
395
|
+
// backward is O(L²) for EVERY layer (~3.5 GB/layer @8K e4b), sliding included,
|
|
396
|
+
// so the worst segment is set by layer count alone and segment_size is the
|
|
397
|
+
// whole knob (seg1 = 14.59 GB @8K, +3% step time — fits the 24 GB M4 Pro).
|
|
398
|
+
// Evidence: scripts/experiments/seg-isolation-smoke.ts; note in segmented.ts.
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
|
|
391
401
|
// ---------------------------------------------------------------------------
|
|
392
402
|
// SFT loop
|
|
393
403
|
// ---------------------------------------------------------------------------
|
|
@@ -421,14 +431,14 @@ async function sftLoop(
|
|
|
421
431
|
const useSegmented = cfg.segmentSize > 0;
|
|
422
432
|
let segmented: SegmentedBackward | SegmentedBackwardGemma4 | null = null;
|
|
423
433
|
if (useSegmented) {
|
|
424
|
-
if (model instanceof MiniCPM5Model)
|
|
425
|
-
segmented = new SegmentedBackward(model, lora, planSegmentsBySize(model.layers.length, cfg.segmentSize));
|
|
426
|
-
else if (model instanceof Gemma4Model)
|
|
427
|
-
segmented = new SegmentedBackwardGemma4(model, lora, planSegmentsBySize(model.layers.length, cfg.segmentSize));
|
|
428
|
-
else
|
|
434
|
+
if (!(model instanceof MiniCPM5Model) && !(model instanceof Gemma4Model))
|
|
429
435
|
throw new Error("segmented backward (segmentSize > 0) is only wired for MiniCPM5 and Gemma4");
|
|
436
|
+
const ranges = planSegmentsBySize(model.layers.length, cfg.segmentSize);
|
|
437
|
+
segmented = model instanceof MiniCPM5Model
|
|
438
|
+
? new SegmentedBackward(model, lora, ranges)
|
|
439
|
+
: new SegmentedBackwardGemma4(model, lora, ranges);
|
|
430
440
|
emit({ type: "stage", stage: "setup", progress: 0.04,
|
|
431
|
-
message: `segmented backward: ${
|
|
441
|
+
message: `segmented backward: ${ranges.length} segments of <=${cfg.segmentSize} layers` });
|
|
432
442
|
}
|
|
433
443
|
|
|
434
444
|
// The value_and_grad loss closure: temporarily swap the differentiated
|
|
@@ -832,6 +842,8 @@ async function orpoLoop(
|
|
|
832
842
|
let segmentedOrpo: SegmentedBackwardOrpo | SegmentedBackwardOrpoGemma4 | null = null;
|
|
833
843
|
let segmentedOrpoPrefix: SegmentedBackwardOrpoPrefix | SegmentedBackwardOrpoPrefixGemma4 | null = null;
|
|
834
844
|
if (useSegmented) {
|
|
845
|
+
if (!(model instanceof MiniCPM5Model) && !(model instanceof Gemma4Model))
|
|
846
|
+
throw new Error("segmented backward (segmentSize > 0) for ORPO is only wired for MiniCPM5 and Gemma4");
|
|
835
847
|
const ranges = planSegmentsBySize(model.layers.length, cfg.segmentSize);
|
|
836
848
|
if (useSegmentedPrefix) {
|
|
837
849
|
// The composed prefix-shared segmented backward (MiniCPM5 or Gemma4/e4b), plus a
|
|
@@ -845,15 +857,13 @@ async function orpoLoop(
|
|
|
845
857
|
segmentedOrpo = new SegmentedBackwardOrpoGemma4(model as Gemma4Model, lora, ranges, cfg.orpoLambda, segFusedChunk, cfg.sftScope);
|
|
846
858
|
}
|
|
847
859
|
emit({ type: "stage", stage: "setup", progress: 0.04,
|
|
848
|
-
message: `orpo segmented + prefix-share (${model instanceof Gemma4Model ? "e4b" : "MiniCPM5"}): single concat forward streamed over ${
|
|
860
|
+
message: `orpo segmented + prefix-share (${model instanceof Gemma4Model ? "e4b" : "MiniCPM5"}): single concat forward streamed over ${ranges.length} segments of <=${cfg.segmentSize} layers (two-forward fallback on prompt mismatch)` });
|
|
849
861
|
} else if (model instanceof MiniCPM5Model)
|
|
850
862
|
segmentedOrpo = new SegmentedBackwardOrpo(model, lora, ranges, cfg.orpoLambda, segFusedChunk, cfg.sftScope);
|
|
851
|
-
else if (model instanceof Gemma4Model)
|
|
852
|
-
segmentedOrpo = new SegmentedBackwardOrpoGemma4(model, lora, ranges, cfg.orpoLambda, segFusedChunk, cfg.sftScope);
|
|
853
863
|
else
|
|
854
|
-
|
|
864
|
+
segmentedOrpo = new SegmentedBackwardOrpoGemma4(model as Gemma4Model, lora, ranges, cfg.orpoLambda, segFusedChunk, cfg.sftScope);
|
|
855
865
|
if (!useSegmentedPrefix) emit({ type: "stage", stage: "setup", progress: 0.04,
|
|
856
|
-
message: `orpo segmented backward: ${
|
|
866
|
+
message: `orpo segmented backward: ${ranges.length} segments of <=${cfg.segmentSize} layers${segFusedChunk > 0 ? ` + fused linear-CE head (${segFusedChunk}/chunk)` : ""}` });
|
|
857
867
|
}
|
|
858
868
|
|
|
859
869
|
// Token-chunked head (non-segmented path only): the per-chunk checkpoints are
|