mlx-bun 0.0.4 → 0.0.6

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.
Files changed (90) hide show
  1. package/README.md +175 -93
  2. package/docs/dag/training-inference-map.html +575 -0
  3. package/docs/design/adapters-end-to-end.md +50 -46
  4. package/docs/design/compat-cli-surface-design.md +10 -8
  5. package/docs/design/docs-reorg-plan.md +14 -1
  6. package/docs/design/hlg-sampling.md +12 -2
  7. package/docs/design/memory-system.md +193 -295
  8. package/docs/design/optimization_plan.md +4 -2
  9. package/docs/design/orpo-dynamic-lambda.md +179 -0
  10. package/docs/design/orpo-future-enhancements.md +340 -0
  11. package/docs/design/orpo-training.md +1212 -0
  12. package/docs/design/parallel-slots.md +34 -26
  13. package/docs/design/parity-tier-dag.md +143 -0
  14. package/docs/design/runtime-isolation.md +1 -1
  15. package/docs/design/segmented-backward-training.md +58 -39
  16. package/docs/design/tauri-desktop-app.md +127 -0
  17. package/docs/design/w4a16-compute-precision-spike.md +232 -0
  18. package/docs/investigations/orpo-base-uf-experiment-and-directions.md +120 -0
  19. package/docs/investigations/orpo-flash-cce-pin-leak.md +167 -0
  20. package/docs/investigations/orpo-uf-testing-handoff.md +126 -0
  21. package/docs/{segmented-backward-handoff.md → investigations/segmented-backward-handoff.md} +1 -1
  22. package/docs/investigations/starter-model-port-handoff.md +32 -13
  23. package/docs/investigations/steel-flash-cce-handoff.md +489 -0
  24. package/docs/investigations/trainer-validation-experiment.md +173 -0
  25. package/docs/planning/IDEAS.md +29 -0
  26. package/docs/planning/PRODUCT_ROADMAP.md +147 -27
  27. package/docs/planning/chunk-task-roadmap.md +1 -1
  28. package/docs/planning/journal.md +161 -0
  29. package/docs/reference/distribution.md +61 -5
  30. package/docs/reference/library-api.md +29 -14
  31. package/docs/reference/memory.md +77 -0
  32. package/docs/reference/orpo-quickstart.md +130 -0
  33. package/docs/reference/server-api.md +105 -7
  34. package/docs/reference/server-config.md +12 -5
  35. package/docs/reference/training.md +296 -0
  36. package/package.json +1 -1
  37. package/src/cli.ts +651 -18
  38. package/src/eval/ifeval.ts +138 -0
  39. package/src/eval/runner.ts +76 -6
  40. package/src/eval/tasks/bfcl.ts +49 -15
  41. package/src/eval/tasks/gsm8k.ts +21 -9
  42. package/src/eval/tasks/hashhop.ts +33 -4
  43. package/src/eval/tasks/humaneval.ts +30 -8
  44. package/src/eval/tasks/ifeval.ts +52 -18
  45. package/src/eval/tasks/mmlu.ts +85 -74
  46. package/src/eval/ultrafeedback.ts +60 -0
  47. package/src/jobs/runner.ts +6 -0
  48. package/src/lora.ts +12 -7
  49. package/src/memory/chunk.ts +25 -0
  50. package/src/memory/cluster.ts +24 -0
  51. package/src/memory/db.ts +114 -0
  52. package/src/memory/ingest.ts +22 -0
  53. package/src/memory/pipeline.ts +63 -0
  54. package/src/memory/prompts.ts +23 -0
  55. package/src/memory/schedule.ts +163 -0
  56. package/src/memory/synthesize.ts +29 -0
  57. package/src/memory/tools.ts +399 -0
  58. package/src/memory/vault.ts +694 -0
  59. package/src/memory/wikify.ts +23 -0
  60. package/src/mlx/metal-kernel.ts +31 -4
  61. package/src/model/flash-attention.ts +37 -19
  62. package/src/model/fused-geglu-kernel.ts +111 -0
  63. package/src/model/gemma4-base.ts +39 -1
  64. package/src/model/gemma4.ts +196 -21
  65. package/src/model/minicpm5.ts +44 -3
  66. package/src/pi-provider.ts +29 -13
  67. package/src/pi-session.ts +81 -0
  68. package/src/pi-terminal.ts +13 -24
  69. package/src/pi-web.ts +246 -166
  70. package/src/quantize/job.ts +17 -2
  71. package/src/quantize/quantizer.ts +26 -2
  72. package/src/server.ts +216 -47
  73. package/src/tokenizer.ts +6 -2
  74. package/src/train/dataset.ts +29 -6
  75. package/src/train/flash-cce.ts +1175 -0
  76. package/src/train/job.ts +64 -3
  77. package/src/train/lora-params.ts +53 -4
  78. package/src/train/loss.ts +947 -0
  79. package/src/train/optimizer.ts +10 -3
  80. package/src/train/prefix-shared.ts +435 -0
  81. package/src/train/rank.ts +36 -0
  82. package/src/train/segmented.ts +1148 -3
  83. package/src/train/steel-qmm-header.ts +1461 -0
  84. package/src/train/trainer.ts +570 -54
  85. package/src/train/watch.ts +542 -0
  86. package/src/web/app.html +285 -19
  87. package/src/web/skills/memory/SKILL.md +97 -0
  88. package/src/web/skills/web-research/SKILL.md +3 -1
  89. package/src/web/skills.ts +46 -8
  90. /package/{docs → src/assets}/curve-designer.html +0 -0
package/src/train/loss.ts CHANGED
@@ -18,9 +18,68 @@
18
18
  import { MlxArray } from "../mlx/array";
19
19
  import { Dtype } from "../mlx/ffi";
20
20
  import * as ops from "../mlx/ops";
21
+ import { Checkpoint } from "../mlx/checkpoint";
22
+ import { CustomVjp } from "../mlx/custom-vjp";
21
23
  import type { RuntimeModel } from "../model/factory";
24
+ import { Gemma4Model } from "../model/gemma4";
25
+ import { MiniCPM5Model } from "../model/minicpm5";
26
+ import { logitSoftcap } from "../model/gemma4-base";
22
27
  import { trainForward, trainForwardHidden } from "./forward";
23
28
  import { setLoraScale, type TrainableLora } from "./lora-params";
