mlx-bun 0.0.8 → 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/README.md +112 -20
- package/docs/design/batching-perf-path.md +232 -0
- 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/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/omlx-adoption-map.md +90 -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/segmented-backward-training.md +15 -0
- package/docs/design/spec-decode-larger-targets.md +229 -0
- package/docs/design/ssd-kv-cold-tier.md +158 -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 +348 -0
- package/docs/investigations/dspark-handoff.md +58 -0
- package/docs/investigations/kernel-perf-review-2026-07.md +171 -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.10.md +57 -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 +43 -9
- package/docs/reference/training.md +31 -12
- 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 +1077 -90
- package/src/config.ts +4 -1
- package/src/download.ts +99 -8
- package/src/embed.ts +77 -0
- package/src/eval/perplexity.ts +164 -0
- package/src/eval/runner.ts +15 -6
- package/src/eval/tasks/bfcl.ts +1 -1
- package/src/generate.ts +149 -18
- package/src/harness-pi.ts +43 -17
- package/src/index.ts +2 -0
- package/src/kv-store.ts +240 -98
- 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/array.ts +21 -0
- package/src/mlx/ffi.ts +3 -0
- package/src/mlx/ops.ts +72 -0
- package/src/model/compiled-decode.ts +43 -8
- package/src/model/factory.ts +29 -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 +96 -7
- package/src/model/gemma4.ts +33 -2
- package/src/model/minicpm5.ts +106 -8
- package/src/model/qwen3-delta.ts +48 -1
- package/src/model/qwen3.ts +276 -0
- package/src/model/qwen3_5.ts +11 -2
- package/src/model/steel-linear-kernel.ts +96 -0
- package/src/model/support.ts +38 -8
- 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/prompt-cache.ts +55 -9
- package/src/quantize/config-writer.ts +1 -1
- package/src/registry.ts +219 -8
- package/src/responses.ts +23 -0
- package/src/sampler.ts +220 -9
- package/src/serve/batch-scheduler.ts +307 -83
- package/src/serve/generation-gateway.ts +156 -29
- package/src/server.ts +746 -73
- 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/ssd-cache.ts +213 -0
- package/src/status-page.html +1 -1
- package/src/tokenizer.ts +7 -0
- package/src/train/flash-cce.ts +81 -23
- 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 +341 -40
- package/src/train/prefix-shared.ts +62 -11
- package/src/train/segmented.ts +215 -55
- package/src/train/trainer.ts +112 -38
- package/src/web/skills/memory/SKILL.md +40 -17
package/src/train/loss.ts
CHANGED
|
@@ -138,6 +138,36 @@ export function sftLoss(model: RuntimeModel, batch: SftBatch): MlxArray {
|
|
|
138
138
|
return scalar;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/** model.logitsFromHidden for TRAINING heads, with the upstream mlx
|
|
142
|
+
* quantized_matmul bug worked around: transpose=false returns WRONG values at
|
|
143
|
+
* row count 2 or 3 (reproduced on stock mlx 0.31.2; M=1 and M>=4 correct,
|
|
144
|
+
* transpose=true correct at every M). mlx's autograd computes dh for a
|
|
145
|
+
* transpose=true head qmm via an INTERNAL transpose=false call at the
|
|
146
|
+
* cotangent's row count — so any differentiated [M,V] logits with M in {2,3}
|
|
147
|
+
* (2-3-token responses, 2-3-token tail chunks, tiny prompt spans) got a
|
|
148
|
+
* silently wrong dh (measured relnorm ~0.35-0.99 vs the f32 ground truth).
|
|
149
|
+
* Fix: pad the span to 4 rows with zeros and slice the logits back — the pad
|
|
150
|
+
* rows' cotangents are exactly zero (slice adjoint), and the internal vjp
|
|
151
|
+
* then runs at M=4 where the kernel is correct. */
|
|
152
|
+
export function logitsFromHiddenPadM(
|
|
153
|
+
model: { logitsFromHidden(h: MlxArray): MlxArray },
|
|
154
|
+
hSpan: MlxArray, // [1, M, hidden]
|
|
155
|
+
): MlxArray {
|
|
156
|
+
const M = hSpan.shape[1]!;
|
|
157
|
+
if (M !== 2 && M !== 3) return model.logitsFromHidden(hSpan);
|
|
158
|
+
const [B, , H] = hSpan.shape as [number, number, number];
|
|
159
|
+
const pad = ops.zeros([B, 4 - M, H], hSpan.dtype);
|
|
160
|
+
const hPad = ops.concatAxis([hSpan, pad], 1);
|
|
161
|
+
pad.dispose();
|
|
162
|
+
const lgPad = model.logitsFromHidden(hPad); // [1, 4, V]
|
|
163
|
+
hPad.dispose();
|
|
164
|
+
const start = MlxArray.fromInt32(new Int32Array([0]), [1]);
|
|
165
|
+
const lg = ops.sliceDynamic(lgPad, start, [1], [B, M, lgPad.shape[2]!]);
|
|
166
|
+
start.dispose();
|
|
167
|
+
lgPad.dispose();
|
|
168
|
+
return lg;
|
|
169
|
+
}
|
|
170
|
+
|
|
141
171
|
/** B=1 masked CE that applies the LM head only at the supervised (response)
|
|
142
172
|
* span. The supervised input positions are the contiguous range
|
|
143
173
|
* [promptLen-1, len-1) (each predicts the next, response, token). The LM head
|
|
@@ -158,7 +188,7 @@ export function responseOnlyCe(model: RuntimeModel, h: MlxArray, batch: SftBatch
|
|
|
158
188
|
const hidden = h.shape[2]!;
|
|
159
189
|
const start = MlxArray.fromInt32(new Int32Array([startT]), [1]);
|
|
160
190
|
const hResp = ops.sliceDynamic(h, start, [1], [1, M, hidden]);
|
|
161
|
-
const logits = model
|
|
191
|
+
const logits = logitsFromHiddenPadM(model, hResp); // [1, M, V] (M in {2,3} padded — mlx qmm bug)
|
|
162
192
|
const V = logits.shape[2]!;
|
|
163
193
|
const logits2d = ops.reshape(logits, [M, V]);
|
|
164
194
|
|
|
@@ -198,7 +228,7 @@ export function responseOnlyLogpMean(
|
|
|
198
228
|
const hidden = h.shape[2]!;
|
|
199
229
|
const start = MlxArray.fromInt32(new Int32Array([startT]), [1]);
|
|
200
230
|
const hResp = ops.sliceDynamic(h, start, [1], [1, M, hidden]); // [1, M, hidden]
|
|
201
|
-
const logits = model
|
|
231
|
+
const logits = logitsFromHiddenPadM(model, hResp); // [1, M, V] (M in {2,3} padded — mlx qmm bug)
|
|
202
232
|
const V = logits.shape[2]!;
|
|
203
233
|
const logits2d = ops.reshape(logits, [M, V]);
|
|
204
234
|
|
|
@@ -447,17 +477,108 @@ export function dpoMetrics(
|
|
|
447
477
|
//
|
|
448
478
|
// Reference-FREE: one objective, no reference model (vs DPO's scale-0 ref pass).
|
|
449
479
|
// L = L_NLL(chosen) + λ · L_OR
|
|
450
|
-
// L_NLL =
|
|
480
|
+
// L_NLL = the chosen-sequence SFT term (unweighted); scope selected by sft_scope:
|
|
481
|
+
// "full" (default in TrainConfig; paper/TRL-faithful): mean cross-entropy
|
|
482
|
+
// over ALL non-pad supervised positions of the chosen sequence —
|
|
483
|
+
// prompt AND response (TRL masks only padding to -100 for the
|
|
484
|
+
// chosen_nll_loss; nn.CrossEntropyLoss reduction="mean").
|
|
485
|
+
// "response" (the pre-2026-07 mlx-bun behavior, bit-exact): -ℓw, the negative
|
|
486
|
+
// mean response log-prob.
|
|
451
487
|
// L_OR = -log σ(log_odds)
|
|
452
488
|
// log_odds = (ℓw - ℓr) - (log1mexp(ℓw) - log1mexp(ℓr))
|
|
453
489
|
// 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
|
-
//
|
|
490
|
+
// paper's choice (and TRL's), distinct from DPO's summed seqLogp. CRITICAL:
|
|
491
|
+
// ℓw/ℓr stay RESPONSE-ONLY in BOTH sft_scope modes (TRL's get_batch_logps uses
|
|
492
|
+
// prompt-masked labels for the odds ratio; only the NLL term is full-sequence).
|
|
493
|
+
// Two facts shape the code: (1) ℓw feeds BOTH terms (under "full" the chosen
|
|
494
|
+
// forward additionally yields the prompt-position log-probs — same forward, the
|
|
495
|
+
// head just runs over the extended span); (2) only 2 forwards/step (chosen +
|
|
496
|
+
// rejected), both with grad, no reference. The REJECTED branch never needs
|
|
497
|
+
// prompt logps. This is the naïve full-logits oracle path — the chunked /
|
|
458
498
|
// response-only / segmented tiers validate against it (see the design's oracle
|
|
459
499
|
// ladder, docs/design/orpo-training.md).
|
|
460
500
|
|
|
501
|
+
/** SFT-term scope for ORPO's L_NLL(chosen): "full" = prompt+response token-mean
|
|
502
|
+
* CE (paper/TRL-faithful); "response" = -ℓw (the pre-change behavior). The
|
|
503
|
+
* odds-ratio ℓ terms are response-only in both modes. */
|
|
504
|
+
export type SftScope = "full" | "response";
|
|
505
|
+
|
|
506
|
+
/** A contiguous supervised input-position span [startT, startT+M): position t
|
|
507
|
+
* predicts token ids[t+1]. The response span comes from the mask; "full" mode
|
|
508
|
+
* additionally runs the head over the prompt span {startT: 0, M: respStartT}. */
|
|
509
|
+
export interface HeadSpan { startT: number; M: number }
|
|
510
|
+
|
|
511
|
+
/** The response span from a DPO-row mask (mask[t+1]==1 ⇔ input position t is
|
|
512
|
+
* supervised): first supervised position + count. M<=0 ⇔ no response. */
|
|
513
|
+
export function respSpanFromMask(mask: number[], T: number): HeadSpan {
|
|
514
|
+
let startT = -1;
|
|
515
|
+
let M = 0;
|
|
516
|
+
for (let t = 0; t < T; t++) if (mask[t + 1]) { if (startT < 0) startT = t; M++; }
|
|
517
|
+
return { startT, M };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Length-normalized mean log-prob over the contiguous input-position span
|
|
521
|
+
* [s0, s0+n) of `h [1, T, hidden]` (post-finalNorm), scoring targets
|
|
522
|
+
* ids[s0+1 .. s0+n]. The span-parameterized generalization of
|
|
523
|
+
* responseOnlyLogpMean (same ops); used for the prompt span under
|
|
524
|
+
* sft_scope:"full". Whole-vocab autograd head. Returns [1] (caller owns);
|
|
525
|
+
* disposes nothing it didn't create. */
|
|
526
|
+
export function spanLogpMeanFromHidden(
|
|
527
|
+
model: { logitsFromHidden(h: MlxArray): MlxArray },
|
|
528
|
+
h: MlxArray, // [1, T, hidden] post-finalNorm
|
|
529
|
+
ids: number[],
|
|
530
|
+
s0: number,
|
|
531
|
+
n: number,
|
|
532
|
+
): MlxArray {
|
|
533
|
+
if (n <= 0) return MlxArray.fromFloat32(new Float32Array([0]), [1]);
|
|
534
|
+
const hidden = h.shape[2]!;
|
|
535
|
+
const start = MlxArray.fromInt32(new Int32Array([s0]), [1]);
|
|
536
|
+
const hSpan = ops.sliceDynamic(h, start, [1], [1, n, hidden]); // [1, n, hidden]
|
|
537
|
+
const logits = logitsFromHiddenPadM(model, hSpan); // [1, n, V] (n in {2,3} padded — mlx qmm bug)
|
|
538
|
+
const V = logits.shape[2]!;
|
|
539
|
+
const logits2d = ops.reshape(logits, [n, V]);
|
|
540
|
+
|
|
541
|
+
const targetsHost = new Int32Array(n);
|
|
542
|
+
for (let i = 0; i < n; i++) targetsHost[i] = ids[s0 + 1 + i]!;
|
|
543
|
+
const targets = MlxArray.fromInt32(targetsHost, [n, 1]);
|
|
544
|
+
|
|
545
|
+
const lse = ops.logsumexpAxis(logits2d, -1, false); // [n]
|
|
546
|
+
const gathered = ops.takeAlongAxis(logits2d, targets, -1); // [n, 1]
|
|
547
|
+
const picked = ops.reshape(gathered, [n]); // [n]
|
|
548
|
+
const logp = ops.sub(picked, lse); // [n] log_softmax at the label
|
|
549
|
+
const logpF = logp.dtype === Dtype.float32 ? logp : logp.astype(Dtype.float32);
|
|
550
|
+
const sumLogp = ops.sumAxis(logpF, 0, false); // scalar
|
|
551
|
+
const meanScalar = ops.mulScalar(sumLogp, 1 / n);
|
|
552
|
+
const mean = ops.reshape(meanScalar, [1]); // [1]
|
|
553
|
+
|
|
554
|
+
for (const a of [start, hSpan, logits, logits2d, targets, lse, gathered, picked, logp, sumLogp, meanScalar]) a.dispose();
|
|
555
|
+
if (logpF !== logp) logpF.dispose();
|
|
556
|
+
return mean;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Combine the prompt-span mean logp (`promptMean`, over nPrompt positions) and
|
|
560
|
+
* the response mean logp (`lw`, over nResp positions) into the FULL-scope chosen
|
|
561
|
+
* NLL: `-(nPrompt·promptMean + nResp·lw) / (nPrompt + nResp)` — the token-mean
|
|
562
|
+
* CE over all prompt+response supervised positions (nn.CrossEntropyLoss
|
|
563
|
+
* reduction="mean" over the label-shifted non-pad positions). Graph-preserving
|
|
564
|
+
* (pure ops on the two means), so the head gradient flows to BOTH spans. Does
|
|
565
|
+
* NOT dispose promptMean/lw (caller owns); returns [1] (caller owns). */
|
|
566
|
+
export function combineFullNll(
|
|
567
|
+
promptMean: MlxArray | null,
|
|
568
|
+
lw: MlxArray,
|
|
569
|
+
nPrompt: number,
|
|
570
|
+
nResp: number,
|
|
571
|
+
): MlxArray {
|
|
572
|
+
if (!promptMean || nPrompt <= 0) return ops.neg(lw); // no prompt predictions → full == response
|
|
573
|
+
const ps = ops.mulScalar(promptMean, nPrompt); // Σ prompt logp (reconstructed)
|
|
574
|
+
const rs = ops.mulScalar(lw, nResp); // Σ response logp (reconstructed)
|
|
575
|
+
const sum = ops.add(ps, rs);
|
|
576
|
+
const mean = ops.mulScalar(sum, 1 / (nPrompt + nResp));
|
|
577
|
+
const out = ops.neg(mean);
|
|
578
|
+
for (const a of [ps, rs, sum, mean]) a.dispose();
|
|
579
|
+
return out;
|
|
580
|
+
}
|
|
581
|
+
|
|
461
582
|
/** Per-row LENGTH-NORMALIZED response log-prob for one branch (B>=1): the
|
|
462
583
|
* summed response logp divided by the per-row response-token count. Pad/prompt
|
|
463
584
|
* positions contribute 0 (mask is 0 there) and never inflate the count.
|
|
@@ -533,7 +654,7 @@ export function branchLogpMeanB1(
|
|
|
533
654
|
const hidden = h.shape[2]!;
|
|
534
655
|
const start = MlxArray.fromInt32(new Int32Array([startT]), [1]);
|
|
535
656
|
const hResp = ops.sliceDynamic(h, start, [1], [1, M, hidden]); // [1, M, hidden]
|
|
536
|
-
const logits = model
|
|
657
|
+
const logits = logitsFromHiddenPadM(model, hResp); // [1, M, V] — head only on the response span (M in {2,3} padded — mlx qmm bug)
|
|
537
658
|
const V = logits.shape[2]!;
|
|
538
659
|
const logits2d = ops.reshape(logits, [M, V]);
|
|
539
660
|
|
|
@@ -607,7 +728,7 @@ function chunkedLogpMeanB1(
|
|
|
607
728
|
|
|
608
729
|
const ckpt = new Checkpoint((inp) => {
|
|
609
730
|
const hc = inp[0]!;
|
|
610
|
-
const logits = model
|
|
731
|
+
const logits = logitsFromHiddenPadM(model, hc); // [1, Cc, V] — recomputed in backward (Cc in {2,3} padded — mlx qmm bug)
|
|
611
732
|
const V = logits.shape[2]!;
|
|
612
733
|
const l2 = ops.reshape(logits, [Cc, V]);
|
|
613
734
|
const targets = MlxArray.fromInt32(tHost, [Cc, 1]);
|
|
@@ -654,12 +775,11 @@ export function chunkedLogpMeanFromHidden(
|
|
|
654
775
|
mask: number[],
|
|
655
776
|
chunkSize: number,
|
|
656
777
|
sink: Array<{ dispose(): void }>,
|
|
778
|
+
span?: HeadSpan, // override the mask-derived response span (sft_scope:"full" prompt span)
|
|
657
779
|
): MlxArray {
|
|
658
780
|
const L = ids.length;
|
|
659
781
|
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++; }
|
|
782
|
+
const { startT, M } = span ?? respSpanFromMask(mask, T);
|
|
663
783
|
if (M <= 0) return MlxArray.fromFloat32(new Float32Array([0]), [1]);
|
|
664
784
|
const hidden = h.shape[2]!;
|
|
665
785
|
|
|
@@ -675,7 +795,7 @@ export function chunkedLogpMeanFromHidden(
|
|
|
675
795
|
for (let i = 0; i < Cc; i++) tHost[i] = ids[c0 + 1 + i]!;
|
|
676
796
|
const ckpt = new Checkpoint((inp) => {
|
|
677
797
|
const hc = inp[0]!;
|
|
678
|
-
const logits = model
|
|
798
|
+
const logits = logitsFromHiddenPadM(model, hc); // [1, Cc, V] — recomputed in backward (Cc in {2,3} padded — mlx qmm bug)
|
|
679
799
|
const V = logits.shape[2]!;
|
|
680
800
|
const l2 = ops.reshape(logits, [Cc, V]);
|
|
681
801
|
const targets = MlxArray.fromInt32(tHost, [Cc, 1]);
|
|
@@ -779,12 +899,11 @@ export function fusedLogpMeanFromHidden(
|
|
|
779
899
|
sink: Array<{ dispose(): void }>,
|
|
780
900
|
vocabBlock = 0,
|
|
781
901
|
flash = false,
|
|
902
|
+
span?: HeadSpan, // override the mask-derived response span (sft_scope:"full" prompt span)
|
|
782
903
|
): MlxArray {
|
|
783
904
|
const L = ids.length;
|
|
784
905
|
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++; }
|
|
906
|
+
const { startT, M } = span ?? respSpanFromMask(mask, T);
|
|
788
907
|
if (M <= 0) return MlxArray.fromFloat32(new Float32Array([0]), [1]);
|
|
789
908
|
const hidden = h.shape[2]!;
|
|
790
909
|
const startArr = MlxArray.fromInt32(new Int32Array([startT]), [1]);
|
|
@@ -1004,8 +1123,9 @@ function makeVocabBlockedHeadVjp(
|
|
|
1004
1123
|
/** flash-CCE head as a CustomVjp: forward runs the Metal kernel → Σ logp (stashing
|
|
1005
1124
|
* `lse` for the backward, avoiding a forward recompute); backward runs the kernel's
|
|
1006
1125
|
* `dh` for unit cotangent then scales by the scalar cotangent `c` (loss = Σ logp →
|
|
1007
|
-
* ∂loss/∂logp_t = c for all t). The coeff filter + vocab-block skip
|
|
1008
|
-
*
|
|
1126
|
+
* ∂loss/∂logp_t = c for all t). The coeff filter + vocab-block skip default ON at
|
|
1127
|
+
* 1e-5 (2026-07-02 flip; real-data gates in flash-cce.ts) — set
|
|
1128
|
+
* MLX_BUN_CCE_BWD_{FILTER,BLOCK}_EPS=0 for the EXACT dh. */
|
|
1009
1129
|
function makeFlashCceHeadVjp(
|
|
1010
1130
|
head: HeadQuant, targetsHost: Int32Array, M: number,
|
|
1011
1131
|
sink: Array<{ dispose(): void }>,
|
|
@@ -1036,8 +1156,8 @@ function makeFlashCceHeadVjp(
|
|
|
1036
1156
|
const c = cots[0]!; // [1] = ∂loss/∂(Σ logp); uniform over tokens
|
|
1037
1157
|
// dh for unit cotangent, then × c (avoids a host read of the cotangent).
|
|
1038
1158
|
const ones = new Array(M).fill(1) as number[];
|
|
1039
|
-
// filter + block-skip
|
|
1040
|
-
//
|
|
1159
|
+
// filter + block-skip default ON at 1e-5 (env-overridable; =0 → exact dh).
|
|
1160
|
+
// savedBlockMax feeds the ~lossless vocab-block skip.
|
|
1041
1161
|
const dhUnit = flashCceBackward(hR, fch, targets, savedLse!, ones, undefined, savedBlockMax!); // [M, hidden] f32
|
|
1042
1162
|
const cM = c.dtype === dhUnit.dtype ? c : c.astype(dhUnit.dtype);
|
|
1043
1163
|
const dh = ops.mul(dhUnit, cM); // broadcast [M,hidden]·[1]
|
|
@@ -1068,8 +1188,19 @@ export function fusedRespLogpMean(
|
|
|
1068
1188
|
// Vocab-blocked (full CCE) path: even [chunk,V] never materializes — only
|
|
1069
1189
|
// [chunk,vocabBlock]. Otherwise the whole-vocab analytic head below.
|
|
1070
1190
|
const vb = vocabBlock > 0 && vocabBlock < V ? vocabBlock : 0;
|
|
1191
|
+
// Auto-dispatch by M (kernel backlog #3; measured 2026-07-02 on the M1 Max,
|
|
1192
|
+
// scripts/experiments/head-dispatch-sweep.ts): the fused QM head is FASTER
|
|
1193
|
+
// at short M (e4b M=64 1.6×, M=512 1.13×; CPM5 faster at EVERY M) — the
|
|
1194
|
+
// flash head's win is residency, plus time at long M on e4b (M≥2048 after
|
|
1195
|
+
// the 2026-07-02 filter/blockMax flip; 8K: 10.7 vs 13.2 s). So a
|
|
1196
|
+
// requested-flash batch with M below MLX_BUN_FLASH_MIN_M (default 1024, the
|
|
1197
|
+
// e4b time-tie point where flash's memory win takes over) takes the EXACT
|
|
1198
|
+
// fused head instead — short rows get the faster exact head for free.
|
|
1199
|
+
// MLX_BUN_FLASH_MIN_M=0 always honors flash (the pre-dispatch behavior).
|
|
1200
|
+
const flashMinM = Number(process.env.MLX_BUN_FLASH_MIN_M ?? "1024");
|
|
1201
|
+
const useFlash = flash && (flashMinM <= 0 || M >= flashMinM);
|
|
1071
1202
|
let op: CustomVjp;
|
|
1072
|
-
if (
|
|
1203
|
+
if (useFlash) {
|
|
1073
1204
|
op = makeFlashCceHeadVjp(head, targetsHost, M, sink);
|
|
1074
1205
|
} else if (vb > 0) {
|
|
1075
1206
|
op = makeVocabBlockedHeadVjp(head, targetsHost, M, hidden, chunk, vb);
|
|
@@ -1214,56 +1345,225 @@ function orpoLogOdds(lw: MlxArray, lr: MlxArray): MlxArray {
|
|
|
1214
1345
|
return logOdds;
|
|
1215
1346
|
}
|
|
1216
1347
|
|
|
1348
|
+
/** The chosen branch under sft_scope:"full" (B=1): ONE forward yields both the
|
|
1349
|
+
* response-only mean logp ℓw (fed to the odds ratio — computed exactly as the
|
|
1350
|
+
* "response" path computes it, same span/head) AND the full-scope chosen NLL
|
|
1351
|
+
* (the head additionally runs over the PROMPT span [0, startT), combined via
|
|
1352
|
+
* combineFullNll). No third forward: the hidden states already cover the
|
|
1353
|
+
* prompt. Routes both spans through the same head tier the response path uses
|
|
1354
|
+
* (flash/fused CustomVjp, Checkpoint token-chunks, or the whole-vocab autograd
|
|
1355
|
+
* head), so the memory bounds hold for the extended span too. Caller owns both
|
|
1356
|
+
* returned arrays ([1] each). */
|
|
1357
|
+
function chosenLogpMeanAndFullNllB1(
|
|
1358
|
+
model: RuntimeModel,
|
|
1359
|
+
ids: number[],
|
|
1360
|
+
mask: number[],
|
|
1361
|
+
validLen: number,
|
|
1362
|
+
chunk?: ChunkCtx,
|
|
1363
|
+
): { lw: MlxArray; nllFull: MlxArray } {
|
|
1364
|
+
const L = ids.length;
|
|
1365
|
+
const T = L - 1;
|
|
1366
|
+
const { startT, M } = respSpanFromMask(mask, T);
|
|
1367
|
+
if (M <= 0)
|
|
1368
|
+
return {
|
|
1369
|
+
lw: MlxArray.fromFloat32(new Float32Array([0]), [1]),
|
|
1370
|
+
nllFull: MlxArray.fromFloat32(new Float32Array([0]), [1]),
|
|
1371
|
+
};
|
|
1372
|
+
|
|
1373
|
+
const inputHost = new Int32Array(T);
|
|
1374
|
+
for (let t = 0; t < T; t++) inputHost[t] = ids[t]!;
|
|
1375
|
+
const inputIds = MlxArray.fromInt32(inputHost, [1, T]);
|
|
1376
|
+
const inputValid = [Math.max(0, Math.min(validLen, L) - 1)];
|
|
1377
|
+
const h = trainForwardHidden(model, inputIds, inputValid); // [1, T, hidden]
|
|
1378
|
+
inputIds.dispose();
|
|
1379
|
+
|
|
1380
|
+
const useFused = !!chunk && chunk.chunkSize > 0 && !!(chunk.fused || chunk.flash);
|
|
1381
|
+
const useChunked = !!chunk && chunk.chunkSize > 0 && !useFused;
|
|
1382
|
+
let lw: MlxArray;
|
|
1383
|
+
let pm: MlxArray | null = null; // prompt-span mean logp (positions [0, startT))
|
|
1384
|
+
if (useFused) {
|
|
1385
|
+
chunk!.sink.push(h); // CustomVjp/flash primal source — alive until post-eval
|
|
1386
|
+
lw = fusedLogpMeanFromHidden(model, h, ids, mask, chunk!.chunkSize, chunk!.sink, 0, chunk!.flash ?? false, { startT, M });
|
|
1387
|
+
if (startT > 0)
|
|
1388
|
+
pm = fusedLogpMeanFromHidden(model, h, ids, mask, chunk!.chunkSize, chunk!.sink, 0, chunk!.flash ?? false, { startT: 0, M: startT });
|
|
1389
|
+
} else if (useChunked) {
|
|
1390
|
+
chunk!.sink.push(h); // Checkpoint input — alive until post-eval
|
|
1391
|
+
lw = chunkedLogpMeanFromHidden(model, h, ids, mask, chunk!.chunkSize, chunk!.sink, { startT, M });
|
|
1392
|
+
if (startT > 0)
|
|
1393
|
+
pm = chunkedLogpMeanFromHidden(model, h, ids, mask, chunk!.chunkSize, chunk!.sink, { startT: 0, M: startT });
|
|
1394
|
+
} else {
|
|
1395
|
+
lw = spanLogpMeanFromHidden(model, h, ids, startT, M);
|
|
1396
|
+
if (startT > 0) pm = spanLogpMeanFromHidden(model, h, ids, 0, startT);
|
|
1397
|
+
h.dispose(); // whole-vocab path builds a normal autograd graph — the loss retains it
|
|
1398
|
+
}
|
|
1399
|
+
const nllFull = combineFullNll(pm, lw, startT, M);
|
|
1400
|
+
pm?.dispose();
|
|
1401
|
+
return { lw, nllFull };
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/** The chosen branch under sft_scope:"full" for B>1 (the batched full-logits
|
|
1405
|
+
* path): one forward, one label-logp pass, then TWO masked reductions —
|
|
1406
|
+
* per-row response means for ℓw [B] (identical positions/count to the
|
|
1407
|
+
* "response" path) and the flattened token-mean full NLL over all non-pad
|
|
1408
|
+
* supervised positions across the batch (nn.CrossEntropyLoss
|
|
1409
|
+
* reduction="mean"). Caller owns both returned arrays. */
|
|
1410
|
+
function chosenLogpMeanAndFullNllBatch(
|
|
1411
|
+
model: RuntimeModel,
|
|
1412
|
+
ids: number[][],
|
|
1413
|
+
mask: number[][],
|
|
1414
|
+
validLen: number[],
|
|
1415
|
+
): { lw: MlxArray; nllFull: MlxArray } {
|
|
1416
|
+
const B = ids.length;
|
|
1417
|
+
const L = ids[0]!.length;
|
|
1418
|
+
const T = L - 1;
|
|
1419
|
+
const inputHost = new Int32Array(B * T);
|
|
1420
|
+
for (let b = 0; b < B; b++)
|
|
1421
|
+
for (let t = 0; t < T; t++) inputHost[b * T + t] = ids[b]![t]!;
|
|
1422
|
+
const inputIds = MlxArray.fromInt32(inputHost, [B, T]);
|
|
1423
|
+
const inputValid = validLen.map((v) => Math.max(0, Math.min(v, L) - 1));
|
|
1424
|
+
const logits = trainForward(model, inputIds, inputValid); // [B, T, V]
|
|
1425
|
+
inputIds.dispose();
|
|
1426
|
+
const V = logits.shape[2]!;
|
|
1427
|
+
const N = B * T;
|
|
1428
|
+
|
|
1429
|
+
const targetsHost = new Int32Array(N);
|
|
1430
|
+
const respMaskHost = new Float32Array(N);
|
|
1431
|
+
const fullMaskHost = new Float32Array(N);
|
|
1432
|
+
const respRecip = new Float32Array(B);
|
|
1433
|
+
let fullCount = 0;
|
|
1434
|
+
for (let b = 0; b < B; b++) {
|
|
1435
|
+
const len = Math.min(validLen[b]!, L);
|
|
1436
|
+
let c = 0;
|
|
1437
|
+
for (let t = 0; t < T; t++) {
|
|
1438
|
+
const flat = b * T + t;
|
|
1439
|
+
targetsHost[flat] = ids[b]![t + 1]!;
|
|
1440
|
+
respMaskHost[flat] = mask[b]![t + 1]!; // resp_mask[:, 1:]
|
|
1441
|
+
c += mask[b]![t + 1]!;
|
|
1442
|
+
// full scope: every non-pad supervised position (prompt + response),
|
|
1443
|
+
// i.e. the predicted token ids[t+1] is a real token (t+1 < len).
|
|
1444
|
+
if (t + 1 < len) { fullMaskHost[flat] = 1; fullCount++; }
|
|
1445
|
+
}
|
|
1446
|
+
respRecip[b] = 1 / Math.max(c, 1);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
const logits2d = ops.reshape(logits, [N, V]);
|
|
1450
|
+
logits.dispose();
|
|
1451
|
+
const targets = MlxArray.fromInt32(targetsHost, [N, 1]);
|
|
1452
|
+
const lse = ops.logsumexpAxis(logits2d, -1, false); // [N]
|
|
1453
|
+
const gathered = ops.takeAlongAxis(logits2d, targets, -1); // [N, 1]
|
|
1454
|
+
const picked = ops.reshape(gathered, [N]); // [N]
|
|
1455
|
+
const logp = ops.sub(picked, lse); // [N] log_softmax at the label
|
|
1456
|
+
const logpF = logp.dtype === Dtype.float32 ? logp : logp.astype(Dtype.float32);
|
|
1457
|
+
|
|
1458
|
+
// ℓw: per-row response length-normalized mean (same as branchLogpMean B>1).
|
|
1459
|
+
const rm = MlxArray.fromFloat32(respMaskHost, [N]);
|
|
1460
|
+
const respMasked = ops.mul(logpF, rm); // [N]
|
|
1461
|
+
const resp2d = ops.reshape(respMasked, [B, T]);
|
|
1462
|
+
const respSum = ops.sumAxis(resp2d, 1, false); // [B]
|
|
1463
|
+
const recipArr = MlxArray.fromFloat32(respRecip, [B]);
|
|
1464
|
+
const lw = ops.mul(respSum, recipArr); // [B]
|
|
1465
|
+
|
|
1466
|
+
// Full NLL: token-mean over ALL non-pad supervised positions, batch-flattened.
|
|
1467
|
+
const fm = MlxArray.fromFloat32(fullMaskHost, [N]);
|
|
1468
|
+
const fullMasked = ops.mul(logpF, fm); // [N]
|
|
1469
|
+
const fullSum = ops.sumAxis(fullMasked, 0, false); // scalar Σ logp
|
|
1470
|
+
const fullMean = ops.mulScalar(fullSum, 1 / Math.max(fullCount, 1));
|
|
1471
|
+
const nllFull = ops.neg(fullMean); // scalar
|
|
1472
|
+
|
|
1473
|
+
for (const a of [logits2d, targets, lse, gathered, picked, logp, rm, respMasked, resp2d, respSum, recipArr, fm, fullMasked, fullSum, fullMean]) a.dispose();
|
|
1474
|
+
if (logpF !== logp) logpF.dispose();
|
|
1475
|
+
return { lw, nllFull };
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1217
1478
|
/** 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
|
-
|
|
1479
|
+
* `L_NLL + λ·L_OR` with `L_OR = mean(softplus(-log_odds))`. The SFT term:
|
|
1480
|
+
* when `nllFull` is ABSENT (sft_scope:"response"), `L_NLL = mean(-ℓw)` —
|
|
1481
|
+
* bit-exact to the pre-sft_scope behavior; when PRESENT (sft_scope:"full"),
|
|
1482
|
+
* `L_NLL = meanAll(nllFull)`, the separately-computed full-scope chosen NLL
|
|
1483
|
+
* ([1]/[B]/scalar all reduce correctly). The odds-ratio term uses the
|
|
1484
|
+
* response-only ℓw/ℓr in BOTH modes (matching TRL). Model-free and
|
|
1485
|
+
* graph-preserving — the forwards live in `orpoLoss`, the math (and its grad
|
|
1486
|
+
* path) lives here, so the loss can be unit-tested against a hand reference
|
|
1487
|
+
* with synthetic lw/lr. Does NOT dispose lw/lr/nllFull (the caller owns them);
|
|
1488
|
+
* returns the scalar loss. */
|
|
1489
|
+
export function orpoLossFromLogps(lw: MlxArray, lr: MlxArray, lambda: number, nllFull?: MlxArray): MlxArray {
|
|
1490
|
+
const negLw = nllFull ? null : ops.neg(lw);
|
|
1491
|
+
const nll = ops.meanAll(nllFull ?? negLw!, false); // scalar L_NLL
|
|
1226
1492
|
const logOdds = orpoLogOdds(lw, lr); // [B]
|
|
1227
1493
|
const negLO = ops.neg(logOdds);
|
|
1228
1494
|
const orPer = ops.softplus(negLO); // [B] -log σ(log_odds)
|
|
1229
1495
|
const orLoss = ops.meanAll(orPer, false); // scalar L_OR
|
|
1230
1496
|
const lamOr = ops.mulScalar(orLoss, lambda);
|
|
1231
1497
|
const loss = ops.add(nll, lamOr);
|
|
1232
|
-
for (const a of [
|
|
1498
|
+
for (const a of [nll, logOdds, negLO, orPer, orLoss, lamOr]) a.dispose();
|
|
1499
|
+
negLw?.dispose();
|
|
1233
1500
|
return loss;
|
|
1234
1501
|
}
|
|
1235
1502
|
|
|
1236
1503
|
/** ORPO loss for one preference batch (B>=1). Runs the chosen + rejected policy
|
|
1237
1504
|
* forwards (adapter active), reduces via {@link orpoLossFromLogps}. No
|
|
1238
|
-
* reference forward.
|
|
1239
|
-
|
|
1505
|
+
* reference forward. `sftScope` selects the L_NLL scope: "response" (default —
|
|
1506
|
+
* bit-exact to the pre-sft_scope code path) or "full" (paper/TRL-faithful:
|
|
1507
|
+
* the chosen NLL covers prompt+response, computed from the SAME chosen forward
|
|
1508
|
+
* — no third forward; the rejected branch is untouched). Returns a scalar
|
|
1509
|
+
* MlxArray (caller owns). */
|
|
1510
|
+
export function orpoLoss(
|
|
1511
|
+
model: RuntimeModel,
|
|
1512
|
+
batch: DpoBatch,
|
|
1513
|
+
lambda: number,
|
|
1514
|
+
chunk?: ChunkCtx,
|
|
1515
|
+
sftScope: SftScope = "response",
|
|
1516
|
+
): MlxArray {
|
|
1240
1517
|
const cLen = chosenLengths(batch);
|
|
1241
1518
|
const rLen = rejectedLengths(batch);
|
|
1242
|
-
|
|
1519
|
+
let lw: MlxArray;
|
|
1520
|
+
let nllFull: MlxArray | null = null;
|
|
1521
|
+
if (sftScope === "full") {
|
|
1522
|
+
const r = batch.chosenIds.length === 1
|
|
1523
|
+
? chosenLogpMeanAndFullNllB1(model, batch.chosenIds[0]!, batch.chosenMask[0]!, cLen[0]!, chunk)
|
|
1524
|
+
: chosenLogpMeanAndFullNllBatch(model, batch.chosenIds, batch.chosenMask, cLen);
|
|
1525
|
+
lw = r.lw;
|
|
1526
|
+
nllFull = r.nllFull;
|
|
1527
|
+
} else {
|
|
1528
|
+
lw = branchLogpMean(model, batch.chosenIds, batch.chosenMask, cLen, chunk); // [B]
|
|
1529
|
+
}
|
|
1243
1530
|
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).
|
|
1531
|
+
const loss = orpoLossFromLogps(lw, lr, lambda, nllFull ?? undefined);
|
|
1532
|
+
// Safe to drop the lw/lr/nllFull wrappers now — the loss graph retains the
|
|
1533
|
+
// underlying nodes (same pattern as dpoLoss disposing piC/piR before returning).
|
|
1247
1534
|
lw.dispose();
|
|
1248
1535
|
lr.dispose();
|
|
1536
|
+
nllFull?.dispose();
|
|
1249
1537
|
return loss;
|
|
1250
1538
|
}
|
|
1251
1539
|
|
|
1252
1540
|
/** ORPO diagnostics (no grad): the total loss and its `nll`/`or` split,
|
|
1253
1541
|
* preference accuracy (mean of ℓw > ℓr), and reward margin (mean log_odds).
|
|
1254
|
-
* Runs its own two forwards.
|
|
1542
|
+
* Runs its own two forwards. `sftScope` selects the reported nll's scope so
|
|
1543
|
+
* the diagnostic loss matches the training objective ("response" default). */
|
|
1255
1544
|
export function orpoMetrics(
|
|
1256
1545
|
model: RuntimeModel,
|
|
1257
1546
|
batch: DpoBatch,
|
|
1258
1547
|
lambda: number,
|
|
1548
|
+
sftScope: SftScope = "response",
|
|
1259
1549
|
): { loss: number; nll: number; or: number; accuracy: number; margin: number } {
|
|
1260
1550
|
const cLen = chosenLengths(batch);
|
|
1261
1551
|
const rLen = rejectedLengths(batch);
|
|
1262
|
-
|
|
1552
|
+
let lw: MlxArray;
|
|
1553
|
+
let nllFull: MlxArray | null = null;
|
|
1554
|
+
if (sftScope === "full") {
|
|
1555
|
+
const r = batch.chosenIds.length === 1
|
|
1556
|
+
? chosenLogpMeanAndFullNllB1(model, batch.chosenIds[0]!, batch.chosenMask[0]!, cLen[0]!)
|
|
1557
|
+
: chosenLogpMeanAndFullNllBatch(model, batch.chosenIds, batch.chosenMask, cLen);
|
|
1558
|
+
lw = r.lw;
|
|
1559
|
+
nllFull = r.nllFull;
|
|
1560
|
+
} else {
|
|
1561
|
+
lw = branchLogpMean(model, batch.chosenIds, batch.chosenMask, cLen); // [B]
|
|
1562
|
+
}
|
|
1263
1563
|
const lr = branchLogpMean(model, batch.rejectedIds, batch.rejectedMask, rLen); // [B]
|
|
1264
1564
|
|
|
1265
|
-
const negLw = ops.neg(lw);
|
|
1266
|
-
const nllArr = ops.meanAll(negLw, false);
|
|
1565
|
+
const negLw = ops.neg(lw); // (diagnostic -ℓw; the nll split uses nllFull under "full")
|
|
1566
|
+
const nllArr = nllFull ? ops.meanAll(nllFull, false) : ops.meanAll(negLw, false);
|
|
1267
1567
|
const logOdds = orpoLogOdds(lw, lr); // [B]
|
|
1268
1568
|
const negLO = ops.neg(logOdds);
|
|
1269
1569
|
const orPer = ops.softplus(negLO);
|
|
@@ -1285,5 +1585,6 @@ export function orpoMetrics(
|
|
|
1285
1585
|
margin /= loVals.length;
|
|
1286
1586
|
|
|
1287
1587
|
for (const a of [lw, lr, negLw, nllArr, logOdds, negLO, orPer, orArr]) a.dispose();
|
|
1588
|
+
nllFull?.dispose();
|
|
1288
1589
|
return { loss, nll, or, accuracy: acc, margin };
|
|
1289
1590
|
}
|