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/loss.ts
CHANGED
|
@@ -447,17 +447,108 @@ export function dpoMetrics(
|
|
|
447
447
|
//
|
|
448
448
|
// Reference-FREE: one objective, no reference model (vs DPO's scale-0 ref pass).
|
|
449
449
|
// L = L_NLL(chosen) + λ · L_OR
|
|
450
|
-
// L_NLL =
|
|
450
|
+
// L_NLL = the chosen-sequence SFT term (unweighted); scope selected by sft_scope:
|
|
451
|
+
// "full" (default in TrainConfig; paper/TRL-faithful): mean cross-entropy
|
|
452
|
+
// over ALL non-pad supervised positions of the chosen sequence —
|
|
453
|
+
// prompt AND response (TRL masks only padding to -100 for the
|
|
454
|
+
// chosen_nll_loss; nn.CrossEntropyLoss reduction="mean").
|
|
455
|
+
// "response" (the pre-2026-07 mlx-bun behavior, bit-exact): -ℓw, the negative
|
|
456
|
+
// mean response log-prob.
|
|
451
457
|
// L_OR = -log σ(log_odds)
|
|
452
458
|
// log_odds = (ℓw - ℓr) - (log1mexp(ℓw) - log1mexp(ℓr))
|
|
453
459
|
// where ℓ is the LENGTH-NORMALIZED (mean over response tokens) log-prob — the
|
|
454
|
-
// paper's choice (and TRL's), distinct from DPO's summed seqLogp.
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
460
|
+
// paper's choice (and TRL's), distinct from DPO's summed seqLogp. CRITICAL:
|
|
461
|
+
// ℓw/ℓr stay RESPONSE-ONLY in BOTH sft_scope modes (TRL's get_batch_logps uses
|
|
462
|
+
// prompt-masked labels for the odds ratio; only the NLL term is full-sequence).
|
|
463
|
+
// Two facts shape the code: (1) ℓw feeds BOTH terms (under "full" the chosen
|
|
464
|
+
// forward additionally yields the prompt-position log-probs — same forward, the
|
|
465
|
+
// head just runs over the extended span); (2) only 2 forwards/step (chosen +
|
|
466
|
+
// rejected), both with grad, no reference. The REJECTED branch never needs
|
|
467
|
+
// prompt logps. This is the naïve full-logits oracle path — the chunked /
|
|
458
468
|
// response-only / segmented tiers validate against it (see the design's oracle
|
|
459
469
|
// ladder, docs/design/orpo-training.md).
|
|
460
470
|
|
|
471
|
+
/** SFT-term scope for ORPO's L_NLL(chosen): "full" = prompt+response token-mean
|
|
472
|
+
* CE (paper/TRL-faithful); "response" = -ℓw (the pre-change behavior). The
|
|
473
|
+
* odds-ratio ℓ terms are response-only in both modes. */
|
|
474
|
+
export type SftScope = "full" | "response";
|
|
475
|
+
|
|
476
|
+
/** A contiguous supervised input-position span [startT, startT+M): position t
|
|
477
|
+
* predicts token ids[t+1]. The response span comes from the mask; "full" mode
|
|
478
|
+
* additionally runs the head over the prompt span {startT: 0, M: respStartT}. */
|
|
479
|
+
export interface HeadSpan { startT: number; M: number }
|
|
480
|
+
|
|
481
|
+
/** The response span from a DPO-row mask (mask[t+1]==1 ⇔ input position t is
|
|
482
|
+
* supervised): first supervised position + count. M<=0 ⇔ no response. */
|
|
483
|
+
export function respSpanFromMask(mask: number[], T: number): HeadSpan {
|
|
484
|
+
let startT = -1;
|
|
485
|
+
let M = 0;
|
|
486
|
+
for (let t = 0; t < T; t++) if (mask[t + 1]) { if (startT < 0) startT = t; M++; }
|
|
487
|
+
return { startT, M };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Length-normalized mean log-prob over the contiguous input-position span
|
|
491
|
+
* [s0, s0+n) of `h [1, T, hidden]` (post-finalNorm), scoring targets
|
|
492
|
+
* ids[s0+1 .. s0+n]. The span-parameterized generalization of
|
|
493
|
+
* responseOnlyLogpMean (same ops); used for the prompt span under
|
|
494
|
+
* sft_scope:"full". Whole-vocab autograd head. Returns [1] (caller owns);
|
|
495
|
+
* disposes nothing it didn't create. */
|
|
496
|
+
export function spanLogpMeanFromHidden(
|
|
497
|
+
model: { logitsFromHidden(h: MlxArray): MlxArray },
|
|
498
|
+
h: MlxArray, // [1, T, hidden] post-finalNorm
|
|
499
|
+
ids: number[],
|
|
500
|
+
s0: number,
|
|
501
|
+
n: number,
|
|
502
|
+
): MlxArray {
|
|
503
|
+
if (n <= 0) return MlxArray.fromFloat32(new Float32Array([0]), [1]);
|
|
504
|
+
const hidden = h.shape[2]!;
|
|
505
|
+
const start = MlxArray.fromInt32(new Int32Array([s0]), [1]);
|
|
506
|
+
const hSpan = ops.sliceDynamic(h, start, [1], [1, n, hidden]); // [1, n, hidden]
|
|
507
|
+
const logits = model.logitsFromHidden(hSpan); // [1, n, V]
|
|
508
|
+
const V = logits.shape[2]!;
|
|
509
|
+
const logits2d = ops.reshape(logits, [n, V]);
|
|
510
|
+
|
|
511
|
+
const targetsHost = new Int32Array(n);
|
|
512
|
+
for (let i = 0; i < n; i++) targetsHost[i] = ids[s0 + 1 + i]!;
|
|
513
|
+
const targets = MlxArray.fromInt32(targetsHost, [n, 1]);
|
|
514
|
+
|
|
515
|
+
const lse = ops.logsumexpAxis(logits2d, -1, false); // [n]
|
|
516
|
+
const gathered = ops.takeAlongAxis(logits2d, targets, -1); // [n, 1]
|
|
517
|
+
const picked = ops.reshape(gathered, [n]); // [n]
|
|
518
|
+
const logp = ops.sub(picked, lse); // [n] log_softmax at the label
|
|
519
|
+
const logpF = logp.dtype === Dtype.float32 ? logp : logp.astype(Dtype.float32);
|
|
520
|
+
const sumLogp = ops.sumAxis(logpF, 0, false); // scalar
|
|
521
|
+
const meanScalar = ops.mulScalar(sumLogp, 1 / n);
|
|
522
|
+
const mean = ops.reshape(meanScalar, [1]); // [1]
|
|
523
|
+
|
|
524
|
+
for (const a of [start, hSpan, logits, logits2d, targets, lse, gathered, picked, logp, sumLogp, meanScalar]) a.dispose();
|
|
525
|
+
if (logpF !== logp) logpF.dispose();
|
|
526
|
+
return mean;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Combine the prompt-span mean logp (`promptMean`, over nPrompt positions) and
|
|
530
|
+
* the response mean logp (`lw`, over nResp positions) into the FULL-scope chosen
|
|
531
|
+
* NLL: `-(nPrompt·promptMean + nResp·lw) / (nPrompt + nResp)` — the token-mean
|
|
532
|
+
* CE over all prompt+response supervised positions (nn.CrossEntropyLoss
|
|
533
|
+
* reduction="mean" over the label-shifted non-pad positions). Graph-preserving
|
|
534
|
+
* (pure ops on the two means), so the head gradient flows to BOTH spans. Does
|
|
535
|
+
* NOT dispose promptMean/lw (caller owns); returns [1] (caller owns). */
|
|
536
|
+
export function combineFullNll(
|
|
537
|
+
promptMean: MlxArray | null,
|
|
538
|
+
lw: MlxArray,
|
|
539
|
+
nPrompt: number,
|
|
540
|
+
nResp: number,
|
|
541
|
+
): MlxArray {
|
|
542
|
+
if (!promptMean || nPrompt <= 0) return ops.neg(lw); // no prompt predictions → full == response
|
|
543
|
+
const ps = ops.mulScalar(promptMean, nPrompt); // Σ prompt logp (reconstructed)
|
|
544
|
+
const rs = ops.mulScalar(lw, nResp); // Σ response logp (reconstructed)
|
|
545
|
+
const sum = ops.add(ps, rs);
|
|
546
|
+
const mean = ops.mulScalar(sum, 1 / (nPrompt + nResp));
|
|
547
|
+
const out = ops.neg(mean);
|
|
548
|
+
for (const a of [ps, rs, sum, mean]) a.dispose();
|
|
549
|
+
return out;
|
|
550
|
+
}
|
|
551
|
+
|
|
461
552
|
/** Per-row LENGTH-NORMALIZED response log-prob for one branch (B>=1): the
|
|
462
553
|
* summed response logp divided by the per-row response-token count. Pad/prompt
|
|
463
554
|
* positions contribute 0 (mask is 0 there) and never inflate the count.
|
|
@@ -654,12 +745,11 @@ export function chunkedLogpMeanFromHidden(
|
|
|
654
745
|
mask: number[],
|
|
655
746
|
chunkSize: number,
|
|
656
747
|
sink: Array<{ dispose(): void }>,
|
|
748
|
+
span?: HeadSpan, // override the mask-derived response span (sft_scope:"full" prompt span)
|
|
657
749
|
): MlxArray {
|
|
658
750
|
const L = ids.length;
|
|
659
751
|
const T = L - 1;
|
|
660
|
-
|
|
661
|
-
let M = 0;
|
|
662
|
-
for (let t = 0; t < T; t++) if (mask[t + 1]) { if (startT < 0) startT = t; M++; }
|
|
752
|
+
const { startT, M } = span ?? respSpanFromMask(mask, T);
|
|
663
753
|
if (M <= 0) return MlxArray.fromFloat32(new Float32Array([0]), [1]);
|
|
664
754
|
const hidden = h.shape[2]!;
|
|
665
755
|
|
|
@@ -779,12 +869,11 @@ export function fusedLogpMeanFromHidden(
|
|
|
779
869
|
sink: Array<{ dispose(): void }>,
|
|
780
870
|
vocabBlock = 0,
|
|
781
871
|
flash = false,
|
|
872
|
+
span?: HeadSpan, // override the mask-derived response span (sft_scope:"full" prompt span)
|
|
782
873
|
): MlxArray {
|
|
783
874
|
const L = ids.length;
|
|
784
875
|
const T = L - 1;
|
|
785
|
-
|
|
786
|
-
let M = 0;
|
|
787
|
-
for (let t = 0; t < T; t++) if (mask[t + 1]) { if (startT < 0) startT = t; M++; }
|
|
876
|
+
const { startT, M } = span ?? respSpanFromMask(mask, T);
|
|
788
877
|
if (M <= 0) return MlxArray.fromFloat32(new Float32Array([0]), [1]);
|
|
789
878
|
const hidden = h.shape[2]!;
|
|
790
879
|
const startArr = MlxArray.fromInt32(new Int32Array([startT]), [1]);
|
|
@@ -1214,56 +1303,225 @@ function orpoLogOdds(lw: MlxArray, lr: MlxArray): MlxArray {
|
|
|
1214
1303
|
return logOdds;
|
|
1215
1304
|
}
|
|
1216
1305
|
|
|
1306
|
+
/** The chosen branch under sft_scope:"full" (B=1): ONE forward yields both the
|
|
1307
|
+
* response-only mean logp ℓw (fed to the odds ratio — computed exactly as the
|
|
1308
|
+
* "response" path computes it, same span/head) AND the full-scope chosen NLL
|
|
1309
|
+
* (the head additionally runs over the PROMPT span [0, startT), combined via
|
|
1310
|
+
* combineFullNll). No third forward: the hidden states already cover the
|
|
1311
|
+
* prompt. Routes both spans through the same head tier the response path uses
|
|
1312
|
+
* (flash/fused CustomVjp, Checkpoint token-chunks, or the whole-vocab autograd
|
|
1313
|
+
* head), so the memory bounds hold for the extended span too. Caller owns both
|
|
1314
|
+
* returned arrays ([1] each). */
|
|
1315
|
+
function chosenLogpMeanAndFullNllB1(
|
|
1316
|
+
model: RuntimeModel,
|
|
1317
|
+
ids: number[],
|
|
1318
|
+
mask: number[],
|
|
1319
|
+
validLen: number,
|
|
1320
|
+
chunk?: ChunkCtx,
|
|
1321
|
+
): { lw: MlxArray; nllFull: MlxArray } {
|
|
1322
|
+
const L = ids.length;
|
|
1323
|
+
const T = L - 1;
|
|
1324
|
+
const { startT, M } = respSpanFromMask(mask, T);
|
|
1325
|
+
if (M <= 0)
|
|
1326
|
+
return {
|
|
1327
|
+
lw: MlxArray.fromFloat32(new Float32Array([0]), [1]),
|
|
1328
|
+
nllFull: MlxArray.fromFloat32(new Float32Array([0]), [1]),
|
|
1329
|
+
};
|
|
1330
|
+
|
|
1331
|
+
const inputHost = new Int32Array(T);
|
|
1332
|
+
for (let t = 0; t < T; t++) inputHost[t] = ids[t]!;
|
|
1333
|
+
const inputIds = MlxArray.fromInt32(inputHost, [1, T]);
|
|
1334
|
+
const inputValid = [Math.max(0, Math.min(validLen, L) - 1)];
|
|
1335
|
+
const h = trainForwardHidden(model, inputIds, inputValid); // [1, T, hidden]
|
|
1336
|
+
inputIds.dispose();
|
|
1337
|
+
|
|
1338
|
+
const useFused = !!chunk && chunk.chunkSize > 0 && !!(chunk.fused || chunk.flash);
|
|
1339
|
+
const useChunked = !!chunk && chunk.chunkSize > 0 && !useFused;
|
|
1340
|
+
let lw: MlxArray;
|
|
1341
|
+
let pm: MlxArray | null = null; // prompt-span mean logp (positions [0, startT))
|
|
1342
|
+
if (useFused) {
|
|
1343
|
+
chunk!.sink.push(h); // CustomVjp/flash primal source — alive until post-eval
|
|
1344
|
+
lw = fusedLogpMeanFromHidden(model, h, ids, mask, chunk!.chunkSize, chunk!.sink, 0, chunk!.flash ?? false, { startT, M });
|
|
1345
|
+
if (startT > 0)
|
|
1346
|
+
pm = fusedLogpMeanFromHidden(model, h, ids, mask, chunk!.chunkSize, chunk!.sink, 0, chunk!.flash ?? false, { startT: 0, M: startT });
|
|
1347
|
+
} else if (useChunked) {
|
|
1348
|
+
chunk!.sink.push(h); // Checkpoint input — alive until post-eval
|
|
1349
|
+
lw = chunkedLogpMeanFromHidden(model, h, ids, mask, chunk!.chunkSize, chunk!.sink, { startT, M });
|
|
1350
|
+
if (startT > 0)
|
|
1351
|
+
pm = chunkedLogpMeanFromHidden(model, h, ids, mask, chunk!.chunkSize, chunk!.sink, { startT: 0, M: startT });
|
|
1352
|
+
} else {
|
|
1353
|
+
lw = spanLogpMeanFromHidden(model, h, ids, startT, M);
|
|
1354
|
+
if (startT > 0) pm = spanLogpMeanFromHidden(model, h, ids, 0, startT);
|
|
1355
|
+
h.dispose(); // whole-vocab path builds a normal autograd graph — the loss retains it
|
|
1356
|
+
}
|
|
1357
|
+
const nllFull = combineFullNll(pm, lw, startT, M);
|
|
1358
|
+
pm?.dispose();
|
|
1359
|
+
return { lw, nllFull };
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/** The chosen branch under sft_scope:"full" for B>1 (the batched full-logits
|
|
1363
|
+
* path): one forward, one label-logp pass, then TWO masked reductions —
|
|
1364
|
+
* per-row response means for ℓw [B] (identical positions/count to the
|
|
1365
|
+
* "response" path) and the flattened token-mean full NLL over all non-pad
|
|
1366
|
+
* supervised positions across the batch (nn.CrossEntropyLoss
|
|
1367
|
+
* reduction="mean"). Caller owns both returned arrays. */
|
|
1368
|
+
function chosenLogpMeanAndFullNllBatch(
|
|
1369
|
+
model: RuntimeModel,
|
|
1370
|
+
ids: number[][],
|
|
1371
|
+
mask: number[][],
|
|
1372
|
+
validLen: number[],
|
|
1373
|
+
): { lw: MlxArray; nllFull: MlxArray } {
|
|
1374
|
+
const B = ids.length;
|
|
1375
|
+
const L = ids[0]!.length;
|
|
1376
|
+
const T = L - 1;
|
|
1377
|
+
const inputHost = new Int32Array(B * T);
|
|
1378
|
+
for (let b = 0; b < B; b++)
|
|
1379
|
+
for (let t = 0; t < T; t++) inputHost[b * T + t] = ids[b]![t]!;
|
|
1380
|
+
const inputIds = MlxArray.fromInt32(inputHost, [B, T]);
|
|
1381
|
+
const inputValid = validLen.map((v) => Math.max(0, Math.min(v, L) - 1));
|
|
1382
|
+
const logits = trainForward(model, inputIds, inputValid); // [B, T, V]
|
|
1383
|
+
inputIds.dispose();
|
|
1384
|
+
const V = logits.shape[2]!;
|
|
1385
|
+
const N = B * T;
|
|
1386
|
+
|
|
1387
|
+
const targetsHost = new Int32Array(N);
|
|
1388
|
+
const respMaskHost = new Float32Array(N);
|
|
1389
|
+
const fullMaskHost = new Float32Array(N);
|
|
1390
|
+
const respRecip = new Float32Array(B);
|
|
1391
|
+
let fullCount = 0;
|
|
1392
|
+
for (let b = 0; b < B; b++) {
|
|
1393
|
+
const len = Math.min(validLen[b]!, L);
|
|
1394
|
+
let c = 0;
|
|
1395
|
+
for (let t = 0; t < T; t++) {
|
|
1396
|
+
const flat = b * T + t;
|
|
1397
|
+
targetsHost[flat] = ids[b]![t + 1]!;
|
|
1398
|
+
respMaskHost[flat] = mask[b]![t + 1]!; // resp_mask[:, 1:]
|
|
1399
|
+
c += mask[b]![t + 1]!;
|
|
1400
|
+
// full scope: every non-pad supervised position (prompt + response),
|
|
1401
|
+
// i.e. the predicted token ids[t+1] is a real token (t+1 < len).
|
|
1402
|
+
if (t + 1 < len) { fullMaskHost[flat] = 1; fullCount++; }
|
|
1403
|
+
}
|
|
1404
|
+
respRecip[b] = 1 / Math.max(c, 1);
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
const logits2d = ops.reshape(logits, [N, V]);
|
|
1408
|
+
logits.dispose();
|
|
1409
|
+
const targets = MlxArray.fromInt32(targetsHost, [N, 1]);
|
|
1410
|
+
const lse = ops.logsumexpAxis(logits2d, -1, false); // [N]
|
|
1411
|
+
const gathered = ops.takeAlongAxis(logits2d, targets, -1); // [N, 1]
|
|
1412
|
+
const picked = ops.reshape(gathered, [N]); // [N]
|
|
1413
|
+
const logp = ops.sub(picked, lse); // [N] log_softmax at the label
|
|
1414
|
+
const logpF = logp.dtype === Dtype.float32 ? logp : logp.astype(Dtype.float32);
|
|
1415
|
+
|
|
1416
|
+
// ℓw: per-row response length-normalized mean (same as branchLogpMean B>1).
|
|
1417
|
+
const rm = MlxArray.fromFloat32(respMaskHost, [N]);
|
|
1418
|
+
const respMasked = ops.mul(logpF, rm); // [N]
|
|
1419
|
+
const resp2d = ops.reshape(respMasked, [B, T]);
|
|
1420
|
+
const respSum = ops.sumAxis(resp2d, 1, false); // [B]
|
|
1421
|
+
const recipArr = MlxArray.fromFloat32(respRecip, [B]);
|
|
1422
|
+
const lw = ops.mul(respSum, recipArr); // [B]
|
|
1423
|
+
|
|
1424
|
+
// Full NLL: token-mean over ALL non-pad supervised positions, batch-flattened.
|
|
1425
|
+
const fm = MlxArray.fromFloat32(fullMaskHost, [N]);
|
|
1426
|
+
const fullMasked = ops.mul(logpF, fm); // [N]
|
|
1427
|
+
const fullSum = ops.sumAxis(fullMasked, 0, false); // scalar Σ logp
|
|
1428
|
+
const fullMean = ops.mulScalar(fullSum, 1 / Math.max(fullCount, 1));
|
|
1429
|
+
const nllFull = ops.neg(fullMean); // scalar
|
|
1430
|
+
|
|
1431
|
+
for (const a of [logits2d, targets, lse, gathered, picked, logp, rm, respMasked, resp2d, respSum, recipArr, fm, fullMasked, fullSum, fullMean]) a.dispose();
|
|
1432
|
+
if (logpF !== logp) logpF.dispose();
|
|
1433
|
+
return { lw, nllFull };
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1217
1436
|
/** The pure ORPO reduction from the two length-normalized log-probs ([B] each):
|
|
1218
|
-
* `L_NLL + λ·L_OR` with `
|
|
1219
|
-
* `
|
|
1220
|
-
*
|
|
1221
|
-
*
|
|
1222
|
-
*
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1437
|
+
* `L_NLL + λ·L_OR` with `L_OR = mean(softplus(-log_odds))`. The SFT term:
|
|
1438
|
+
* when `nllFull` is ABSENT (sft_scope:"response"), `L_NLL = mean(-ℓw)` —
|
|
1439
|
+
* bit-exact to the pre-sft_scope behavior; when PRESENT (sft_scope:"full"),
|
|
1440
|
+
* `L_NLL = meanAll(nllFull)`, the separately-computed full-scope chosen NLL
|
|
1441
|
+
* ([1]/[B]/scalar all reduce correctly). The odds-ratio term uses the
|
|
1442
|
+
* response-only ℓw/ℓr in BOTH modes (matching TRL). Model-free and
|
|
1443
|
+
* graph-preserving — the forwards live in `orpoLoss`, the math (and its grad
|
|
1444
|
+
* path) lives here, so the loss can be unit-tested against a hand reference
|
|
1445
|
+
* with synthetic lw/lr. Does NOT dispose lw/lr/nllFull (the caller owns them);
|
|
1446
|
+
* returns the scalar loss. */
|
|
1447
|
+
export function orpoLossFromLogps(lw: MlxArray, lr: MlxArray, lambda: number, nllFull?: MlxArray): MlxArray {
|
|
1448
|
+
const negLw = nllFull ? null : ops.neg(lw);
|
|
1449
|
+
const nll = ops.meanAll(nllFull ?? negLw!, false); // scalar L_NLL
|
|
1226
1450
|
const logOdds = orpoLogOdds(lw, lr); // [B]
|
|
1227
1451
|
const negLO = ops.neg(logOdds);
|
|
1228
1452
|
const orPer = ops.softplus(negLO); // [B] -log σ(log_odds)
|
|
1229
1453
|
const orLoss = ops.meanAll(orPer, false); // scalar L_OR
|
|
1230
1454
|
const lamOr = ops.mulScalar(orLoss, lambda);
|
|
1231
1455
|
const loss = ops.add(nll, lamOr);
|
|
1232
|
-
for (const a of [
|
|
1456
|
+
for (const a of [nll, logOdds, negLO, orPer, orLoss, lamOr]) a.dispose();
|
|
1457
|
+
negLw?.dispose();
|
|
1233
1458
|
return loss;
|
|
1234
1459
|
}
|
|
1235
1460
|
|
|
1236
1461
|
/** ORPO loss for one preference batch (B>=1). Runs the chosen + rejected policy
|
|
1237
1462
|
* forwards (adapter active), reduces via {@link orpoLossFromLogps}. No
|
|
1238
|
-
* reference forward.
|
|
1239
|
-
|
|
1463
|
+
* reference forward. `sftScope` selects the L_NLL scope: "response" (default —
|
|
1464
|
+
* bit-exact to the pre-sft_scope code path) or "full" (paper/TRL-faithful:
|
|
1465
|
+
* the chosen NLL covers prompt+response, computed from the SAME chosen forward
|
|
1466
|
+
* — no third forward; the rejected branch is untouched). Returns a scalar
|
|
1467
|
+
* MlxArray (caller owns). */
|
|
1468
|
+
export function orpoLoss(
|
|
1469
|
+
model: RuntimeModel,
|
|
1470
|
+
batch: DpoBatch,
|
|
1471
|
+
lambda: number,
|
|
1472
|
+
chunk?: ChunkCtx,
|
|
1473
|
+
sftScope: SftScope = "response",
|
|
1474
|
+
): MlxArray {
|
|
1240
1475
|
const cLen = chosenLengths(batch);
|
|
1241
1476
|
const rLen = rejectedLengths(batch);
|
|
1242
|
-
|
|
1477
|
+
let lw: MlxArray;
|
|
1478
|
+
let nllFull: MlxArray | null = null;
|
|
1479
|
+
if (sftScope === "full") {
|
|
1480
|
+
const r = batch.chosenIds.length === 1
|
|
1481
|
+
? chosenLogpMeanAndFullNllB1(model, batch.chosenIds[0]!, batch.chosenMask[0]!, cLen[0]!, chunk)
|
|
1482
|
+
: chosenLogpMeanAndFullNllBatch(model, batch.chosenIds, batch.chosenMask, cLen);
|
|
1483
|
+
lw = r.lw;
|
|
1484
|
+
nllFull = r.nllFull;
|
|
1485
|
+
} else {
|
|
1486
|
+
lw = branchLogpMean(model, batch.chosenIds, batch.chosenMask, cLen, chunk); // [B]
|
|
1487
|
+
}
|
|
1243
1488
|
const lr = branchLogpMean(model, batch.rejectedIds, batch.rejectedMask, rLen, chunk); // [B]
|
|
1244
|
-
const loss = orpoLossFromLogps(lw, lr, lambda);
|
|
1245
|
-
// Safe to drop the lw/lr wrappers now — the loss graph retains the
|
|
1246
|
-
// nodes (same pattern as dpoLoss disposing piC/piR before returning).
|
|
1489
|
+
const loss = orpoLossFromLogps(lw, lr, lambda, nllFull ?? undefined);
|
|
1490
|
+
// Safe to drop the lw/lr/nllFull wrappers now — the loss graph retains the
|
|
1491
|
+
// underlying nodes (same pattern as dpoLoss disposing piC/piR before returning).
|
|
1247
1492
|
lw.dispose();
|
|
1248
1493
|
lr.dispose();
|
|
1494
|
+
nllFull?.dispose();
|
|
1249
1495
|
return loss;
|
|
1250
1496
|
}
|
|
1251
1497
|
|
|
1252
1498
|
/** ORPO diagnostics (no grad): the total loss and its `nll`/`or` split,
|
|
1253
1499
|
* preference accuracy (mean of ℓw > ℓr), and reward margin (mean log_odds).
|
|
1254
|
-
* Runs its own two forwards.
|
|
1500
|
+
* Runs its own two forwards. `sftScope` selects the reported nll's scope so
|
|
1501
|
+
* the diagnostic loss matches the training objective ("response" default). */
|
|
1255
1502
|
export function orpoMetrics(
|
|
1256
1503
|
model: RuntimeModel,
|
|
1257
1504
|
batch: DpoBatch,
|
|
1258
1505
|
lambda: number,
|
|
1506
|
+
sftScope: SftScope = "response",
|
|
1259
1507
|
): { loss: number; nll: number; or: number; accuracy: number; margin: number } {
|
|
1260
1508
|
const cLen = chosenLengths(batch);
|
|
1261
1509
|
const rLen = rejectedLengths(batch);
|
|
1262
|
-
|
|
1510
|
+
let lw: MlxArray;
|
|
1511
|
+
let nllFull: MlxArray | null = null;
|
|
1512
|
+
if (sftScope === "full") {
|
|
1513
|
+
const r = batch.chosenIds.length === 1
|
|
1514
|
+
? chosenLogpMeanAndFullNllB1(model, batch.chosenIds[0]!, batch.chosenMask[0]!, cLen[0]!)
|
|
1515
|
+
: chosenLogpMeanAndFullNllBatch(model, batch.chosenIds, batch.chosenMask, cLen);
|
|
1516
|
+
lw = r.lw;
|
|
1517
|
+
nllFull = r.nllFull;
|
|
1518
|
+
} else {
|
|
1519
|
+
lw = branchLogpMean(model, batch.chosenIds, batch.chosenMask, cLen); // [B]
|
|
1520
|
+
}
|
|
1263
1521
|
const lr = branchLogpMean(model, batch.rejectedIds, batch.rejectedMask, rLen); // [B]
|
|
1264
1522
|
|
|
1265
|
-
const negLw = ops.neg(lw);
|
|
1266
|
-
const nllArr = ops.meanAll(negLw, false);
|
|
1523
|
+
const negLw = ops.neg(lw); // (diagnostic -ℓw; the nll split uses nllFull under "full")
|
|
1524
|
+
const nllArr = nllFull ? ops.meanAll(nllFull, false) : ops.meanAll(negLw, false);
|
|
1267
1525
|
const logOdds = orpoLogOdds(lw, lr); // [B]
|
|
1268
1526
|
const negLO = ops.neg(logOdds);
|
|
1269
1527
|
const orPer = ops.softplus(negLO);
|
|
@@ -1285,5 +1543,6 @@ export function orpoMetrics(
|
|
|
1285
1543
|
margin /= loVals.length;
|
|
1286
1544
|
|
|
1287
1545
|
for (const a of [lw, lr, negLw, nllArr, logOdds, negLO, orPer, orArr]) a.dispose();
|
|
1546
|
+
nllFull?.dispose();
|
|
1288
1547
|
return { loss, nll, or, accuracy: acc, margin };
|
|
1289
1548
|
}
|
|
@@ -28,7 +28,7 @@ import * as ops from "../mlx/ops";
|
|
|
28
28
|
import { createCausalMask, type Cache, type Mask } from "../model/gemma4-base";
|
|
29
29
|
import { MiniCPM5Model, setMiniCpmPrefixPlan } from "../model/minicpm5";
|
|
30
30
|
import { Gemma4Model, setGemmaPrefixPlan } from "../model/gemma4";
|
|
31
|
-
import { orpoLossFromLogps, fusedRespLogpMean, type ChunkCtx } from "./loss";
|
|
31
|
+
import { orpoLossFromLogps, fusedRespLogpMean, combineFullNll, type ChunkCtx, type SftScope } from "./loss";
|
|
32
32
|
import type { DpoBatch } from "./dataset";
|
|
33
33
|
|
|
34
34
|
/** A model that can project final-norm hidden states to vocab logits — the only
|
|
@@ -147,6 +147,26 @@ export function prefixGatherIdx(P: number, Rc: number, Rr: number): { chosenIdx:
|
|
|
147
147
|
return { chosenIdx, rejectedIdx };
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/** sft_scope:"full" over the prefix-shared concat: the full-scope chosen NLL
|
|
151
|
+
* gathers the PROMPT predictions too — hidden position t (t = 0..P-2) predicts
|
|
152
|
+
* prompt token t+1 — and combines them with the response mean ℓw:
|
|
153
|
+
* NLL_full = -((P-1)·promptMean + Rc·ℓw) / (P-1+Rc). The prompt head routes
|
|
154
|
+
* through the same branchLogpMeanGathered tier as the responses (whole-vocab /
|
|
155
|
+
* fused / flash), sharing the one concat forward's hiddens. Returns [1]
|
|
156
|
+
* (caller owns); lw is NOT disposed. P=1 (no prompt predictions) → -ℓw. */
|
|
157
|
+
export function prefixFullNll(
|
|
158
|
+
model: LogitProjector, h: MlxArray, promptIds: number[], lw: MlxArray, Rc: number, chunk?: ChunkCtx,
|
|
159
|
+
): MlxArray {
|
|
160
|
+
const P = promptIds.length;
|
|
161
|
+
if (P <= 1) return combineFullNll(null, lw, 0, Rc);
|
|
162
|
+
const promptIdx = Array.from({ length: P - 1 }, (_, k) => k); // H[k] predicts promptIds[k+1]
|
|
163
|
+
const promptTargets = promptIds.slice(1);
|
|
164
|
+
const pm = branchLogpMeanGathered(model, h, promptIdx, promptTargets, chunk); // [1]
|
|
165
|
+
const nllFull = combineFullNll(pm, lw, P - 1, Rc);
|
|
166
|
+
pm.dispose();
|
|
167
|
+
return nllFull;
|
|
168
|
+
}
|
|
169
|
+
|
|
150
170
|
/** ORPO loss via the shared prompt-prefix single forward (MiniCPM5, B=1).
|
|
151
171
|
* `promptIds` is the shared prefix; `chosenResp`/`rejectedResp` are the response
|
|
152
172
|
* continuations. Returns the scalar loss (caller owns). Differentiable through
|
|
@@ -155,7 +175,7 @@ export function prefixGatherIdx(P: number, Rc: number, Rr: number): { chosenIdx:
|
|
|
155
175
|
export function orpoLossPrefixShared(
|
|
156
176
|
model: MiniCPM5Model,
|
|
157
177
|
promptIds: number[], chosenResp: number[], rejectedResp: number[],
|
|
158
|
-
lambda: number, chunk?: ChunkCtx,
|
|
178
|
+
lambda: number, chunk?: ChunkCtx, sftScope: SftScope = "response",
|
|
159
179
|
): MlxArray {
|
|
160
180
|
const P = promptIds.length, Rc = chosenResp.length, Rr = rejectedResp.length;
|
|
161
181
|
if (P < 1 || Rc < 1 || Rr < 1) throw new Error("orpoLossPrefixShared: need P,Rc,Rr >= 1");
|
|
@@ -188,10 +208,14 @@ export function orpoLossPrefixShared(
|
|
|
188
208
|
if (keepForBwd) chunk!.sink.push(h);
|
|
189
209
|
const lw = branchLogpMeanGathered(model, h, chosenIdx, chosenResp, chunk);
|
|
190
210
|
const lr = branchLogpMeanGathered(model, h, rejectedIdx, rejectedResp, chunk);
|
|
211
|
+
// sft_scope:"full": add the prompt predictions (H[0..P-2] → promptIds[1..P-1])
|
|
212
|
+
// from the SAME concat forward; ℓw/ℓr stay response-only for the odds ratio.
|
|
213
|
+
const nllFull = sftScope === "full" ? prefixFullNll(model, h, promptIds, lw, Rc, chunk) : null;
|
|
191
214
|
if (!keepForBwd) h.dispose();
|
|
192
|
-
const loss = orpoLossFromLogps(lw, lr, lambda);
|
|
215
|
+
const loss = orpoLossFromLogps(lw, lr, lambda, nllFull ?? undefined);
|
|
193
216
|
lw.dispose();
|
|
194
217
|
lr.dispose();
|
|
218
|
+
nllFull?.dispose();
|
|
195
219
|
return loss;
|
|
196
220
|
}
|
|
197
221
|
|
|
@@ -349,7 +373,7 @@ class Gemma4PrefixSharedCache implements Cache {
|
|
|
349
373
|
export function orpoLossPrefixSharedGemma(
|
|
350
374
|
model: Gemma4Model,
|
|
351
375
|
promptIds: number[], chosenResp: number[], rejectedResp: number[],
|
|
352
|
-
lambda: number, chunk?: ChunkCtx,
|
|
376
|
+
lambda: number, chunk?: ChunkCtx, sftScope: SftScope = "response",
|
|
353
377
|
): MlxArray {
|
|
354
378
|
const P = promptIds.length, Rc = chosenResp.length, Rr = rejectedResp.length;
|
|
355
379
|
if (P < 1 || Rc < 1 || Rr < 1) throw new Error("orpoLossPrefixSharedGemma: need P,Rc,Rr >= 1");
|
|
@@ -384,10 +408,14 @@ export function orpoLossPrefixSharedGemma(
|
|
|
384
408
|
if (keepForBwd) chunk!.sink.push(h);
|
|
385
409
|
const lw = branchLogpMeanGathered(model, h, chosenIdx, chosenResp, chunk);
|
|
386
410
|
const lr = branchLogpMeanGathered(model, h, rejectedIdx, rejectedResp, chunk);
|
|
411
|
+
// sft_scope:"full": prompt predictions from the same concat forward (see
|
|
412
|
+
// orpoLossPrefixShared); ℓw/ℓr stay response-only for the odds ratio.
|
|
413
|
+
const nllFull = sftScope === "full" ? prefixFullNll(model, h, promptIds, lw, Rc, chunk) : null;
|
|
387
414
|
if (!keepForBwd) h.dispose();
|
|
388
|
-
const loss = orpoLossFromLogps(lw, lr, lambda);
|
|
415
|
+
const loss = orpoLossFromLogps(lw, lr, lambda, nllFull ?? undefined);
|
|
389
416
|
lw.dispose();
|
|
390
417
|
lr.dispose();
|
|
418
|
+
nllFull?.dispose();
|
|
391
419
|
return loss;
|
|
392
420
|
}
|
|
393
421
|
|