mlx-bun 0.0.7 → 0.0.9
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 +112 -20
- package/docs/design/batching-v2-plan.md +515 -0
- package/docs/design/bucketing-stage.md +196 -0
- package/docs/design/compat-cli-surface-design.md +11 -11
- package/docs/design/diffusion-gemma-port.md +602 -0
- package/docs/design/docs-reorg-plan.md +1 -1
- package/docs/design/dreaming-nightly-pipeline.md +291 -0
- package/docs/design/dspark-speculative-decoding.md +106 -0
- package/docs/design/generic-model-support.md +381 -0
- package/docs/design/memory-inference-path.md +422 -0
- package/docs/design/memory-synthesis.md +287 -0
- package/docs/design/minicpm5-decode-megakernel.md +484 -0
- package/docs/design/mlx-lm-tool-parity-plan.md +535 -0
- package/docs/design/orpo-dynamic-lambda.md +41 -0
- package/docs/design/orpo-training.md +35 -5
- package/docs/design/parallel-slots.md +12 -1
- package/docs/design/parity-tier-dag.md +13 -5
- package/docs/design/spec-decode-larger-targets.md +229 -0
- package/docs/design/tauri-desktop-app.md +2 -2
- package/docs/design/the-dreaming-handoff.md +141 -0
- package/docs/design/the-dreaming-master-plan.md +446 -0
- package/docs/design/write-pipeline-entity.md +468 -0
- package/docs/investigations/curve-bisector-routing.md +1 -1
- package/docs/investigations/decode-roofline-lookagain.md +262 -0
- package/docs/investigations/dspark-handoff.md +58 -0
- package/docs/investigations/kernel-perf-review-2026-07.md +92 -0
- package/docs/planning/PRODUCT_ROADMAP.md +2 -2
- package/docs/planning/curve-sampler-research-plan.md +331 -0
- package/docs/planning/memory-docs-and-dag-plan.md +185 -0
- package/docs/planning/pi-integration-review.md +178 -0
- package/docs/planning/release-notes-v0.0.9.md +122 -0
- package/docs/planning/web-ui-pass-plan.md +213 -0
- package/docs/planning/website-readme-pass-plan.md +224 -0
- package/docs/reference/cli.md +255 -0
- package/docs/reference/embedding.md +7 -2
- package/docs/reference/library-api.md +22 -3
- package/docs/reference/memory.md +5 -5
- package/docs/reference/models.md +137 -0
- package/docs/reference/orpo-quickstart.md +6 -0
- package/docs/reference/server-api.md +187 -9
- package/docs/reference/server-config.md +40 -9
- package/docs/reference/training.md +25 -11
- package/package.json +1 -1
- package/src/anthropic.ts +22 -0
- package/src/assets/curve-designer.html +1 -1
- package/src/chat-template.ts +25 -7
- package/src/cli.ts +1029 -86
- package/src/config.ts +54 -3
- package/src/diffusion/diffusion-generate.ts +436 -0
- package/src/download.ts +99 -8
- package/src/embed.ts +77 -0
- package/src/eval/perplexity.ts +164 -0
- package/src/eval/runner.ts +19 -7
- package/src/eval/tasks/bfcl.ts +1 -1
- package/src/generate.ts +196 -19
- package/src/harness-pi.ts +43 -17
- package/src/index.ts +2 -0
- package/src/lora.ts +2 -2
- package/src/memory/article.ts +558 -0
- package/src/memory/chunk-validation.ts +150 -0
- package/src/memory/chunk.ts +305 -12
- package/src/memory/cluster.ts +344 -17
- package/src/memory/coverage.ts +134 -0
- package/src/memory/crosslink.ts +0 -0
- package/src/memory/db.ts +184 -45
- package/src/memory/entity.ts +203 -0
- package/src/memory/gate.ts +187 -0
- package/src/memory/history.ts +152 -0
- package/src/memory/ingest.ts +324 -11
- package/src/memory/model.ts +321 -0
- package/src/memory/normalize.ts +671 -0
- package/src/memory/parse.ts +22 -0
- package/src/memory/pipeline.ts +242 -30
- package/src/memory/prompts.ts +42 -0
- package/src/memory/query.ts +334 -0
- package/src/memory/reconcile.ts +734 -0
- package/src/memory/redirect.ts +108 -0
- package/src/memory/reindex.ts +325 -0
- package/src/memory/resolve.ts +307 -0
- package/src/memory/route.ts +272 -0
- package/src/memory/schedule.ts +6 -4
- package/src/memory/stages.ts +774 -0
- package/src/memory/synthesize.ts +1629 -20
- package/src/memory/tools.ts +446 -96
- package/src/memory/vault.ts +462 -35
- package/src/memory/wikify.ts +1057 -16
- package/src/mlx/ffi.ts +11 -0
- package/src/mlx/ops.ts +113 -0
- package/src/model/compiled-decode.ts +43 -8
- package/src/model/diffusion-gemma.ts +1026 -0
- package/src/model/factory.ts +35 -8
- package/src/model/flash-attention.ts +4 -2
- package/src/model/fused-decode-kernel.ts +14 -7
- package/src/model/fused-mlp-kernel.ts +169 -0
- package/src/model/fused-swiglu-kernel.ts +240 -0
- package/src/model/gemma4-base.ts +62 -6
- package/src/model/gemma4.ts +33 -2
- package/src/model/minicpm5.ts +106 -8
- package/src/model/qwen3.ts +276 -0
- package/src/model/steel-linear-kernel.ts +96 -0
- package/src/model/support.ts +45 -7
- package/src/model/universal/archs.ts +332 -0
- package/src/model/universal/dense.ts +576 -0
- package/src/model/universal/modules.ts +163 -0
- package/src/model/universal/rope.ts +366 -0
- package/src/pi-provider.ts +1 -1
- package/src/pi-terminal.ts +29 -8
- package/src/pi-web.ts +19 -2
- package/src/quantize/config-writer.ts +1 -1
- package/src/registry.ts +215 -7
- package/src/responses.ts +23 -0
- package/src/sampler.ts +220 -9
- package/src/serve/batch-scheduler.ts +272 -80
- package/src/serve/generation-gateway.ts +90 -18
- package/src/server.ts +643 -50
- package/src/spec/drafter.ts +81 -49
- package/src/spec/dspark/data-dflash.ts +156 -0
- package/src/spec/dspark/data.ts +201 -0
- package/src/spec/dspark/generate-dflash.ts +193 -0
- package/src/spec/dspark/generate.ts +290 -0
- package/src/spec/dspark/loss.ts +213 -0
- package/src/spec/dspark/module-dflash.ts +361 -0
- package/src/spec/dspark/module.ts +540 -0
- package/src/spec/dspark/sample.ts +112 -0
- package/src/spec/generate.ts +40 -19
- package/src/status-page.html +1 -1
- package/src/tokenizer.ts +7 -0
- package/src/train/diffusion-lora.ts +216 -0
- package/src/train/flash-cce.ts +70 -15
- package/src/train/fuse.ts +243 -0
- package/src/train/index.ts +3 -0
- package/src/train/job.ts +8 -0
- package/src/train/loss.ts +289 -30
- package/src/train/prefix-shared.ts +33 -5
- package/src/train/segmented.ts +67 -11
- package/src/train/trainer.ts +92 -28
- package/src/vision/diffusion-vision.ts +620 -0
- package/src/web/skills/memory/SKILL.md +40 -17
package/src/train/segmented.ts
CHANGED
|
@@ -42,8 +42,12 @@ import type { Gemma4Model } from "../model/gemma4";
|
|
|
42
42
|
import { setGemmaPrefixPlan } from "../model/gemma4";
|
|
43
43
|
import type { Cache, Mask, SharedKv } from "../model/gemma4-base";
|
|
44
44
|
import { TrainingCache } from "./forward";
|
|
45
|
-
import {
|
|
46
|
-
|
|
45
|
+
import {
|
|
46
|
+
responseOnlyCe, responseOnlyLogpMean, fusedLogpMeanFromHidden, chunkedLogpMeanFromHidden,
|
|
47
|
+
orpoLossFromLogps, spanLogpMeanFromHidden, respSpanFromMask, combineFullNll,
|
|
48
|
+
type ChunkCtx, type SftScope, type HeadSpan,
|
|
49
|
+
} from "./loss";
|
|
50
|
+
import { PrefixSharedCache, prefixGatherIdx, branchLogpMeanGathered, blockSparsePrefixMaskGemma, prefixFullNll } from "./prefix-shared";
|
|
47
51
|
|
|
48
52
|
// Experiment toggle: which bounded head to use inside the segmented mlx_vjp.
|
|
49
53
|
// "checkpoint" = per-chunk Checkpoint recompute (bounds memory under nesting);
|
|
@@ -54,11 +58,11 @@ import { PrefixSharedCache, prefixGatherIdx, branchLogpMeanGathered, blockSparse
|
|
|
54
58
|
const SEG_HEAD = process.env.MLX_BUN_SEG_HEAD ?? "checkpoint";
|
|
55
59
|
function boundedHeadFromHidden(
|
|
56
60
|
model: MiniCPM5Model | Gemma4Model, h: MlxArray, ids: number[], mask: number[],
|
|
57
|
-
chunkSize: number, sink: Array<{ dispose(): void }>,
|
|
61
|
+
chunkSize: number, sink: Array<{ dispose(): void }>, span?: HeadSpan,
|
|
58
62
|
): MlxArray {
|
|
59
63
|
return SEG_HEAD === "fused"
|
|
60
|
-
? fusedLogpMeanFromHidden(model, h, ids, mask, chunkSize, sink)
|
|
61
|
-
: chunkedLogpMeanFromHidden(model, h, ids, mask, chunkSize, sink);
|
|
64
|
+
? fusedLogpMeanFromHidden(model, h, ids, mask, chunkSize, sink, 0, false, span)
|
|
65
|
+
: chunkedLogpMeanFromHidden(model, h, ids, mask, chunkSize, sink, span);
|
|
62
66
|
}
|
|
63
67
|
import type { TrainableLora } from "./lora-params";
|
|
64
68
|
import { type SftBatch, type DpoBatch } from "./dataset";
|
|
@@ -582,6 +586,7 @@ export class SegmentedBackwardOrpo {
|
|
|
582
586
|
private readonly ranges: [number, number][],
|
|
583
587
|
private readonly lambda: number,
|
|
584
588
|
private readonly fusedChunkSize = 0,
|
|
589
|
+
private readonly sftScope: SftScope = "response",
|
|
585
590
|
) {
|
|
586
591
|
this.nLayers = model.layers.length;
|
|
587
592
|
const lastHi = ranges[ranges.length - 1]?.[1] ?? 0;
|
|
@@ -609,9 +614,26 @@ export class SegmentedBackwardOrpo {
|
|
|
609
614
|
const lr = cs > 0 // [1]
|
|
610
615
|
? boundedHeadFromHidden(model, hnR, this.curRejectedIds!, this.curRejectedMask!, cs, this.headSink)
|
|
611
616
|
: responseOnlyLogpMean(model, hnR, this.curRejectedIds!, this.curRejectedMask!);
|
|
617
|
+
// sft_scope:"full": the chosen head additionally runs over the PROMPT span
|
|
618
|
+
// [0, startT) of the SAME hnC (same tier: bounded or whole-vocab), and the
|
|
619
|
+
// full-scope chosen NLL replaces mean(-ℓw). The prompt-position gradient
|
|
620
|
+
// rides into this vjp's dh_chosen automatically (the loss graph includes
|
|
621
|
+
// the prompt-span head). ℓw/ℓr stay response-only for the odds ratio.
|
|
622
|
+
let nllFull: MlxArray | null = null;
|
|
623
|
+
if (this.sftScope === "full") {
|
|
624
|
+
const { startT, M } = respSpanFromMask(this.curChosenMask!, this.curChosenIds!.length - 1);
|
|
625
|
+
let pm: MlxArray | null = null;
|
|
626
|
+
if (M > 0 && startT > 0)
|
|
627
|
+
pm = cs > 0
|
|
628
|
+
? boundedHeadFromHidden(model, hnC, this.curChosenIds!, this.curChosenMask!, cs, this.headSink, { startT: 0, M: startT })
|
|
629
|
+
: spanLogpMeanFromHidden(model, hnC, this.curChosenIds!, 0, startT);
|
|
630
|
+
nllFull = combineFullNll(pm, lw, M > 0 ? startT : 0, M);
|
|
631
|
+
pm?.dispose();
|
|
632
|
+
}
|
|
612
633
|
hnC.dispose(); hnR.dispose();
|
|
613
|
-
const loss = orpoLossFromLogps(lw, lr, this.lambda);
|
|
634
|
+
const loss = orpoLossFromLogps(lw, lr, this.lambda, nllFull ?? undefined);
|
|
614
635
|
lw.dispose(); lr.dispose();
|
|
636
|
+
nllFull?.dispose();
|
|
615
637
|
return [loss];
|
|
616
638
|
}, 1);
|
|
617
639
|
|
|
@@ -801,6 +823,7 @@ export class SegmentedBackwardOrpoPrefix {
|
|
|
801
823
|
private curP = 0;
|
|
802
824
|
private curRc = 0;
|
|
803
825
|
private curRr = 0;
|
|
826
|
+
private curPromptIds: number[] | null = null;
|
|
804
827
|
private curChosenResp: number[] | null = null;
|
|
805
828
|
private curRejectedResp: number[] | null = null;
|
|
806
829
|
private curChunk: ChunkCtx | undefined = undefined;
|
|
@@ -816,6 +839,7 @@ export class SegmentedBackwardOrpoPrefix {
|
|
|
816
839
|
private readonly ranges: [number, number][],
|
|
817
840
|
private readonly lambda: number,
|
|
818
841
|
private readonly chunkCtx?: ChunkCtx,
|
|
842
|
+
private readonly sftScope: SftScope = "response",
|
|
819
843
|
) {
|
|
820
844
|
this.nLayers = model.layers.length;
|
|
821
845
|
const lastHi = ranges[ranges.length - 1]?.[1] ?? 0;
|
|
@@ -838,9 +862,16 @@ export class SegmentedBackwardOrpoPrefix {
|
|
|
838
862
|
const { chosenIdx, rejectedIdx } = prefixGatherIdx(this.curP, this.curRc, this.curRr);
|
|
839
863
|
const lw = branchLogpMeanGathered(model, hn, chosenIdx, this.curChosenResp!, this.curChunk);
|
|
840
864
|
const lr = branchLogpMeanGathered(model, hn, rejectedIdx, this.curRejectedResp!, this.curChunk);
|
|
865
|
+
// sft_scope:"full": prompt predictions (H[0..P-2] → prompt[1..P-1]) from the
|
|
866
|
+
// same concat hiddens; the gather's takeAxis vjp scatter-adds the prompt
|
|
867
|
+
// cotangent into dh alongside the responses'. ℓw/ℓr stay response-only.
|
|
868
|
+
const nllFull = this.sftScope === "full"
|
|
869
|
+
? prefixFullNll(model, hn, this.curPromptIds!, lw, this.curRc, this.curChunk)
|
|
870
|
+
: null;
|
|
841
871
|
hn.dispose();
|
|
842
|
-
const loss = orpoLossFromLogps(lw, lr, this.lambda);
|
|
872
|
+
const loss = orpoLossFromLogps(lw, lr, this.lambda, nllFull ?? undefined);
|
|
843
873
|
lw.dispose(); lr.dispose();
|
|
874
|
+
nllFull?.dispose();
|
|
844
875
|
return [loss];
|
|
845
876
|
}, 1);
|
|
846
877
|
|
|
@@ -873,6 +904,7 @@ export class SegmentedBackwardOrpoPrefix {
|
|
|
873
904
|
if (P < 1 || Rc < 1 || Rr < 1) throw new Error("SegmentedBackwardOrpoPrefix: need P,Rc,Rr >= 1");
|
|
874
905
|
const T = P + Rc + Rr;
|
|
875
906
|
this.curP = P; this.curRc = Rc; this.curRr = Rr;
|
|
907
|
+
this.curPromptIds = promptIds;
|
|
876
908
|
this.curChosenResp = chosenResp; this.curRejectedResp = rejectedResp;
|
|
877
909
|
// Per-step chunk ctx pointing the head's CustomVjp/slice disposables at this
|
|
878
910
|
// step's headSink (the flash/fused head recompute runs during headVjp.apply;
|
|
@@ -942,7 +974,7 @@ export class SegmentedBackwardOrpoPrefix {
|
|
|
942
974
|
inputIds.dispose();
|
|
943
975
|
for (const c of this.curCaches) c.dispose();
|
|
944
976
|
this.curCaches = [];
|
|
945
|
-
this.curChosenResp = this.curRejectedResp = null;
|
|
977
|
+
this.curPromptIds = this.curChosenResp = this.curRejectedResp = null;
|
|
946
978
|
for (const d of this.headSink) d.dispose();
|
|
947
979
|
this.headSink = [];
|
|
948
980
|
dhOut?.dispose();
|
|
@@ -1024,6 +1056,7 @@ export class SegmentedBackwardOrpoGemma4 {
|
|
|
1024
1056
|
private readonly ranges: [number, number][],
|
|
1025
1057
|
private readonly lambda: number,
|
|
1026
1058
|
private readonly fusedChunkSize = 0,
|
|
1059
|
+
private readonly sftScope: SftScope = "response",
|
|
1027
1060
|
) {
|
|
1028
1061
|
this.nLayers = model.layers.length;
|
|
1029
1062
|
const lastHi = ranges[ranges.length - 1]?.[1] ?? 0;
|
|
@@ -1072,9 +1105,23 @@ export class SegmentedBackwardOrpoGemma4 {
|
|
|
1072
1105
|
const lr = cs > 0
|
|
1073
1106
|
? boundedHeadFromHidden(this.model, hnR, this.curRejectedIds!, this.curRejectedMask!, cs, this.headSink)
|
|
1074
1107
|
: responseOnlyLogpMean(this.model, hnR, this.curRejectedIds!, this.curRejectedMask!);
|
|
1108
|
+
// sft_scope:"full": prompt-span head over the same hnC (see
|
|
1109
|
+
// SegmentedBackwardOrpo); ℓw/ℓr stay response-only for the odds ratio.
|
|
1110
|
+
let nllFull: MlxArray | null = null;
|
|
1111
|
+
if (this.sftScope === "full") {
|
|
1112
|
+
const { startT, M } = respSpanFromMask(this.curChosenMask!, this.curChosenIds!.length - 1);
|
|
1113
|
+
let pm: MlxArray | null = null;
|
|
1114
|
+
if (M > 0 && startT > 0)
|
|
1115
|
+
pm = cs > 0
|
|
1116
|
+
? boundedHeadFromHidden(this.model, hnC, this.curChosenIds!, this.curChosenMask!, cs, this.headSink, { startT: 0, M: startT })
|
|
1117
|
+
: spanLogpMeanFromHidden(this.model, hnC, this.curChosenIds!, 0, startT);
|
|
1118
|
+
nllFull = combineFullNll(pm, lw, M > 0 ? startT : 0, M);
|
|
1119
|
+
pm?.dispose();
|
|
1120
|
+
}
|
|
1075
1121
|
hnC.dispose(); hnR.dispose();
|
|
1076
|
-
const loss = orpoLossFromLogps(lw, lr, this.lambda);
|
|
1122
|
+
const loss = orpoLossFromLogps(lw, lr, this.lambda, nllFull ?? undefined);
|
|
1077
1123
|
lw.dispose(); lr.dispose();
|
|
1124
|
+
nllFull?.dispose();
|
|
1078
1125
|
return [loss];
|
|
1079
1126
|
}, 1);
|
|
1080
1127
|
|
|
@@ -1394,6 +1441,7 @@ export class SegmentedBackwardOrpoPrefixGemma4 {
|
|
|
1394
1441
|
private curP = 0;
|
|
1395
1442
|
private curRc = 0;
|
|
1396
1443
|
private curRr = 0;
|
|
1444
|
+
private curPromptIds: number[] | null = null;
|
|
1397
1445
|
private curChosenResp: number[] | null = null;
|
|
1398
1446
|
private curRejectedResp: number[] | null = null;
|
|
1399
1447
|
private curChunk: ChunkCtx | undefined = undefined;
|
|
@@ -1408,6 +1456,7 @@ export class SegmentedBackwardOrpoPrefixGemma4 {
|
|
|
1408
1456
|
private readonly ranges: [number, number][],
|
|
1409
1457
|
private readonly lambda: number,
|
|
1410
1458
|
private readonly chunkCtx?: ChunkCtx,
|
|
1459
|
+
private readonly sftScope: SftScope = "response",
|
|
1411
1460
|
) {
|
|
1412
1461
|
this.nLayers = model.layers.length;
|
|
1413
1462
|
const lastHi = ranges[ranges.length - 1]?.[1] ?? 0;
|
|
@@ -1455,9 +1504,15 @@ export class SegmentedBackwardOrpoPrefixGemma4 {
|
|
|
1455
1504
|
const { chosenIdx, rejectedIdx } = prefixGatherIdx(this.curP, this.curRc, this.curRr);
|
|
1456
1505
|
const lw = branchLogpMeanGathered(model, hn, chosenIdx, this.curChosenResp!, this.curChunk);
|
|
1457
1506
|
const lr = branchLogpMeanGathered(model, hn, rejectedIdx, this.curRejectedResp!, this.curChunk);
|
|
1507
|
+
// sft_scope:"full": prompt predictions from the same concat hiddens (see
|
|
1508
|
+
// SegmentedBackwardOrpoPrefix); ℓw/ℓr stay response-only.
|
|
1509
|
+
const nllFull = this.sftScope === "full"
|
|
1510
|
+
? prefixFullNll(model, hn, this.curPromptIds!, lw, this.curRc, this.curChunk)
|
|
1511
|
+
: null;
|
|
1458
1512
|
hn.dispose();
|
|
1459
|
-
const loss = orpoLossFromLogps(lw, lr, this.lambda);
|
|
1513
|
+
const loss = orpoLossFromLogps(lw, lr, this.lambda, nllFull ?? undefined);
|
|
1460
1514
|
lw.dispose(); lr.dispose();
|
|
1515
|
+
nllFull?.dispose();
|
|
1461
1516
|
return [loss];
|
|
1462
1517
|
}, 1);
|
|
1463
1518
|
|
|
@@ -1525,6 +1580,7 @@ export class SegmentedBackwardOrpoPrefixGemma4 {
|
|
|
1525
1580
|
if (P < 1 || Rc < 1 || Rr < 1) throw new Error("SegmentedBackwardOrpoPrefixGemma4: need P,Rc,Rr >= 1");
|
|
1526
1581
|
const T = P + Rc + Rr;
|
|
1527
1582
|
this.curP = P; this.curRc = Rc; this.curRr = Rr;
|
|
1583
|
+
this.curPromptIds = promptIds;
|
|
1528
1584
|
this.curChosenResp = chosenResp; this.curRejectedResp = rejectedResp;
|
|
1529
1585
|
this.headSink = [];
|
|
1530
1586
|
this.curChunk = this.chunkCtx ? { ...this.chunkCtx, sink: this.headSink } : undefined;
|
|
@@ -1640,7 +1696,7 @@ export class SegmentedBackwardOrpoPrefixGemma4 {
|
|
|
1640
1696
|
this.curPerLayer?.dispose();
|
|
1641
1697
|
this.curPerLayer = null;
|
|
1642
1698
|
if (this.curMasks) { for (const m of this.curMasks.values()) m.arr?.dispose(); this.curMasks = null; }
|
|
1643
|
-
this.curChosenResp = this.curRejectedResp = null;
|
|
1699
|
+
this.curPromptIds = this.curChosenResp = this.curRejectedResp = null;
|
|
1644
1700
|
for (const d of this.headSink) d.dispose();
|
|
1645
1701
|
this.headSink = [];
|
|
1646
1702
|
dhOut?.dispose();
|
package/src/train/trainer.ts
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
saveAdapter, disposeLora, warmStartFromAdapter, type TrainableLora, type SaveAdapterConfig,
|
|
35
35
|
} from "./lora-params";
|
|
36
36
|
import { resolveRanks, bitsMapFromModel, readPerLayerKl, DEFAULT_TARGET_MODULES, type RankScaling } from "./rank";
|
|
37
|
-
import { sftLoss, dpoLoss, dpoRefLogps, dpoMetrics, orpoLoss, orpoMetrics, type ChunkCtx } from "./loss";
|
|
37
|
+
import { sftLoss, dpoLoss, dpoRefLogps, dpoMetrics, orpoLoss, orpoMetrics, type ChunkCtx, type SftScope } from "./loss";
|
|
38
38
|
import { orpoLossPrefixShared, orpoLossPrefixSharedGemma, splitPrefixBatch } from "./prefix-shared";
|
|
39
39
|
import { AdamW, warmupCosineSchedule } from "./optimizer";
|
|
40
40
|
import { writeFileSync, appendFileSync, mkdirSync } from "node:fs";
|
|
@@ -134,6 +134,15 @@ export interface TrainConfig {
|
|
|
134
134
|
* differ. Composes with orpoFlashCe/orpoFusedCe (each branch routes through the
|
|
135
135
|
* [M,V]-free head). See src/train/prefix-shared.ts. */
|
|
136
136
|
orpoPrefixShared: boolean;
|
|
137
|
+
/** ORPO L_SFT scope (`sft_scope`). "full" (default; paper/TRL-faithful):
|
|
138
|
+
* the chosen-NLL term is the token-mean cross-entropy over the FULL
|
|
139
|
+
* prompt+response (only padding excluded), computed from the same chosen
|
|
140
|
+
* forward — matching TRL's ORPOTrainer chosen_nll_loss. "response":
|
|
141
|
+
* L_NLL = -ℓw (response-only), bit-exact to pre-sft_scope runs. The
|
|
142
|
+
* odds-ratio ℓw/ℓr terms are response-only length-normalized means in BOTH
|
|
143
|
+
* modes (that also matches TRL). Applies to every ORPO path (naive /
|
|
144
|
+
* chunked / fused / flash / prefix-shared / segmented). orpo only. */
|
|
145
|
+
sftScope: SftScope;
|
|
137
146
|
/** Warm-start: path to an existing adapter dir (with adapters.safetensors) whose
|
|
138
147
|
* LoRA weights initialize this run instead of the random/zero init. Continues
|
|
139
148
|
* training from a checkpoint's weights; the optimizer state and LR schedule restart.
|
|
@@ -188,6 +197,7 @@ export const DEFAULT_TRAIN_CONFIG: TrainConfig = {
|
|
|
188
197
|
orpoFusedCe: false, // default off; true = fused linear-CE head (analytic backward, no [M,vocab])
|
|
189
198
|
orpoFlashCe: false, // default off; true = flash-CCE Metal-kernel head (implies fused)
|
|
190
199
|
orpoPrefixShared: false, // default off; true = shared prompt-prefix single forward (B=1)
|
|
200
|
+
sftScope: "full", // paper/TRL-faithful chosen-NLL over prompt+response; "response" = pre-2026-07 behavior
|
|
191
201
|
warmStartAdapter: "", // default off; path to an adapter dir to warm-start LoRA weights from
|
|
192
202
|
adapterPath: "adapters",
|
|
193
203
|
baseModel: "",
|
|
@@ -298,6 +308,7 @@ export async function trainLora(
|
|
|
298
308
|
iters: cfg.iters, learning_rate: cfg.learningRate, max_seq_length: cfg.maxSeqLen,
|
|
299
309
|
batch_size: cfg.batchSize, rank: cfg.rank, scale: cfg.scale,
|
|
300
310
|
orpo_lambda: cfg.method === "orpo" ? cfg.orpoLambda : undefined,
|
|
311
|
+
sft_scope: cfg.method === "orpo" ? cfg.sftScope : undefined,
|
|
301
312
|
adapter_path: cfg.adapterPath,
|
|
302
313
|
});
|
|
303
314
|
|
|
@@ -349,6 +360,7 @@ export async function trainLora(
|
|
|
349
360
|
segmentSize: cfg.segmentSize, iters: cfg.iters,
|
|
350
361
|
numLayers: cfg.numLayers, baseModel: cfg.baseModel,
|
|
351
362
|
orpoLambda: cfg.method === "orpo" ? cfg.orpoLambda : undefined,
|
|
363
|
+
sftScope: cfg.method === "orpo" ? cfg.sftScope : undefined,
|
|
352
364
|
},
|
|
353
365
|
wallSeconds: (Date.now() - startedAt) / 1000,
|
|
354
366
|
peakGb: peakMemory() / 1e9,
|
|
@@ -466,14 +478,40 @@ async function sftLoop(
|
|
|
466
478
|
// dQ-causal-barrier bugs were fixed; FD-validated in
|
|
467
479
|
// scripts/experiments/flash-fd-check.ts), though ~30× slower than ops.sdpa.
|
|
468
480
|
// Cleared in finally.
|
|
469
|
-
|
|
481
|
+
//
|
|
482
|
+
// GEMMA GUARD (same convention as the PERF_KERNEL/FUSED_GELU sanitization in
|
|
483
|
+
// cli.ts / job.ts, but enforced at the trainer): e4b on this path SIGTRAPed
|
|
484
|
+
// (uncatchable native crash) at multi-K sequence lengths (>=2K, reproduced in
|
|
485
|
+
// scripts/experiments/segmented-grad-test-e4b.ts; docs/reference/training.md)
|
|
486
|
+
// and has NOT been re-validated at that scale since the two kernel fixes —
|
|
487
|
+
// the regression tests stop at T<=256. cli.ts even defaults e4b seq to 8192,
|
|
488
|
+
// so a stale `export MLX_BUN_TRAIN_ATTN=flash` from a MiniCPM5 experiment
|
|
489
|
+
// would otherwise ride silently into a crash mid-run. Refuse it for Gemma
|
|
490
|
+
// until the >=2K re-validation lands; MiniCPM5 stays allowed.
|
|
491
|
+
if (process.env.MLX_BUN_TRAIN_ATTN === "flash") {
|
|
492
|
+
if (model instanceof Gemma4Model)
|
|
493
|
+
throw new Error(
|
|
494
|
+
"MLX_BUN_TRAIN_ATTN=flash is disabled for Gemma models: e4b SIGTRAPs on this " +
|
|
495
|
+
"path at seq >= 2048 (docs/reference/training.md) and it has not been " +
|
|
496
|
+
"re-validated at that scale since the kernel fixes. Unset MLX_BUN_TRAIN_ATTN " +
|
|
497
|
+
"(ops.sdpa, the default, is exact and ~30x faster) or train MiniCPM5.",
|
|
498
|
+
);
|
|
499
|
+
emit({ type: "stage", stage: "setup", progress: 0.045,
|
|
500
|
+
message: "training attention: hand-rolled flash kernel (MLX_BUN_TRAIN_ATTN=flash; O(L) memory, ~30x slower than ops.sdpa)" });
|
|
501
|
+
setTrainingAttn("flash");
|
|
502
|
+
}
|
|
470
503
|
|
|
471
504
|
// Pad with the eos id when available (a real sentinel token); padded
|
|
472
505
|
// positions are excluded from both the loss and attention masks, so the
|
|
473
506
|
// exact pad value never affects the result — it just must be a valid id.
|
|
474
507
|
const padId = tok.eosTokenId ?? 0;
|
|
475
508
|
const batches = iterateSftBatches(train, cfg.batchSize, cfg.maxSeqLen, cfg.seed, true, padId);
|
|
476
|
-
|
|
509
|
+
// tokens/sec follows mlx-lm's tuner convention: accumulate the response-token
|
|
510
|
+
// count across the REPORT WINDOW and divide by the window's own wall time
|
|
511
|
+
// (validation time excluded) — not one step's count extrapolated over the
|
|
512
|
+
// whole run, which jumps ~10x step-to-step on variable-length data.
|
|
513
|
+
let windowTokens = 0;
|
|
514
|
+
let windowStart = Date.now();
|
|
477
515
|
|
|
478
516
|
try {
|
|
479
517
|
for (let step = 1; step <= cfg.iters; step++) {
|
|
@@ -483,12 +521,11 @@ async function sftLoop(
|
|
|
483
521
|
// when 1). afterMicroEval frees each micro-batch's gradient-checkpoint
|
|
484
522
|
// closures right after its backward so they don't pile up across
|
|
485
523
|
// accumulation.
|
|
486
|
-
let tokensThisStep = 0;
|
|
487
524
|
const { loss: lossVal, grads } = accumulateStep(
|
|
488
525
|
cfg.gradAccumSteps,
|
|
489
526
|
() => {
|
|
490
527
|
currentBatch = batches.next().value as SftBatch;
|
|
491
|
-
|
|
528
|
+
windowTokens += countResponseTokens(currentBatch);
|
|
492
529
|
return segmented ? segmented.step(currentBatch!) : vag!.apply(flatParams(lora));
|
|
493
530
|
},
|
|
494
531
|
ckptCtx
|
|
@@ -512,7 +549,7 @@ async function sftLoop(
|
|
|
512
549
|
}
|
|
513
550
|
|
|
514
551
|
if (step % cfg.stepsPerReport === 0 || step === 1) {
|
|
515
|
-
const elapsed = (Date.now() -
|
|
552
|
+
const elapsed = (Date.now() - windowStart) / 1000;
|
|
516
553
|
emit({
|
|
517
554
|
type: "metric",
|
|
518
555
|
kind: "train",
|
|
@@ -520,19 +557,31 @@ async function sftLoop(
|
|
|
520
557
|
loss: lossVal,
|
|
521
558
|
grad_norm: gradNorm,
|
|
522
559
|
learning_rate: opt.lr,
|
|
523
|
-
tokens_per_sec: elapsed > 0 ?
|
|
560
|
+
tokens_per_sec: elapsed > 0 ? windowTokens / elapsed : 0,
|
|
524
561
|
progress: step / cfg.iters,
|
|
525
562
|
message: `step ${step}/${cfg.iters} loss=${lossVal.toFixed(4)}`,
|
|
526
563
|
});
|
|
564
|
+
windowTokens = 0;
|
|
565
|
+
windowStart = Date.now();
|
|
527
566
|
}
|
|
528
567
|
|
|
529
568
|
if (valid.length > 0 && step % cfg.stepsPerEval === 0) {
|
|
530
569
|
// Eval is forward-only (no backward), so disable checkpointing for it —
|
|
531
570
|
// it adds no benefit and a forward-only pass fits without it.
|
|
532
571
|
if (ckptCtx) (model as Gemma4Model).gradCkpt = null;
|
|
533
|
-
const
|
|
572
|
+
const tVal = Date.now();
|
|
573
|
+
const { loss: vLoss, used, skipped } = evalSftLoss(model, valid, cfg);
|
|
534
574
|
if (ckptCtx) (model as Gemma4Model).gradCkpt = ckptCtx;
|
|
535
|
-
|
|
575
|
+
// A silently-shrinking val set must be visible: rows whose supervised
|
|
576
|
+
// span is empty after truncation are skipped BY DESIGN (mlx-lm drops
|
|
577
|
+
// them at dataset build time) — report how many, and over what n the
|
|
578
|
+
// mean was taken. Any other per-row failure now rethrows (see
|
|
579
|
+
// evalSftLoss).
|
|
580
|
+
emit({ type: "metric", kind: "val", step, loss: vLoss, progress: step / cfg.iters,
|
|
581
|
+
val_rows_used: used, val_rows_skipped: skipped,
|
|
582
|
+
...(skipped > 0 ? { message: `val loss over ${used} rows (${skipped} skipped: no response tokens to supervise)` } : {}) });
|
|
583
|
+
// validation wall time is not training throughput — keep it out of the window
|
|
584
|
+
windowStart += Date.now() - tVal;
|
|
536
585
|
}
|
|
537
586
|
}
|
|
538
587
|
} finally {
|
|
@@ -548,13 +597,16 @@ async function sftLoop(
|
|
|
548
597
|
return { numIters: cfg.iters };
|
|
549
598
|
}
|
|
550
599
|
|
|
551
|
-
function evalSftLoss(
|
|
600
|
+
function evalSftLoss(
|
|
601
|
+
model: RuntimeModel, valid: ReturnType<typeof Array.prototype.slice>, cfg: TrainConfig,
|
|
602
|
+
): { loss: number; used: number; skipped: number } {
|
|
552
603
|
let total = 0;
|
|
553
604
|
let n = 0;
|
|
605
|
+
let skipped = 0;
|
|
554
606
|
for (const ex of valid as { ids: number[]; promptLen: number }[]) {
|
|
555
607
|
let ids = ex.ids;
|
|
556
608
|
let promptLen = ex.promptLen;
|
|
557
|
-
if (ids.length < 2) continue;
|
|
609
|
+
if (ids.length < 2) { skipped++; continue; }
|
|
558
610
|
if (ids.length > cfg.maxSeqLen) {
|
|
559
611
|
ids = ids.slice(0, cfg.maxSeqLen);
|
|
560
612
|
promptLen = Math.min(promptLen, Math.max(0, ids.length - 1));
|
|
@@ -565,12 +617,18 @@ function evalSftLoss(model: RuntimeModel, valid: ReturnType<typeof Array.prototy
|
|
|
565
617
|
total += loss.toFloat32()[0]!;
|
|
566
618
|
loss.dispose();
|
|
567
619
|
n++;
|
|
568
|
-
} catch {
|
|
569
|
-
//
|
|
620
|
+
} catch (e) {
|
|
621
|
+
// ONLY the empty-supervision row is skippable (a truncated row whose
|
|
622
|
+
// response fell entirely past maxSeqLen — mlx-lm drops these at dataset
|
|
623
|
+
// build). Everything else (MLX OOM/shape/vjp errors surface as catchable
|
|
624
|
+
// JS Errors via the ffi handler) must rethrow: swallowing them silently
|
|
625
|
+
// reduces the val set to its easy rows and reports a plausible loss.
|
|
626
|
+
if (e instanceof Error && e.message === "sftLoss: no response tokens to supervise") skipped++;
|
|
627
|
+
else throw e;
|
|
570
628
|
}
|
|
571
629
|
clearCache();
|
|
572
630
|
}
|
|
573
|
-
return n > 0 ? total / n : 0;
|
|
631
|
+
return { loss: n > 0 ? total / n : 0, used: n, skipped };
|
|
574
632
|
}
|
|
575
633
|
|
|
576
634
|
// ---------------------------------------------------------------------------
|
|
@@ -780,18 +838,18 @@ async function orpoLoop(
|
|
|
780
838
|
// plain segmented-ORPO fallback for rows whose chosen/rejected prompts differ
|
|
781
839
|
// (splitPrefixBatch returns null -> two-forward segmented step).
|
|
782
840
|
if (model instanceof MiniCPM5Model) {
|
|
783
|
-
segmentedOrpoPrefix = new SegmentedBackwardOrpoPrefix(model, lora, ranges, cfg.orpoLambda, segPrefixChunk);
|
|
784
|
-
segmentedOrpo = new SegmentedBackwardOrpo(model, lora, ranges, cfg.orpoLambda, segFusedChunk);
|
|
841
|
+
segmentedOrpoPrefix = new SegmentedBackwardOrpoPrefix(model, lora, ranges, cfg.orpoLambda, segPrefixChunk, cfg.sftScope);
|
|
842
|
+
segmentedOrpo = new SegmentedBackwardOrpo(model, lora, ranges, cfg.orpoLambda, segFusedChunk, cfg.sftScope);
|
|
785
843
|
} else {
|
|
786
|
-
segmentedOrpoPrefix = new SegmentedBackwardOrpoPrefixGemma4(model as Gemma4Model, lora, ranges, cfg.orpoLambda, segPrefixChunk);
|
|
787
|
-
segmentedOrpo = new SegmentedBackwardOrpoGemma4(model as Gemma4Model, lora, ranges, cfg.orpoLambda, segFusedChunk);
|
|
844
|
+
segmentedOrpoPrefix = new SegmentedBackwardOrpoPrefixGemma4(model as Gemma4Model, lora, ranges, cfg.orpoLambda, segPrefixChunk, cfg.sftScope);
|
|
845
|
+
segmentedOrpo = new SegmentedBackwardOrpoGemma4(model as Gemma4Model, lora, ranges, cfg.orpoLambda, segFusedChunk, cfg.sftScope);
|
|
788
846
|
}
|
|
789
847
|
emit({ type: "stage", stage: "setup", progress: 0.04,
|
|
790
848
|
message: `orpo segmented + prefix-share (${model instanceof Gemma4Model ? "e4b" : "MiniCPM5"}): single concat forward streamed over ${Math.ceil(model.layers.length / cfg.segmentSize)} segments of <=${cfg.segmentSize} layers (two-forward fallback on prompt mismatch)` });
|
|
791
849
|
} else if (model instanceof MiniCPM5Model)
|
|
792
|
-
segmentedOrpo = new SegmentedBackwardOrpo(model, lora, ranges, cfg.orpoLambda, segFusedChunk);
|
|
850
|
+
segmentedOrpo = new SegmentedBackwardOrpo(model, lora, ranges, cfg.orpoLambda, segFusedChunk, cfg.sftScope);
|
|
793
851
|
else if (model instanceof Gemma4Model)
|
|
794
|
-
segmentedOrpo = new SegmentedBackwardOrpoGemma4(model, lora, ranges, cfg.orpoLambda, segFusedChunk);
|
|
852
|
+
segmentedOrpo = new SegmentedBackwardOrpoGemma4(model, lora, ranges, cfg.orpoLambda, segFusedChunk, cfg.sftScope);
|
|
795
853
|
else
|
|
796
854
|
throw new Error("segmented backward (segmentSize > 0) for ORPO is only wired for MiniCPM5 and Gemma4");
|
|
797
855
|
if (!useSegmentedPrefix) emit({ type: "stage", stage: "setup", progress: 0.04,
|
|
@@ -837,12 +895,12 @@ async function orpoLoop(
|
|
|
837
895
|
const split = splitPrefixBatch(currentBatch!);
|
|
838
896
|
if (split) {
|
|
839
897
|
if (model instanceof MiniCPM5Model)
|
|
840
|
-
return orpoLossPrefixShared(model, split.promptIds, split.chosenResp, split.rejectedResp, cfg.orpoLambda, chunk);
|
|
841
|
-
return orpoLossPrefixSharedGemma(model as Gemma4Model, split.promptIds, split.chosenResp, split.rejectedResp, cfg.orpoLambda, chunk);
|
|
898
|
+
return orpoLossPrefixShared(model, split.promptIds, split.chosenResp, split.rejectedResp, cfg.orpoLambda, chunk, cfg.sftScope);
|
|
899
|
+
return orpoLossPrefixSharedGemma(model as Gemma4Model, split.promptIds, split.chosenResp, split.rejectedResp, cfg.orpoLambda, chunk, cfg.sftScope);
|
|
842
900
|
}
|
|
843
901
|
notePrefixFallback();
|
|
844
902
|
}
|
|
845
|
-
return orpoLoss(model, currentBatch!, cfg.orpoLambda, chunk);
|
|
903
|
+
return orpoLoss(model, currentBatch!, cfg.orpoLambda, chunk, cfg.sftScope);
|
|
846
904
|
} finally {
|
|
847
905
|
restorePrimals(lora, saved);
|
|
848
906
|
}
|
|
@@ -897,7 +955,7 @@ async function orpoLoop(
|
|
|
897
955
|
// Diagnostics recompute the two forwards (like dpoMetrics) — cheap at
|
|
898
956
|
// B=1 (response-only head, no full logits) and gated by stepsPerReport.
|
|
899
957
|
// Reusing the value_and_grad forward is a shared-with-DPO follow-on.
|
|
900
|
-
const m = orpoMetrics(model, currentBatch!, cfg.orpoLambda);
|
|
958
|
+
const m = orpoMetrics(model, currentBatch!, cfg.orpoLambda, cfg.sftScope);
|
|
901
959
|
const elapsed = (Date.now() - t0) / 1000;
|
|
902
960
|
emit({
|
|
903
961
|
type: "metric",
|
|
@@ -929,7 +987,7 @@ async function orpoLoop(
|
|
|
929
987
|
chosenIds: [ex.chosenIds], rejectedIds: [ex.rejectedIds],
|
|
930
988
|
chosenMask: [ex.chosenMask], rejectedMask: [ex.rejectedMask],
|
|
931
989
|
};
|
|
932
|
-
const m = orpoMetrics(model, vb, cfg.orpoLambda);
|
|
990
|
+
const m = orpoMetrics(model, vb, cfg.orpoLambda, cfg.sftScope);
|
|
933
991
|
vl += m.loss; va += m.accuracy; vm += m.margin; vn++;
|
|
934
992
|
clearCache();
|
|
935
993
|
}
|
|
@@ -1105,10 +1163,16 @@ function clipGradsByNorm(grads: MlxArray[], normVal: number, maxNorm: number): v
|
|
|
1105
1163
|
}
|
|
1106
1164
|
}
|
|
1107
1165
|
|
|
1166
|
+
/** Supervised (response) tokens across ALL rows of the batch — the count
|
|
1167
|
+
* tokens/sec is reported over (mlx-lm's n_tokens counts every row too). */
|
|
1108
1168
|
function countResponseTokens(batch: SftBatch): number {
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1169
|
+
let total = 0;
|
|
1170
|
+
for (let r = 0; r < batch.ids.length; r++) {
|
|
1171
|
+
const promptLen = batch.promptLens[r]!;
|
|
1172
|
+
const len = batch.ids[r]!.length;
|
|
1173
|
+
total += Math.max(0, len - Math.max(promptLen, 1));
|
|
1174
|
+
}
|
|
1175
|
+
return total;
|
|
1112
1176
|
}
|
|
1113
1177
|
|
|
1114
1178
|
async function fileExists(path: string): Promise<boolean> {
|