29
+ import { flashCceForward, flashCceBackward, type FlashCceHead } from "./flash-cce";
30
+
31
+ /** The quantized LM-head weights + final-logit softcap, accessed uniformly across
32
+ * models for the fused linear-CE head: Gemma is tied (`embed.asLinear`, softcap
33
+ * from config), MiniCPM5 is the separate `lmHead` (no softcap). The head is NOT
34
+ * a default LoRA target, so the base quantized weights are authoritative. */
35
+ interface HeadQuant {
36
+ w: MlxArray; scales: MlxArray; biases: MlxArray | null;
37
+ spec: ops.QuantSpec; softcap: number | null;
38
+ }
39
+ function headQuant(model: RuntimeModel): HeadQuant {
40
+ if (model instanceof Gemma4Model) {
41
+ const e = model.embed;
42
+ return { w: e.w, scales: e.scales, biases: e.biases, spec: e.spec, softcap: model.config.text.finalLogitSoftcapping };
43
+ }
44
+ if (model instanceof MiniCPM5Model) {
45
+ const h = model.lmHead;
46
+ return { w: h.w, scales: h.scales, biases: h.biases, spec: h.spec, softcap: null };
47
+ }
48
+ throw new Error("fused linear-CE head is not wired for this model type");
49
+ }
50
+
51
+ /** Token-chunking context for the ORPO loss head. When `chunkSize > 0`, the B=1
52
+ * response-only head is computed in token-chunks, each wrapped in a Checkpoint
53
+ * so its `[chunk, vocab]` logits are RECOMPUTED in the backward instead of all
54
+ * `[M, vocab]` being retained — bounding the dominant large-vocab term to
55
+ * `[chunkSize, vocab]`. The checkpoints recompute during the enclosing
56
+ * value_and_grad backward, so they must outlive `orpoLoss`: they are pushed
57
+ * into `sink`, which the caller (orpoLoop) disposes AFTER `vag.apply` returns
58
+ * (i.e. after the backward). Exact — rematerialization is numerically
59
+ * identical, and MLX computes the (quantized) head backward itself. */
60
+ export interface ChunkCtx {
61
+ chunkSize: number;
62
+ /** When set, use the FUSED linear-CE head (`fusedLogpMeanB1`: one CustomVjp with
63
+ * an analytic softmax−onehot backward, no autograd through the head and no
64
+ * retained `[M,V]` logits) instead of the per-chunk `Checkpoint` head. Same
65
+ * token `chunkSize`; the fused path bounds large-vocab memory in both
66
+ * directions and is the Liger/CCE structure (the moat). Off → the Checkpoint
67
+ * token-chunked head (unchanged). */
68
+ fused?: boolean;
69
+ /** When set (with `fused`), route the head through the flash-CCE Metal kernel
70
+ * (`makeFlashCceHeadVjp`): in-kernel quantized logits + online softmax (fwd) and
71
+ * `dh` accumulation (bwd), so neither `[M,V]` logits nor a dequantized head touch
72
+ * HBM — the lowest-memory head, and the fastest on large vocab (e4b/CPM). The
73
+ * Apple-CCE coeff filter is on by default in the backward. */
74
+ flash?: boolean;
75
+ /** Disposables whose lifetime must extend past the enclosing value_and_grad
76
+ * backward: the per-chunk `Checkpoint`s / the fused `CustomVjp` (they recompute
77
+ * in the backward) AND the hidden `h` / chunk slices they read (these
78
+ * deliberately drop their input activations, so freeing them early corrupts the
79
+ * recompute — and even the forward eval). The caller (orpoLoop) disposes these
80
+ * only AFTER the step's grads are eval'd (the `afterMicroEval` hook). */
81
+ sink: Array<{ dispose(): void }>;
82
+ }
24
83
  import {
25
84
  rowLength, dpoChosenLength, dpoRejectedLength,
26
85
  type SftBatch, type DpoBatch,
@@ -120,6 +179,47 @@ export function responseOnlyCe(model: RuntimeModel, h: MlxArray, batch: SftBatch
120
179
  return loss;
121
180
  }
122
181
 
182
+ /** B=1 response-only length-normalized mean log-prob from pre-computed final-hidden h [1, T, hidden].
183
+ * Mirror of responseOnlyCe but returns mean response logp (scalar wrapped in [1]) instead of
184
+ * CE loss. Used by SegmentedBackwardOrpo's head VJP. Disposes nothing; caller owns h and result. */
185
+ export function responseOnlyLogpMean(
186
+ model: RuntimeModel,
187
+ h: MlxArray, // [1, T, hidden] post-finalNorm
188
+ ids: number[],
189
+ mask: number[],
190
+ ): MlxArray {
191
+ const L = ids.length;
192
+ const T = L - 1; // h has T positions
193
+ // Find response span: supervised positions where mask[t+1] == 1
194
+ let startT = -1; let M = 0;
195
+ for (let t = 0; t < T; t++) if (mask[t + 1]) { if (startT < 0) startT = t; M++; }
196
+ if (M <= 0) return MlxArray.fromFloat32(new Float32Array([0]), [1]);
197
+
198
+ const hidden = h.shape[2]!;
199
+ const start = MlxArray.fromInt32(new Int32Array([startT]), [1]);
200
+ const hResp = ops.sliceDynamic(h, start, [1], [1, M, hidden]); // [1, M, hidden]
201
+ const logits = model.logitsFromHidden(hResp); // [1, M, V]
202
+ const V = logits.shape[2]!;
203
+ const logits2d = ops.reshape(logits, [M, V]);
204
+
205
+ const targetsHost = new Int32Array(M);
206
+ for (let i = 0; i < M; i++) targetsHost[i] = ids[startT + 1 + i]!;
207
+ const targets = MlxArray.fromInt32(targetsHost, [M, 1]);
208
+
209
+ const lse = ops.logsumexpAxis(logits2d, -1, false); // [M]
210
+ const gathered = ops.takeAlongAxis(logits2d, targets, -1); // [M, 1]
211
+ const picked = ops.reshape(gathered, [M]); // [M]
212
+ const logp = ops.sub(picked, lse); // [M] log_softmax at label
213
+ const logpF = logp.dtype === Dtype.float32 ? logp : logp.astype(Dtype.float32);
214
+ const sumLogp = ops.sumAxis(logpF, 0, false); // scalar
215
+ const meanScalar = ops.mulScalar(sumLogp, 1 / M);
216
+ const mean = ops.reshape(meanScalar, [1]); // [1]
217
+
218
+ for (const a of [start, hResp, logits, logits2d, targets, lse, gathered, picked, logp, sumLogp, meanScalar]) a.dispose();
219
+ if (logpF !== logp) logpF.dispose();
220
+ return mean;
221
+ }
222
+
123
223
  /** Build the token-mean masked-CE scalar from batched logits [B, T, V]
124
224
  * (T = L-1) and the batch's host ids/boundaries. */
125
225
  export function maskedCe(logits: MlxArray, batch: SftBatch): MlxArray {
@@ -340,3 +440,850 @@ export function dpoMetrics(
340
440
  for (const a of [refChosen, refRejected, piC, piR, logrC, logrR, diff, adv, negAdv, zero, perRow, loss]) a.dispose();
341
441
  return { loss: lossVal, accuracy: acc, margin };
342
442
  }
443
+
444
+ // ---------------------------------------------------------------------------
445
+ // ORPO (Odds Ratio Preference Optimization, Hong et al. 2024)
446
+ // ---------------------------------------------------------------------------
447
+ //
448
+ // Reference-FREE: one objective, no reference model (vs DPO's scale-0 ref pass).
449
+ // L = L_NLL(chosen) + λ · L_OR
450
+ // L_NLL = -mean_response_logp(chosen) (the SFT term; unweighted)
451
+ // L_OR = -log σ(log_odds)
452
+ // log_odds = (ℓw - ℓr) - (log1mexp(ℓw) - log1mexp(ℓr))
453
+ // 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. Two facts
455
+ // shape the code: (1) ℓw feeds BOTH terms (L_NLL = -ℓw), so the chosen forward
456
+ // does double duty; (2) only 2 forwards/step (chosen + rejected), both with
457
+ // grad, no reference. This is the naïve full-logits oracle path — the chunked /
458
+ // response-only / segmented tiers validate against it (see the design's oracle
459
+ // ladder, docs/design/orpo-training.md).
460
+
461
+ /** Per-row LENGTH-NORMALIZED response log-prob for one branch (B>=1): the
462
+ * summed response logp divided by the per-row response-token count. Pad/prompt
463
+ * positions contribute 0 (mask is 0 there) and never inflate the count.
464
+ * Returns a [B] MlxArray (caller owns).
465
+ *
466
+ * B=1 takes the response-only path (mirrors `responseOnlyCe`): the LM head is
467
+ * applied ONLY at the supervised response positions, so the dominant
468
+ * `[1, T, vocab]` logits term (and its grad) is never materialized — the same
469
+ * long-context memory win the SFT B=1 path already gets, doubly important for
470
+ * ORPO since it forwards two responses. Numerically identical to the B>1
471
+ * full-logits path (same positions, same count). B>1 uses the full-logits
472
+ * path (the batched forward already materializes logits). */
473
+ function branchLogpMean(
474
+ model: RuntimeModel,
475
+ ids: number[][],
476
+ mask: number[][],
477
+ validLen: number[],
478
+ chunk?: ChunkCtx,
479
+ ): MlxArray {
480
+ if (ids.length === 1) {
481
+ if (chunk && chunk.chunkSize > 0)
482
+ return chunk.fused
483
+ ? fusedLogpMeanB1(model, ids[0]!, mask[0]!, validLen[0]!, chunk.chunkSize, chunk.sink, 0, chunk.flash ?? false)
484
+ : chunkedLogpMeanB1(model, ids[0]!, mask[0]!, validLen[0]!, chunk.chunkSize, chunk.sink);
485
+ return branchLogpMeanB1(model, ids[0]!, mask[0]!, validLen[0]!);
486
+ }
487
+
488
+ const sum = branchLogp(model, ids, mask, validLen); // [B] summed response logp
489
+ // Per-row response-token count = Σ_t mask[b][t+1] (resp_mask[:, 1:]); clamp to
490
+ // >=1 so an all-prompt row (no response) can't divide by zero. Multiply by the
491
+ // host-side reciprocal (avoids needing an elementwise divide op).
492
+ const B = ids.length;
493
+ const L = ids[0]!.length;
494
+ const recip = new Float32Array(B);
495
+ for (let b = 0; b < B; b++) {
496
+ let c = 0;
497
+ for (let t = 0; t < L - 1; t++) c += mask[b]![t + 1]!;
498
+ recip[b] = 1 / Math.max(c, 1);
499
+ }
500
+ const recipArr = MlxArray.fromFloat32(recip, [B]);
501
+ const mean = ops.mul(sum, recipArr); // [B] length-normalized
502
+ sum.dispose();
503
+ recipArr.dispose();
504
+ return mean;
505
+ }
506
+
507
+ /** B=1 response-only length-normalized log-prob → [1]. The supervised input
508
+ * positions are exactly `{ t in [0, T) : mask[t+1] == 1 }` (the same set the
509
+ * full-logits seqLogp sums), which for prompt+response data is the contiguous
510
+ * span `[startT, startT+M)`; the LM head runs only on that slice. Grads to
511
+ * prompt-position LoRA still flow — the response hidden states depend on the
512
+ * prompt through causal attention, captured upstream in `h`. */
513
+ export function branchLogpMeanB1(
514
+ model: RuntimeModel,
515
+ ids: number[],
516
+ mask: number[],
517
+ validLen: number,
518
+ ): MlxArray {
519
+ const L = ids.length;
520
+ const T = L - 1;
521
+ // Supervised input positions (contiguous for prompt+response): first/count.
522
+ let startT = -1;
523
+ let M = 0;
524
+ for (let t = 0; t < T; t++) if (mask[t + 1]) { if (startT < 0) startT = t; M++; }
525
+ if (M <= 0) return MlxArray.fromFloat32(new Float32Array([0]), [1]); // no response → 0 (matches clamp)
526
+
527
+ const inputHost = new Int32Array(T);
528
+ for (let t = 0; t < T; t++) inputHost[t] = ids[t]!;
529
+ const inputIds = MlxArray.fromInt32(inputHost, [1, T]);
530
+ const inputValid = [Math.max(0, Math.min(validLen, L) - 1)];
531
+
532
+ const h = trainForwardHidden(model, inputIds, inputValid); // [1, T, hidden]
533
+ const hidden = h.shape[2]!;
534
+ const start = MlxArray.fromInt32(new Int32Array([startT]), [1]);
535
+ const hResp = ops.sliceDynamic(h, start, [1], [1, M, hidden]); // [1, M, hidden]
536
+ const logits = model.logitsFromHidden(hResp); // [1, M, V] — head only on the response span
537
+ const V = logits.shape[2]!;
538
+ const logits2d = ops.reshape(logits, [M, V]);
539
+
540
+ const targetsHost = new Int32Array(M);
541
+ for (let i = 0; i < M; i++) targetsHost[i] = ids[startT + 1 + i]!;
542
+ const targets = MlxArray.fromInt32(targetsHost, [M, 1]);
543
+
544
+ const lse = ops.logsumexpAxis(logits2d, -1, false); // [M]
545
+ const gathered = ops.takeAlongAxis(logits2d, targets, -1); // [M, 1]
546
+ const picked = ops.reshape(gathered, [M]); // [M]
547
+ const logp = ops.sub(picked, lse); // [M] log_softmax at the label
548
+ const logpF = logp.dtype === Dtype.float32 ? logp : logp.astype(Dtype.float32);
549
+ const sumLogp = ops.sumAxis(logpF, 0, false); // scalar
550
+ const meanScalar = ops.mulScalar(sumLogp, 1 / M); // scalar (length-normalized)
551
+ const mean = ops.reshape(meanScalar, [1]); // [1] to match the [B] contract
552
+
553
+ for (const a of [inputIds, h, start, hResp, logits, logits2d, targets, lse, gathered, picked, logp, sumLogp, meanScalar]) a.dispose();
554
+ if (logpF !== logp) logpF.dispose();
555
+ return mean;
556
+ }
557
+
558
+ /** B=1 chunked response-only mean log-prob → [1]. Same result as
559
+ * `branchLogpMeanB1` but the LM head over the M response positions is computed
560
+ * in token-chunks of `chunkSize`, each wrapped in a `Checkpoint`: the
561
+ * `[chunk, vocab]` logits are recomputed in the backward rather than all
562
+ * `[M, vocab]` retained, so peak large-vocab memory is bounded to
563
+ * `[chunkSize, vocab]`. Each checkpoint must survive until the enclosing
564
+ * value_and_grad backward (it recomputes there), so it is pushed into `sink`
565
+ * for the caller to dispose after `vag.apply`. Grads flow chunk → slice → `h`
566
+ * → layer-stack LoRA exactly as the single-shot path. */
567
+ function chunkedLogpMeanB1(
568
+ model: RuntimeModel,
569
+ ids: number[],
570
+ mask: number[],
571
+ validLen: number,
572
+ chunkSize: number,
573
+ sink: Array<{ dispose(): void }>,
574
+ ): MlxArray {
575
+ const L = ids.length;
576
+ const T = L - 1;
577
+ let startT = -1;
578
+ let M = 0;
579
+ for (let t = 0; t < T; t++) if (mask[t + 1]) { if (startT < 0) startT = t; M++; }
580
+ if (M <= 0) return MlxArray.fromFloat32(new Float32Array([0]), [1]);
581
+
582
+ const inputHost = new Int32Array(T);
583
+ for (let t = 0; t < T; t++) inputHost[t] = ids[t]!;
584
+ const inputIds = MlxArray.fromInt32(inputHost, [1, T]);
585
+ const inputValid = [Math.max(0, Math.min(validLen, L) - 1)];
586
+ const h = trainForwardHidden(model, inputIds, inputValid); // [1, T, hidden]
587
+ inputIds.dispose();
588
+ // `h` and the chunk slices feed Checkpoints, which drop their input
589
+ // activations — so they must NOT be freed until after the backward. Defer to
590
+ // the sink (disposed post-eval by the caller).
591
+ sink.push(h);
592
+ const hidden = h.shape[2]!;
593
+
594
+ let total: MlxArray | null = null;
595
+ for (let c0 = startT; c0 < startT + M; c0 += chunkSize) {
596
+ const c1 = Math.min(c0 + chunkSize, startT + M);
597
+ const Cc = c1 - c0;
598
+ const startArr = MlxArray.fromInt32(new Int32Array([c0]), [1]);
599
+ const hChunk = ops.sliceDynamic(h, startArr, [1], [1, Cc, hidden]); // [1, Cc, hidden]
600
+ startArr.dispose();
601
+ sink.push(hChunk); // checkpoint input — alive until post-eval
602
+
603
+ // Targets for this chunk, captured immutably by the checkpoint closure so
604
+ // the backward recompute reproduces exactly these positions' logp.
605
+ const tHost = new Int32Array(Cc);
606
+ for (let i = 0; i < Cc; i++) tHost[i] = ids[c0 + 1 + i]!;
607
+
608
+ const ckpt = new Checkpoint((inp) => {
609
+ const hc = inp[0]!;
610
+ const logits = model.logitsFromHidden(hc); // [1, Cc, V] — recomputed in backward
611
+ const V = logits.shape[2]!;
612
+ const l2 = ops.reshape(logits, [Cc, V]);
613
+ const targets = MlxArray.fromInt32(tHost, [Cc, 1]);
614
+ const lse = ops.logsumexpAxis(l2, -1, false); // [Cc]
615
+ const gathered = ops.takeAlongAxis(l2, targets, -1); // [Cc, 1]
616
+ const picked = ops.reshape(gathered, [Cc]); // [Cc]
617
+ const logp = ops.sub(picked, lse); // [Cc]
618
+ const logpF = logp.dtype === Dtype.float32 ? logp : logp.astype(Dtype.float32);
619
+ const s = ops.sumAxis(logpF, 0, false); // scalar — Σ logp over the chunk
620
+ for (const a of [logits, l2, targets, lse, gathered, picked, logp]) a.dispose();
621
+ if (logpF !== logp) logpF.dispose();
622
+ return [s];
623
+ });
624
+ sink.push(ckpt); // disposed by the caller after the backward (vag.apply)
625
+ const [s] = ckpt.apply([hChunk]);
626
+ if (total === null) total = s!;
627
+ else {
628
+ const sum = ops.add(total, s!);
629
+ total.dispose();
630
+ s!.dispose();
631
+ total = sum;
632
+ }
633
+ }
634
+
635
+ const meanScalar = ops.mulScalar(total!, 1 / M); // length-normalized
636
+ total!.dispose();
637
+ const mean = ops.reshape(meanScalar, [1]); // [1] to match the [B] contract
638
+ meanScalar.dispose();
639
+ return mean;
640
+ }
641
+
642
+ /** CHECKPOINT token-chunked response-only mean logp from a PRE-COMPUTED
643
+ * post-finalNorm hidden `h [1,T,hidden]` (the segmented-backward head variant of
644
+ * `chunkedLogpMeanB1`). Each token-chunk's `logitsFromHidden` is wrapped in a
645
+ * `Checkpoint`, so the per-chunk `[chunk,V]` logits are RECOMPUTED in the
646
+ * backward and freed sequentially — the recompute boundary is what actually
647
+ * bounds the head term under MLX's lazy eval when nested inside the segmented
648
+ * `mlx_vjp` (unlike the analytic CustomVjp, whose per-chunk graph isn't freed
649
+ * incrementally inside an outer vjp). Checkpoints + slices go in `sink`. */
650
+ export function chunkedLogpMeanFromHidden(
651
+ model: RuntimeModel,
652
+ h: MlxArray, // [1, T, hidden] post-finalNorm
653
+ ids: number[],
654
+ mask: number[],
655
+ chunkSize: number,
656
+ sink: Array<{ dispose(): void }>,
657
+ ): MlxArray {
658
+ const L = ids.length;
659
+ const T = L - 1;
660
+ let startT = -1;
661
+ let M = 0;
662
+ for (let t = 0; t < T; t++) if (mask[t + 1]) { if (startT < 0) startT = t; M++; }
663
+ if (M <= 0) return MlxArray.fromFloat32(new Float32Array([0]), [1]);
664
+ const hidden = h.shape[2]!;
665
+
666
+ let total: MlxArray | null = null;
667
+ for (let c0 = startT; c0 < startT + M; c0 += chunkSize) {
668
+ const c1 = Math.min(c0 + chunkSize, startT + M);
669
+ const Cc = c1 - c0;
670
+ const startArr = MlxArray.fromInt32(new Int32Array([c0]), [1]);
671
+ const hChunk = ops.sliceDynamic(h, startArr, [1], [1, Cc, hidden]); // [1, Cc, hidden]
672
+ startArr.dispose();
673
+ sink.push(hChunk);
674
+ const tHost = new Int32Array(Cc);
675
+ for (let i = 0; i < Cc; i++) tHost[i] = ids[c0 + 1 + i]!;
676
+ const ckpt = new Checkpoint((inp) => {
677
+ const hc = inp[0]!;
678
+ const logits = model.logitsFromHidden(hc); // [1, Cc, V] — recomputed in backward
679
+ const V = logits.shape[2]!;
680
+ const l2 = ops.reshape(logits, [Cc, V]);
681
+ const targets = MlxArray.fromInt32(tHost, [Cc, 1]);
682
+ const lse = ops.logsumexpAxis(l2, -1, false);
683
+ const gathered = ops.takeAlongAxis(l2, targets, -1);
684
+ const picked = ops.reshape(gathered, [Cc]);
685
+ const logp = ops.sub(picked, lse);
686
+ const logpF = logp.dtype === Dtype.float32 ? logp : logp.astype(Dtype.float32);
687
+ const s = ops.sumAxis(logpF, 0, false);
688
+ for (const a of [logits, l2, targets, lse, gathered, picked, logp]) a.dispose();
689
+ if (logpF !== logp) logpF.dispose();
690
+ return [s];
691
+ });
692
+ sink.push(ckpt);
693
+ const [s] = ckpt.apply([hChunk]);
694
+ if (total === null) total = s!;
695
+ else { const sum = ops.add(total, s!); total.dispose(); s!.dispose(); total = sum; }
696
+ }
697
+ const meanScalar = ops.mulScalar(total!, 1 / M);
698
+ total!.dispose();
699
+ const mean = ops.reshape(meanScalar, [1]);
700
+ meanScalar.dispose();
701
+ return mean;
702
+ }
703
+
704
+ /** B=1 FUSED linear cross-entropy response-only mean log-prob → [1]. Same result
705
+ * as `branchLogpMeanB1` but the LM head is a single `CustomVjp` with an ANALYTIC
706
+ * backward — the Liger `FusedLinearCrossEntropy` structure ported to MLX:
707
+ * - FORWARD token-chunks the M response rows; per chunk materializes only
708
+ * `[Cc, V]` logits (= `h_c @ Wᵀ` via `quantizedMatmul`, + Gemma softcap),
709
+ * reduces to `logp = target_logit − logsumexp`, and DROPS the logits. Returns
710
+ * Σ logp (the `1/M` mean is applied outside, a cheap linear scale).
711
+ * - BACKWARD recomputes `[Cc, V]` per chunk and forms the gradient in CLOSED
712
+ * FORM — `∂(Σlogp)/∂logit = onehot_target − softmax` (× `sech²` through the
713
+ * softcap), scaled by the incoming cotangent — instead of autograd-ing through
714
+ * logsumexp+gather (what the `Checkpoint` token-chunked path does). `dh_c =
715
+ * grad_logits_c @ dequant(W)` uses `quantizedMatmul(…, transpose=false)`, which
716
+ * IS mlx's own x-vjp of the `transpose=true` head matmul (the path LoRA
717
+ * training already differentiates through), so it contracts the vocab axis
718
+ * correctly without dequantizing the full `[V, hidden]` head (1.3 GB at 262k).
719
+ * Peak large-vocab memory is bounded to `[chunkSize, V]` in BOTH directions, the
720
+ * full `[M, V]` logits never materialize, and the head backward needs no autograd
721
+ * graph. The `CustomVjp` recomputes in the backward (it must outlive the
722
+ * enclosing value_and_grad), so it — and `h` — are pushed into `sink`, disposed
723
+ * by the caller after the step's grads are eval'd. The head is NOT a LoRA target,
724
+ * so only `dh` flows back (seeding the layer-stack LoRA backward); a head-LoRA
725
+ * `∇A/∇B` fold-in is a follow-on. Exact within the established bf16 class. */
726
+ export function fusedLogpMeanB1(
727
+ model: RuntimeModel,
728
+ ids: number[],
729
+ mask: number[],
730
+ validLen: number,
731
+ chunkSize: number,
732
+ sink: Array<{ dispose(): void }>,
733
+ vocabBlock = 0,
734
+ flash = false,
735
+ ): MlxArray {
736
+ const L = ids.length;
737
+ const T = L - 1;
738
+ let startT = -1;
739
+ let M = 0;
740
+ for (let t = 0; t < T; t++) if (mask[t + 1]) { if (startT < 0) startT = t; M++; }
741
+ if (M <= 0) return MlxArray.fromFloat32(new Float32Array([0]), [1]);
742
+
743
+ const inputHost = new Int32Array(T);
744
+ for (let t = 0; t < T; t++) inputHost[t] = ids[t]!;
745
+ const inputIds = MlxArray.fromInt32(inputHost, [1, T]);
746
+ const inputValid = [Math.max(0, Math.min(validLen, L) - 1)];
747
+ const h = trainForwardHidden(model, inputIds, inputValid); // [1, T, hidden]
748
+ inputIds.dispose();
749
+ sink.push(h); // primal of the CustomVjp (via the slice below) — alive until post-eval
750
+ const hidden = h.shape[2]!;
751
+
752
+ // Response hiddens [M, hidden] — the CustomVjp primal.
753
+ const startArr = MlxArray.fromInt32(new Int32Array([startT]), [1]);
754
+ const hRespSlice = ops.sliceDynamic(h, startArr, [1], [1, M, hidden]); // [1, M, hidden]
755
+ startArr.dispose();
756
+ const hResp = ops.reshape(hRespSlice, [M, hidden]); // [M, hidden]
757
+ hRespSlice.dispose();
758
+ sink.push(hResp);
759
+
760
+ const targetsHost = new Int32Array(M);
761
+ for (let i = 0; i < M; i++) targetsHost[i] = ids[startT + 1 + i]!;
762
+ return fusedRespLogpMean(model, hResp, targetsHost, chunkSize, sink, vocabBlock, flash);
763
+ }
764
+
765
+ /** Fused linear-CE log-prob from a PRE-COMPUTED post-finalNorm hidden
766
+ * `h [1, T, hidden]` (the segmented-backward head: finalNorm is applied by the
767
+ * caller). Mirrors `responseOnlyLogpMean`'s response-span selection but routes
768
+ * the head through the fused analytic CustomVjp, so the head term inside the
769
+ * segmented backward is bounded to `[chunk, V]` instead of the full `[M, V]`.
770
+ * Composes with the segmented `mlx_vjp` (the CustomVjp nests inside it, like the
771
+ * fused-GeGLU kernel). The CustomVjp + the response slice are pushed into `sink`,
772
+ * disposed by the caller AFTER the head vjp is eval'd. */
773
+ export function fusedLogpMeanFromHidden(
774
+ model: RuntimeModel,
775
+ h: MlxArray, // [1, T, hidden] post-finalNorm
776
+ ids: number[],
777
+ mask: number[],
778
+ chunkSize: number,
779
+ sink: Array<{ dispose(): void }>,
780
+ vocabBlock = 0,
781
+ flash = false,
782
+ ): MlxArray {
783
+ const L = ids.length;
784
+ const T = L - 1;
785
+ let startT = -1;
786
+ let M = 0;
787
+ for (let t = 0; t < T; t++) if (mask[t + 1]) { if (startT < 0) startT = t; M++; }
788
+ if (M <= 0) return MlxArray.fromFloat32(new Float32Array([0]), [1]);
789
+ const hidden = h.shape[2]!;
790
+ const startArr = MlxArray.fromInt32(new Int32Array([startT]), [1]);
791
+ const hRespSlice = ops.sliceDynamic(h, startArr, [1], [1, M, hidden]); // [1, M, hidden]
792
+ startArr.dispose();
793
+ const hResp = ops.reshape(hRespSlice, [M, hidden]); // [M, hidden]
794
+ hRespSlice.dispose();
795
+ sink.push(hResp);
796
+ const targetsHost = new Int32Array(M);
797
+ for (let i = 0; i < M; i++) targetsHost[i] = ids[startT + 1 + i]!;
798
+ return fusedRespLogpMean(model, hResp, targetsHost, chunkSize, sink, vocabBlock, flash);
799
+ }
800
+
801
+ /** The fused linear-CE head core: response hidden `hResp [M, hidden]`
802
+ * (post-finalNorm) + target ids → length-normalized mean logp [1], via the
803
+ * analytic CustomVjp (token-chunked forward, softmax−onehot backward, dh through
804
+ * mlx's quantized x-vjp). Shared by `fusedLogpMeanB1` (re-runs the forward) and
805
+ * `fusedLogpMeanFromHidden` (segmented head). Pushes the CustomVjp into `sink`;
806
+ * the caller owns `hResp` (and is responsible for its sink lifetime). */
807
+ /** VOCAB-BLOCKED online-softmax fused head (full Cut Cross Entropy structure):
808
+ * even `[chunk, V]` never exists — the vocab axis is tiled into `vocabBlock`-wide
809
+ * blocks and the logsumexp is built with the [online-softmax](https://arxiv.org/abs/1805.02867)
810
+ * running (max `m`, sumexp `d`) recurrence, so only `[chunk, vocabBlock]` is ever
811
+ * live (MLX frees each block's tensor after the running scalars update — the
812
+ * blocks are sequentially dependent through `m`/`d`). The target logit is captured
813
+ * per block (host-side membership mask, so no full-row gather). The backward
814
+ * recomputes per `(token-chunk, vocab-block)`: `softmax_block = exp(logit_block −
815
+ * lse)`, `grad = cotangent·(onehot_block − softmax_block)·sech²`, accumulating
816
+ * `dh += grad @ dequant(W_block)` (mlx x-vjp, `transpose=false`) into a
817
+ * `[chunk, hidden]` running buffer — `lse` is recomputed by one online pass (the
818
+ * scalar `[chunk]` lse is cheap; stashing it forward→backward is the FLOP
819
+ * optimization left as a follow-on). NOT bit-exact to the whole-vocab head (the
820
+ * block-wise reduction re-associates), but within the bf16 class.
821
+ * `W_block` = an axis-0 (vocab) row-slice of the quantized `w`/`scales`/`biases`. */
822
+ function makeVocabBlockedHeadVjp(
823
+ head: HeadQuant, targetsHost: Int32Array, M: number, hidden: number,
824
+ tokenChunk: number, vocabBlock: number,
825
+ ): CustomVjp {
826
+ const cap = head.softcap;
827
+ const V = head.scales.shape[0]!;
828
+ const wCols = head.w.shape[1]!, scCols = head.scales.shape[1]!, biCols = head.biases?.shape[1] ?? 0;
829
+ const sliceHead = (v0: number, v1: number) => ({
830
+ w: head.w.slice([v0, 0], [v1, wCols]),
831
+ scales: head.scales.slice([v0, 0], [v1, scCols]),
832
+ biases: head.biases ? head.biases.slice([v0, 0], [v1, biCols]) : null,
833
+ });
834
+ // Post-softcap logits [Cc,Vb] for a vocab block, plus sech² (null if no softcap)
835
+ // for the backward chain rule. The block weights `wb` are RETURNED (the backward
836
+ // reuses them for dh = grad @ dequant(W_block)); caller disposes them.
837
+ const blockLogits = (hc: MlxArray, wb: { w: MlxArray; scales: MlxArray; biases: MlxArray | null }):
838
+ { lb: MlxArray; sech2: MlxArray | null } => {
839
+ const raw = ops.quantizedMatmul(hc, wb.w, wb.scales, wb.biases, head.spec, true); // [Cc, Vb]
840
+ if (cap === null) return { lb: raw, sech2: null };
841
+ const capArr = ops.scalarLike(cap, raw);
842
+ const div = ops.div(raw, capArr);
843
+ const th = ops.tanh(div); div.dispose(); // bind the nested div so its wrapper is freed
844
+ raw.dispose();
845
+ const lb = ops.mul(th, capArr); // cap·tanh(raw/cap)
846
+ const th2 = ops.mul(th, th); th.dispose();
847
+ const one = ops.scalarLike(1, th2);
848
+ const sech2 = ops.sub(one, th2); // 1 − tanh²
849
+ one.dispose(); th2.dispose(); capArr.dispose();
850
+ return { lb, sech2 };
851
+ };
852
+ // One online-softmax pass over vocab blocks for a token-chunk hc [Cc,hidden]:
853
+ // running (max m, sumexp d) so only [Cc,Vb] is ever live → lse [Cc] + the
854
+ // captured target logit [Cc]. Used by fwd and (to recompute lse) by the vjp.
855
+ const onlinePass = (hc: MlxArray, c0: number, Cc: number): { lse: MlxArray; tgt: MlxArray } => {
856
+ let m: MlxArray | null = null; // [Cc] running max
857
+ let d: MlxArray | null = null; // [Cc] running sumexp (relative to m)
858
+ let tgt: MlxArray | null = null; // [Cc] accumulated (masked) target logit
859
+ for (let v0 = 0; v0 < V; v0 += vocabBlock) {
860
+ const v1 = Math.min(v0 + vocabBlock, V);
861
+ const wb = sliceHead(v0, v1);
862
+ const { lb: lbRaw, sech2 } = blockLogits(hc, wb); // [Cc, Vb]
863
+ sech2?.dispose();
864
+ wb.w.dispose(); wb.scales.dispose(); wb.biases?.dispose();
865
+ // Online (m, d) accumulation in f32 — bf16 across many blocks loses the
866
+ // precision that softmax = exp(logit − lse) is exponentially sensitive to
867
+ // (matches logsumexpAxis's f32-internal reduction).
868
+ const lb = lbRaw.dtype === Dtype.float32 ? lbRaw : lbRaw.astype(Dtype.float32);
869
+ if (lb !== lbRaw) lbRaw.dispose();
870
+ const bmax = ops.maxAxis(lb, -1, false); // [Cc]
871
+ const mNew: MlxArray = m === null ? bmax : ops.maximum(m, bmax);
872
+ if (m !== null) bmax.dispose();
873
+ const mCol = ops.reshape(mNew, [Cc, 1]);
874
+ const shifted = ops.sub(lb, mCol); mCol.dispose();
875
+ const ex = ops.exp(shifted); shifted.dispose(); // [Cc, Vb]
876
+ const blockSum = ops.sumAxis(ex, -1, false); ex.dispose(); // [Cc]
877
+ let dNew: MlxArray;
878
+ if (d === null) dNew = blockSum;
879
+ else {
880
+ const md = ops.sub(m!, mNew); // prevM − mNew [Cc]
881
+ const sc = ops.exp(md); md.dispose();
882
+ const rescaled = ops.mul(d, sc); sc.dispose();
883
+ dNew = ops.add(rescaled, blockSum);
884
+ rescaled.dispose(); blockSum.dispose();
885
+ }
886
+ // Target logit capture: host membership mask (each target lands in one
887
+ // block), gather the clamped local index, accumulate masked across blocks.
888
+ const locHost = new Int32Array(Cc), maskHost = new Float32Array(Cc);
889
+ for (let t = 0; t < Cc; t++) {
890
+ const tg = targetsHost[c0 + t]!;
891
+ const inb = tg >= v0 && tg < v1;
892
+ locHost[t] = inb ? tg - v0 : 0;
893
+ maskHost[t] = inb ? 1 : 0;
894
+ }
895
+ const locArr = MlxArray.fromInt32(locHost, [Cc, 1]);
896
+ const gathered = ops.reshape(ops.takeAlongAxis(lb, locArr, -1), [Cc]); // [Cc]
897
+ locArr.dispose();
898
+ const maskA = MlxArray.fromFloat32(maskHost, [Cc]);
899
+ const maskM = maskA.dtype === gathered.dtype ? maskA : maskA.astype(gathered.dtype);
900
+ const contrib = ops.mul(gathered, maskM); gathered.dispose();
901
+ if (maskM !== maskA) maskA.dispose();
902
+ maskM.dispose();
903
+ const tgtNew: MlxArray = tgt === null ? contrib : ops.add(tgt, contrib);
904
+ if (tgt !== null) { tgt.dispose(); contrib.dispose(); }
905
+ lb.dispose();
906
+ if (m !== null) m.dispose();
907
+ if (d !== null) d.dispose();
908
+ m = mNew; d = dNew; tgt = tgtNew;
909
+ }
910
+ const logd = ops.log(d!);
911
+ const lse = ops.add(m!, logd);
912
+ logd.dispose(); m!.dispose(); d!.dispose();
913
+ return { lse, tgt: tgt! };
914
+ };
915
+
916
+ return new CustomVjp(
917
+ // FORWARD: Σ over the response of (target_logit − lse), token-chunked, with
918
+ // the lse built vocab-block-by-block (online). Only [Cc,Vb] ever materializes.
919
+ (inputs) => {
920
+ const hR = inputs[0]!; // [M, hidden]
921
+ let sum: MlxArray | null = null;
922
+ for (let c0 = 0; c0 < M; c0 += tokenChunk) {
923
+ const c1 = Math.min(c0 + tokenChunk, M);
924
+ const Cc = c1 - c0;
925
+ const hc = hR.slice([c0, 0], [c1, hidden]);
926
+ const { lse, tgt } = onlinePass(hc, c0, Cc);
927
+ hc.dispose();
928
+ const logp = ops.sub(tgt, lse); // [Cc]
929
+ tgt.dispose(); lse.dispose();
930
+ const logpF = logp.dtype === Dtype.float32 ? logp : logp.astype(Dtype.float32);
931
+ const s = ops.sumAxis(logpF, 0, false);
932
+ logp.dispose(); if (logpF !== logp) logpF.dispose();
933
+ if (sum === null) sum = s;
934
+ else { const n = ops.add(sum, s); sum.dispose(); s.dispose(); sum = n; }
935
+ }
936
+ const out = ops.reshape(sum!, [1]);
937
+ sum!.dispose();
938
+ return [out];
939
+ },
940
+ // BACKWARD: per token-chunk, recompute lse (online), then per vocab block form
941
+ // softmax_block = exp(logit_block − lse), grad = cot·(onehot − softmax)·sech²,
942
+ // accumulate dh += grad @ dequant(W_block). Peak stays [Cc,Vb].
943
+ (primals, cots) => {
944
+ const hR = primals[0]!; // [M, hidden]
945
+ const c = cots[0]!; // [1] scalar = ∂loss/∂(Σlogp)
946
+ const dtype = hR.dtype;
947
+ const cMatch = c.dtype === dtype ? c : c.astype(dtype);
948
+ const chunks: MlxArray[] = [];
949
+ for (let c0 = 0; c0 < M; c0 += tokenChunk) {
950
+ const c1 = Math.min(c0 + tokenChunk, M);
951
+ const Cc = c1 - c0;
952
+ const hc = hR.slice([c0, 0], [c1, hidden]);
953
+ const { lse, tgt } = onlinePass(hc, c0, Cc);
954
+ tgt.dispose();
955
+ const lseCol = ops.reshape(lse, [Cc, 1]); lse.dispose();
956
+ let dh: MlxArray | null = null; // [Cc, hidden]
957
+ for (let v0 = 0; v0 < V; v0 += vocabBlock) {
958
+ const v1 = Math.min(v0 + vocabBlock, V);
959
+ const Vb = v1 - v0;
960
+ const wb = sliceHead(v0, v1);
961
+ const { lb, sech2 } = blockLogits(hc, wb); // [Cc, Vb]
962
+ const softmax = ops.exp(ops.sub(lb, lseCol)); // [Cc, Vb]
963
+ lb.dispose();
964
+ // onehot_block: global col == target.
965
+ const colFlat = ops.arange(v0, v1, 1, Dtype.int32);
966
+ const col = ops.reshape(colFlat, [1, Vb]); colFlat.dispose();
967
+ const tcol = MlxArray.fromInt32(targetsHost.slice(c0, c1), [Cc, 1]);
968
+ const ge1 = ops.greaterEqual(col, tcol);
969
+ const ge2 = ops.greaterEqual(tcol, col);
970
+ const eqMask = ops.logicalAnd(ge1, ge2); // bind the nested compares so their wrappers free
971
+ ge1.dispose(); ge2.dispose();
972
+ col.dispose(); tcol.dispose();
973
+ const onehot = eqMask.astype(softmax.dtype); eqMask.dispose();
974
+ let g = ops.sub(onehot, softmax); // onehot − softmax [Cc, Vb]
975
+ onehot.dispose(); softmax.dispose();
976
+ if (sech2) { const gg = ops.mul(g, sech2); g.dispose(); sech2.dispose(); g = gg; }
977
+ const gScaled = ops.mul(g, cMatch); g.dispose();
978
+ const gT = gScaled.dtype === dtype ? gScaled : gScaled.astype(dtype);
979
+ if (gT !== gScaled) gScaled.dispose();
980
+ const dhcBf = ops.quantizedMatmul(gT, wb.w, wb.scales, wb.biases, head.spec, false); // [Cc, hidden]
981
+ gT.dispose();
982
+ wb.w.dispose(); wb.scales.dispose(); wb.biases?.dispose();
983
+ // Accumulate dh in f32: the whole-vocab qmm reduces V in f32 internally,
984
+ // but summing hundreds of bf16 block-dh's re-introduces an error that
985
+ // scales with block count (the dominant grad bug at small vocabBlock).
986
+ const dhc = dhcBf.dtype === Dtype.float32 ? dhcBf : dhcBf.astype(Dtype.float32);
987
+ if (dhc !== dhcBf) dhcBf.dispose();
988
+ if (dh === null) dh = dhc;
989
+ else { const n = ops.add(dh, dhc); dh.dispose(); dhc.dispose(); dh = n; }
990
+ }
991
+ lseCol.dispose(); hc.dispose();
992
+ const dhFinal = dh!.dtype === dtype ? dh! : dh!.astype(dtype);
993
+ if (dhFinal !== dh!) dh!.dispose();
994
+ chunks.push(dhFinal);
995
+ }
996
+ if (cMatch !== c) cMatch.dispose();
997
+ const dh = chunks.length === 1 ? chunks[0]! : ops.concatAxis(chunks, 0); // [M, hidden]
998
+ if (chunks.length > 1) for (const a of chunks) a.dispose();
999
+ return [dh];
1000
+ },
1001
+ );
1002
+ }
1003
+
1004
+ /** flash-CCE head as a CustomVjp: forward runs the Metal kernel → Σ logp (stashing
1005
+ * `lse` for the backward, avoiding a forward recompute); backward runs the kernel's
1006
+ * `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 are OFF by
1008
+ * default (env-gated MLX_BUN_CCE_BWD_*_EPS) — the backward computes the EXACT dh. */
1009
+ function makeFlashCceHeadVjp(
1010
+ head: HeadQuant, targetsHost: Int32Array, M: number,
1011
+ sink: Array<{ dispose(): void }>,
1012
+ ): CustomVjp {
1013
+ if (!head.biases) throw new Error("flash-CCE head requires affine (biased) quantization");
1014
+ const fch: FlashCceHead = {
1015
+ w: head.w, scales: head.scales, biases: head.biases,
1016
+ bits: head.spec.bits, groupSize: head.spec.groupSize, softcap: head.softcap,
1017
+ };
1018
+ const targets = Array.from(targetsHost);
1019
+ let savedLse: MlxArray | null = null;
1020
+ let savedBlockMax: MlxArray | null = null;
1021
+ return new CustomVjp(
1022
+ (inputs) => {
1023
+ const hR = inputs[0]!; // [M, hidden]
1024
+ const { logp, lse, blockMax } = flashCceForward(hR, fch, targets); // logp[M], lse[M], blockMax[M,NBLK]
1025
+ savedLse = lse; sink.push(lse); // stash lse + blockMax → backward (no recompute;
1026
+ savedBlockMax = blockMax; sink.push(blockMax); // blockMax drives the vocab-block skip)
1027
+ const logpF = logp.dtype === Dtype.float32 ? logp : logp.astype(Dtype.float32);
1028
+ const s = ops.sumAxis(logpF, 0, false); // Σ logp (scalar)
1029
+ if (logpF !== logp) logpF.dispose();
1030
+ logp.dispose();
1031
+ const out = ops.reshape(s, [1]); s.dispose();
1032
+ return [out];
1033
+ },
1034
+ (primals, cots) => {
1035
+ const hR = primals[0]!; // [M, hidden]
1036
+ const c = cots[0]!; // [1] = ∂loss/∂(Σ logp); uniform over tokens
1037
+ // dh for unit cotangent, then × c (avoids a host read of the cotangent).
1038
+ const ones = new Array(M).fill(1) as number[];
1039
+ // filter + block-skip are OFF by default (env-gated) → EXACT dh. savedBlockMax is
1040
+ // passed so the lossless vocab-block skip can be enabled via env without re-plumbing.
1041
+ const dhUnit = flashCceBackward(hR, fch, targets, savedLse!, ones, undefined, savedBlockMax!); // [M, hidden] f32
1042
+ const cM = c.dtype === dhUnit.dtype ? c : c.astype(dhUnit.dtype);
1043
+ const dh = ops.mul(dhUnit, cM); // broadcast [M,hidden]·[1]
1044
+ if (cM !== c) cM.dispose();
1045
+ dhUnit.dispose();
1046
+ const dhO = dh.dtype === hR.dtype ? dh : dh.astype(hR.dtype);
1047
+ if (dhO !== dh) dh.dispose();
1048
+ return [dhO];
1049
+ },
1050
+ );
1051
+ }
1052
+
1053
+ export function fusedRespLogpMean(
1054
+ model: RuntimeModel,
1055
+ hResp: MlxArray, // [M, hidden]
1056
+ targetsHost: Int32Array,
1057
+ chunkSize: number,
1058
+ sink: Array<{ dispose(): void }>,
1059
+ vocabBlock = 0,
1060
+ flash = false,
1061
+ ): MlxArray {
1062
+ const M = hResp.shape[0]!;
1063
+ const hidden = hResp.shape[1]!;
1064
+ const head = headQuant(model);
1065
+ const cap = head.softcap;
1066
+ const chunk = Math.max(1, chunkSize);
1067
+ const V = head.scales.shape[0]!;
1068
+ // Vocab-blocked (full CCE) path: even [chunk,V] never materializes — only
1069
+ // [chunk,vocabBlock]. Otherwise the whole-vocab analytic head below.
1070
+ const vb = vocabBlock > 0 && vocabBlock < V ? vocabBlock : 0;
1071
+ let op: CustomVjp;
1072
+ if (flash) {
1073
+ op = makeFlashCceHeadVjp(head, targetsHost, M, sink);
1074
+ } else if (vb > 0) {
1075
+ op = makeVocabBlockedHeadVjp(head, targetsHost, M, hidden, chunk, vb);
1076
+ } else {
1077
+
1078
+ // logits for one token-chunk [Cc, V] (+ softcap). Shared by fwd and vjp.
1079
+ const chunkLogits = (hc: MlxArray): MlxArray => {
1080
+ const raw = ops.quantizedMatmul(hc, head.w, head.scales, head.biases, head.spec, true); // [Cc, V]
1081
+ if (cap === null) return raw;
1082
+ const capped = logitSoftcap(raw, cap); // EXACT match to logitsFromHidden (div by a bf16 cap array)
1083
+ raw.dispose();
1084
+ return capped;
1085
+ };
1086
+
1087
+ op = new CustomVjp(
1088
+ // FORWARD: Σ over the response of (target_logit − logsumexp), token-chunked.
1089
+ (inputs) => {
1090
+ const hR = inputs[0]!; // [M, hidden]
1091
+ let sum: MlxArray | null = null;
1092
+ for (let c0 = 0; c0 < M; c0 += chunk) {
1093
+ const c1 = Math.min(c0 + chunk, M);
1094
+ const Cc = c1 - c0;
1095
+ const hc = hR.slice([c0, 0], [c1, hidden]); // [Cc, hidden]
1096
+ const logits = chunkLogits(hc); // [Cc, V]
1097
+ hc.dispose();
1098
+ const lse = ops.logsumexpAxis(logits, -1, false); // [Cc]
1099
+ const tgt = MlxArray.fromInt32(targetsHost.slice(c0, c1), [Cc, 1]);
1100
+ const gathered = ops.takeAlongAxis(logits, tgt, -1); // [Cc, 1]
1101
+ const picked = ops.reshape(gathered, [Cc]);
1102
+ const logp = ops.sub(picked, lse); // [Cc]
1103
+ const logpF = logp.dtype === Dtype.float32 ? logp : logp.astype(Dtype.float32);
1104
+ const s = ops.sumAxis(logpF, 0, false); // scalar
1105
+ for (const a of [logits, lse, tgt, gathered, picked, logp]) a.dispose();
1106
+ if (logpF !== logp) logpF.dispose();
1107
+ if (sum === null) sum = s;
1108
+ else { const n = ops.add(sum, s); sum.dispose(); s.dispose(); sum = n; }
1109
+ }
1110
+ const out = ops.reshape(sum!, [1]);
1111
+ sum!.dispose();
1112
+ return [out];
1113
+ },
1114
+ // BACKWARD: dh = (cotangent · (onehot − softmax) · sech²) @ dequant(W).
1115
+ (primals, cots) => {
1116
+ const hR = primals[0]!; // [M, hidden]
1117
+ const c = cots[0]!; // [1] scalar = ∂loss/∂(Σlogp)
1118
+ const dtype = hR.dtype;
1119
+ const cMatch = c.dtype === dtype ? c : c.astype(dtype);
1120
+ const chunks: MlxArray[] = [];
1121
+ for (let c0 = 0; c0 < M; c0 += chunk) {
1122
+ const c1 = Math.min(c0 + chunk, M);
1123
+ const Cc = c1 - c0;
1124
+ const hc = hR.slice([c0, 0], [c1, hidden]); // [Cc, hidden]
1125
+ // recompute logits, keeping sech² through the softcap if present
1126
+ const raw = ops.quantizedMatmul(hc, head.w, head.scales, head.biases, head.spec, true); // [Cc, V]
1127
+ hc.dispose();
1128
+ let logits = raw;
1129
+ let sech2: MlxArray | null = null;
1130
+ if (cap !== null) {
1131
+ // Replicate logitSoftcap internals (div by a bf16 cap array) so the
1132
+ // recomputed logits match the forward exactly, and keep tanh for sech².
1133
+ const capArr = ops.scalarLike(cap, raw);
1134
+ const scaled = ops.div(raw, capArr);
1135
+ raw.dispose();
1136
+ const th = ops.tanh(scaled);
1137
+ scaled.dispose();
1138
+ logits = ops.mul(th, capArr); // cap·tanh(raw/cap), bit-exact to logitSoftcap
1139
+ const th2 = ops.mul(th, th);
1140
+ th.dispose();
1141
+ const one = ops.scalarLike(1, th2);
1142
+ sech2 = ops.sub(one, th2); // 1 − tanh² = sech²
1143
+ one.dispose(); th2.dispose(); capArr.dispose();
1144
+ }
1145
+ const lse = ops.logsumexpAxis(logits, -1, false); // [Cc]
1146
+ const lseCol = ops.reshape(lse, [Cc, 1]);
1147
+ const softmax = ops.exp(ops.sub(logits, lseCol)); // [Cc, V]
1148
+ logits.dispose(); lse.dispose(); lseCol.dispose();
1149
+ // onehot via equality (no scatter op): col == target.
1150
+ const colFlat = ops.arange(0, softmax.shape[1]!, 1, Dtype.int32);
1151
+ const col = ops.reshape(colFlat, [1, softmax.shape[1]!]);
1152
+ colFlat.dispose();
1153
+ const tcol = MlxArray.fromInt32(targetsHost.slice(c0, c1), [Cc, 1]);
1154
+ const ge = ops.greaterEqual(col, tcol);
1155
+ const le = ops.greaterEqual(tcol, col);
1156
+ const eqMask = ops.logicalAnd(ge, le); // [Cc, V] bool, true at target
1157
+ for (const a of [col, tcol, ge, le]) a.dispose();
1158
+ const onehot = eqMask.astype(softmax.dtype);
1159
+ eqMask.dispose();
1160
+ let g = ops.sub(onehot, softmax); // onehot − softmax [Cc, V]
1161
+ onehot.dispose(); softmax.dispose();
1162
+ if (sech2) { const gg = ops.mul(g, sech2); g.dispose(); sech2.dispose(); g = gg; }
1163
+ const gScaled = ops.mul(g, cMatch); // × cotangent (broadcast [Cc,V]·[1])
1164
+ g.dispose();
1165
+ const gT = gScaled.dtype === dtype ? gScaled : gScaled.astype(dtype);
1166
+ if (gT !== gScaled) gScaled.dispose();
1167
+ // dh_c = g @ dequant(W) — mlx's x-vjp of the transpose=true head matmul.
1168
+ const dhc = ops.quantizedMatmul(gT, head.w, head.scales, head.biases, head.spec, false); // [Cc, hidden]
1169
+ gT.dispose();
1170
+ chunks.push(dhc);
1171
+ }
1172
+ if (cMatch !== c) cMatch.dispose();
1173
+ const dh = chunks.length === 1 ? chunks[0]! : ops.concatAxis(chunks, 0); // [M, hidden]
1174
+ if (chunks.length > 1) for (const a of chunks) a.dispose();
1175
+ return [dh];
1176
+ },
1177
+ );
1178
+ } // end whole-vocab branch
1179
+ sink.push(op);
1180
+
1181
+ const [sumLogp] = op.apply([hResp]);
1182
+ const mean = ops.mulScalar(sumLogp!, 1 / M); // length-normalized
1183
+ sumLogp!.dispose();
1184
+ const out = ops.reshape(mean, [1]);
1185
+ mean.dispose();
1186
+ return out;
1187
+ }
1188
+
1189
+ /** Numerically-guarded log(1 - exp(x)) for x < 0 (x = a mean log-prob, so
1190
+ * exp(x) ∈ (0,1) and comfortably below 1 — average per-token logp is well
1191
+ * negative). Clamp `1 - exp(x)` to >= eps so the log never hits -inf in the
1192
+ * pathological near-zero case. Caller owns the result. */
1193
+ export function log1mexp(x: MlxArray): MlxArray {
1194
+ const ex = ops.exp(x);
1195
+ const one = ops.scalarLike(1, x);
1196
+ const oneMinus = ops.sub(one, ex);
1197
+ const eps = ops.scalarLike(1e-7, x);
1198
+ const clamped = ops.maximum(oneMinus, eps);
1199
+ const out = ops.log(clamped);
1200
+ for (const a of [ex, one, oneMinus, eps, clamped]) a.dispose();
1201
+ return out;
1202
+ }
1203
+
1204
+ /** Per-row ORPO log-odds from the two length-normalized log-probs ([B] each):
1205
+ * `log_odds = (ℓw - ℓr) - (log1mexp(ℓw) - log1mexp(ℓr))`. Caller owns lw/lr
1206
+ * (not disposed here) and the [B] result. */
1207
+ function orpoLogOdds(lw: MlxArray, lr: MlxArray): MlxArray {
1208
+ const diff = ops.sub(lw, lr);
1209
+ const lmw = log1mexp(lw);
1210
+ const lmr = log1mexp(lr);
1211
+ const lmDiff = ops.sub(lmw, lmr);
1212
+ const logOdds = ops.sub(diff, lmDiff); // [B]
1213
+ for (const a of [diff, lmw, lmr, lmDiff]) a.dispose();
1214
+ return logOdds;
1215
+ }
1216
+
1217
+ /** The pure ORPO reduction from the two length-normalized log-probs ([B] each):
1218
+ * `L_NLL + λ·L_OR` with `L_NLL = mean(-ℓw)` (unweighted SFT term) and
1219
+ * `L_OR = mean(softplus(-log_odds))`. Model-free and graph-preserving — the
1220
+ * forwards live in `orpoLoss`, the math (and its grad path) lives here, so the
1221
+ * loss can be unit-tested against a hand reference with synthetic lw/lr. Does
1222
+ * NOT dispose lw/lr (the caller owns them); returns the scalar loss. */
1223
+ export function orpoLossFromLogps(lw: MlxArray, lr: MlxArray, lambda: number): MlxArray {
1224
+ const negLw = ops.neg(lw);
1225
+ const nll = ops.meanAll(negLw, false); // scalar L_NLL = mean(-ℓw)
1226
+ const logOdds = orpoLogOdds(lw, lr); // [B]
1227
+ const negLO = ops.neg(logOdds);
1228
+ const orPer = ops.softplus(negLO); // [B] -log σ(log_odds)
1229
+ const orLoss = ops.meanAll(orPer, false); // scalar L_OR
1230
+ const lamOr = ops.mulScalar(orLoss, lambda);
1231
+ const loss = ops.add(nll, lamOr);
1232
+ for (const a of [negLw, nll, logOdds, negLO, orPer, orLoss, lamOr]) a.dispose();
1233
+ return loss;
1234
+ }
1235
+
1236
+ /** ORPO loss for one preference batch (B>=1). Runs the chosen + rejected policy
1237
+ * forwards (adapter active), reduces via {@link orpoLossFromLogps}. No
1238
+ * reference forward. Returns a scalar MlxArray (caller owns). */
1239
+ export function orpoLoss(model: RuntimeModel, batch: DpoBatch, lambda: number, chunk?: ChunkCtx): MlxArray {
1240
+ const cLen = chosenLengths(batch);
1241
+ const rLen = rejectedLengths(batch);
1242
+ const lw = branchLogpMean(model, batch.chosenIds, batch.chosenMask, cLen, chunk); // [B]
1243
+ 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 underlying
1246
+ // nodes (same pattern as dpoLoss disposing piC/piR before returning).
1247
+ lw.dispose();
1248
+ lr.dispose();
1249
+ return loss;
1250
+ }
1251
+
1252
+ /** ORPO diagnostics (no grad): the total loss and its `nll`/`or` split,
1253
+ * preference accuracy (mean of ℓw > ℓr), and reward margin (mean log_odds).
1254
+ * Runs its own two forwards. */
1255
+ export function orpoMetrics(
1256
+ model: RuntimeModel,
1257
+ batch: DpoBatch,
1258
+ lambda: number,
1259
+ ): { loss: number; nll: number; or: number; accuracy: number; margin: number } {
1260
+ const cLen = chosenLengths(batch);
1261
+ const rLen = rejectedLengths(batch);
1262
+ const lw = branchLogpMean(model, batch.chosenIds, batch.chosenMask, cLen); // [B]
1263
+ const lr = branchLogpMean(model, batch.rejectedIds, batch.rejectedMask, rLen); // [B]
1264
+
1265
+ const negLw = ops.neg(lw);
1266
+ const nllArr = ops.meanAll(negLw, false);
1267
+ const logOdds = orpoLogOdds(lw, lr); // [B]
1268
+ const negLO = ops.neg(logOdds);
1269
+ const orPer = ops.softplus(negLO);
1270
+ const orArr = ops.meanAll(orPer, false);
1271
+
1272
+ const lwVals = lw.toFloat32();
1273
+ const lrVals = lr.toFloat32();
1274
+ const loVals = logOdds.toFloat32();
1275
+ const nll = nllArr.toFloat32()[0]!;
1276
+ const or = orArr.toFloat32()[0]!;
1277
+ const loss = nll + lambda * or;
1278
+ let acc = 0;
1279
+ let margin = 0;
1280
+ for (let b = 0; b < lwVals.length; b++) {
1281
+ if (lwVals[b]! > lrVals[b]!) acc++;
1282
+ margin += loVals[b]!;
1283
+ }
1284
+ acc /= lwVals.length;
1285
+ margin /= loVals.length;
1286
+
1287
+ for (const a of [lw, lr, negLw, nllArr, logOdds, negLO, orPer, orArr]) a.dispose();
1288
+ return { loss, nll, or, accuracy: acc, margin };
1289
+ }