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.
Files changed (144) hide show
  1. package/README.md +112 -20
  2. package/docs/design/batching-perf-path.md +232 -0
  3. package/docs/design/batching-v2-plan.md +515 -0
  4. package/docs/design/bucketing-stage.md +196 -0
  5. package/docs/design/compat-cli-surface-design.md +11 -11
  6. package/docs/design/docs-reorg-plan.md +1 -1
  7. package/docs/design/dreaming-nightly-pipeline.md +291 -0
  8. package/docs/design/dspark-speculative-decoding.md +106 -0
  9. package/docs/design/generic-model-support.md +381 -0
  10. package/docs/design/memory-inference-path.md +422 -0
  11. package/docs/design/memory-synthesis.md +287 -0
  12. package/docs/design/minicpm5-decode-megakernel.md +484 -0
  13. package/docs/design/mlx-lm-tool-parity-plan.md +535 -0
  14. package/docs/design/omlx-adoption-map.md +90 -0
  15. package/docs/design/orpo-dynamic-lambda.md +41 -0
  16. package/docs/design/orpo-training.md +35 -5
  17. package/docs/design/parallel-slots.md +12 -1
  18. package/docs/design/parity-tier-dag.md +13 -5
  19. package/docs/design/segmented-backward-training.md +15 -0
  20. package/docs/design/spec-decode-larger-targets.md +229 -0
  21. package/docs/design/ssd-kv-cold-tier.md +158 -0
  22. package/docs/design/tauri-desktop-app.md +2 -2
  23. package/docs/design/the-dreaming-handoff.md +141 -0
  24. package/docs/design/the-dreaming-master-plan.md +446 -0
  25. package/docs/design/write-pipeline-entity.md +468 -0
  26. package/docs/investigations/curve-bisector-routing.md +1 -1
  27. package/docs/investigations/decode-roofline-lookagain.md +348 -0
  28. package/docs/investigations/dspark-handoff.md +58 -0
  29. package/docs/investigations/kernel-perf-review-2026-07.md +171 -0
  30. package/docs/planning/PRODUCT_ROADMAP.md +2 -2
  31. package/docs/planning/curve-sampler-research-plan.md +331 -0
  32. package/docs/planning/memory-docs-and-dag-plan.md +185 -0
  33. package/docs/planning/pi-integration-review.md +178 -0
  34. package/docs/planning/release-notes-v0.0.10.md +57 -0
  35. package/docs/planning/release-notes-v0.0.9.md +122 -0
  36. package/docs/planning/web-ui-pass-plan.md +213 -0
  37. package/docs/planning/website-readme-pass-plan.md +224 -0
  38. package/docs/reference/cli.md +255 -0
  39. package/docs/reference/embedding.md +7 -2
  40. package/docs/reference/library-api.md +22 -3
  41. package/docs/reference/memory.md +5 -5
  42. package/docs/reference/models.md +137 -0
  43. package/docs/reference/orpo-quickstart.md +6 -0
  44. package/docs/reference/server-api.md +187 -9
  45. package/docs/reference/server-config.md +43 -9
  46. package/docs/reference/training.md +31 -12
  47. package/package.json +1 -1
  48. package/src/anthropic.ts +22 -0
  49. package/src/assets/curve-designer.html +1 -1
  50. package/src/chat-template.ts +25 -7
  51. package/src/cli.ts +1077 -90
  52. package/src/config.ts +4 -1
  53. package/src/download.ts +99 -8
  54. package/src/embed.ts +77 -0
  55. package/src/eval/perplexity.ts +164 -0
  56. package/src/eval/runner.ts +15 -6
  57. package/src/eval/tasks/bfcl.ts +1 -1
  58. package/src/generate.ts +149 -18
  59. package/src/harness-pi.ts +43 -17
  60. package/src/index.ts +2 -0
  61. package/src/kv-store.ts +240 -98
  62. package/src/lora.ts +2 -2
  63. package/src/memory/article.ts +558 -0
  64. package/src/memory/chunk-validation.ts +150 -0
  65. package/src/memory/chunk.ts +305 -12
  66. package/src/memory/cluster.ts +344 -17
  67. package/src/memory/coverage.ts +134 -0
  68. package/src/memory/crosslink.ts +0 -0
  69. package/src/memory/db.ts +184 -45
  70. package/src/memory/entity.ts +203 -0
  71. package/src/memory/gate.ts +187 -0
  72. package/src/memory/history.ts +152 -0
  73. package/src/memory/ingest.ts +324 -11
  74. package/src/memory/model.ts +321 -0
  75. package/src/memory/normalize.ts +671 -0
  76. package/src/memory/parse.ts +22 -0
  77. package/src/memory/pipeline.ts +242 -30
  78. package/src/memory/prompts.ts +42 -0
  79. package/src/memory/query.ts +334 -0
  80. package/src/memory/reconcile.ts +734 -0
  81. package/src/memory/redirect.ts +108 -0
  82. package/src/memory/reindex.ts +325 -0
  83. package/src/memory/resolve.ts +307 -0
  84. package/src/memory/route.ts +272 -0
  85. package/src/memory/schedule.ts +6 -4
  86. package/src/memory/stages.ts +774 -0
  87. package/src/memory/synthesize.ts +1629 -20
  88. package/src/memory/tools.ts +446 -96
  89. package/src/memory/vault.ts +462 -35
  90. package/src/memory/wikify.ts +1057 -16
  91. package/src/mlx/array.ts +21 -0
  92. package/src/mlx/ffi.ts +3 -0
  93. package/src/mlx/ops.ts +72 -0
  94. package/src/model/compiled-decode.ts +43 -8
  95. package/src/model/factory.ts +29 -8
  96. package/src/model/flash-attention.ts +4 -2
  97. package/src/model/fused-decode-kernel.ts +14 -7
  98. package/src/model/fused-mlp-kernel.ts +169 -0
  99. package/src/model/fused-swiglu-kernel.ts +240 -0
  100. package/src/model/gemma4-base.ts +96 -7
  101. package/src/model/gemma4.ts +33 -2
  102. package/src/model/minicpm5.ts +106 -8
  103. package/src/model/qwen3-delta.ts +48 -1
  104. package/src/model/qwen3.ts +276 -0
  105. package/src/model/qwen3_5.ts +11 -2
  106. package/src/model/steel-linear-kernel.ts +96 -0
  107. package/src/model/support.ts +38 -8
  108. package/src/model/universal/archs.ts +332 -0
  109. package/src/model/universal/dense.ts +576 -0
  110. package/src/model/universal/modules.ts +163 -0
  111. package/src/model/universal/rope.ts +366 -0
  112. package/src/pi-provider.ts +1 -1
  113. package/src/pi-terminal.ts +29 -8
  114. package/src/pi-web.ts +19 -2
  115. package/src/prompt-cache.ts +55 -9
  116. package/src/quantize/config-writer.ts +1 -1
  117. package/src/registry.ts +219 -8
  118. package/src/responses.ts +23 -0
  119. package/src/sampler.ts +220 -9
  120. package/src/serve/batch-scheduler.ts +307 -83
  121. package/src/serve/generation-gateway.ts +156 -29
  122. package/src/server.ts +746 -73
  123. package/src/spec/drafter.ts +81 -49
  124. package/src/spec/dspark/data-dflash.ts +156 -0
  125. package/src/spec/dspark/data.ts +201 -0
  126. package/src/spec/dspark/generate-dflash.ts +193 -0
  127. package/src/spec/dspark/generate.ts +290 -0
  128. package/src/spec/dspark/loss.ts +213 -0
  129. package/src/spec/dspark/module-dflash.ts +361 -0
  130. package/src/spec/dspark/module.ts +540 -0
  131. package/src/spec/dspark/sample.ts +112 -0
  132. package/src/spec/generate.ts +40 -19
  133. package/src/ssd-cache.ts +213 -0
  134. package/src/status-page.html +1 -1
  135. package/src/tokenizer.ts +7 -0
  136. package/src/train/flash-cce.ts +81 -23
  137. package/src/train/fuse.ts +243 -0
  138. package/src/train/index.ts +3 -0
  139. package/src/train/job.ts +8 -0
  140. package/src/train/loss.ts +341 -40
  141. package/src/train/prefix-shared.ts +62 -11
  142. package/src/train/segmented.ts +215 -55
  143. package/src/train/trainer.ts +112 -38
  144. package/src/web/skills/memory/SKILL.md +40 -17
@@ -36,14 +36,31 @@
36
36
 
37
37
  import { MlxArray } from "../mlx/array";
38
38
  import * as ops from "../mlx/ops";
39
+ import { peakMemory } from "../mlx/ffi";
39
40
  import { Vjp } from "../mlx/autograd";
41
+
42
+ // MLX_BUN_SEG_MEM_LOG=1: log the within-step peak after each phase of the
43
+ // segmented step (forward+boundaries / head vjp / each segment backward).
44
+ // The trainer resets the peak at step start (MLX_BUN_MEM_LOG=1), so the phase
45
+ // where the monotone peak JUMPS is where the step's memory actually lives.
46
+ // (This is how the backlog-#2 refutation below was measured. An inter-segment
47
+ // clearCache was also tried and had NO effect — the watermark is live memory,
48
+ // not allocator cache retention.)
49
+ const SEG_MEM = process.env.MLX_BUN_SEG_MEM_LOG === "1";
50
+ const segMem = (label: string): void => {
51
+ if (SEG_MEM) console.log(` [seg-mem] ${label}: peak=${(peakMemory() / 1e9).toFixed(2)} GB`);
52
+ };
40
53
  import { MiniCPM5Model, setMiniCpmPrefixPlan } from "../model/minicpm5";
41
54
  import type { Gemma4Model } from "../model/gemma4";
42
55
  import { setGemmaPrefixPlan } from "../model/gemma4";
43
56
  import type { Cache, Mask, SharedKv } from "../model/gemma4-base";
44
57
  import { TrainingCache } from "./forward";
45
- import { responseOnlyCe, responseOnlyLogpMean, fusedLogpMeanFromHidden, chunkedLogpMeanFromHidden, orpoLossFromLogps, type ChunkCtx } from "./loss";
46
- import { PrefixSharedCache, prefixGatherIdx, branchLogpMeanGathered, blockSparsePrefixMaskGemma } from "./prefix-shared";
58
+ import {
59
+ responseOnlyLogpMean, fusedLogpMeanFromHidden, chunkedLogpMeanFromHidden,
60
+ orpoLossFromLogps, spanLogpMeanFromHidden, respSpanFromMask, combineFullNll,
61
+ type ChunkCtx, type SftScope, type HeadSpan,
62
+ } from "./loss";
63
+ import { prefixSharedCaches, prefixGatherIdx, branchLogpMeanGathered, blockSparsePrefixMaskGemma, prefixFullNll } from "./prefix-shared";
47
64
 
48
65
  // Experiment toggle: which bounded head to use inside the segmented mlx_vjp.
49
66
  // "checkpoint" = per-chunk Checkpoint recompute (bounds memory under nesting);
@@ -54,14 +71,43 @@ import { PrefixSharedCache, prefixGatherIdx, branchLogpMeanGathered, blockSparse
54
71
  const SEG_HEAD = process.env.MLX_BUN_SEG_HEAD ?? "checkpoint";
55
72
  function boundedHeadFromHidden(
56
73
  model: MiniCPM5Model | Gemma4Model, h: MlxArray, ids: number[], mask: number[],
57
- chunkSize: number, sink: Array<{ dispose(): void }>,
74
+ chunkSize: number, sink: Array<{ dispose(): void }>, span?: HeadSpan,
58
75
  ): MlxArray {
59
76
  return SEG_HEAD === "fused"
60
- ? fusedLogpMeanFromHidden(model, h, ids, mask, chunkSize, sink)
61
- : chunkedLogpMeanFromHidden(model, h, ids, mask, chunkSize, sink);
77
+ ? fusedLogpMeanFromHidden(model, h, ids, mask, chunkSize, sink, 0, false, span)
78
+ : chunkedLogpMeanFromHidden(model, h, ids, mask, chunkSize, sink, span);
62
79
  }
63
80
  import type { TrainableLora } from "./lora-params";
64
- import { type SftBatch, type DpoBatch } from "./dataset";
81
+ import { rowLength, type SftBatch, type DpoBatch } from "./dataset";
82
+
83
+ // Token-chunk size for the bounded SFT head (the ORPO paths take theirs from
84
+ // cfg.orpoChunkSize; SFT has no per-run knob, 512 matches the ORPO default).
85
+ const SFT_HEAD_CHUNK = Number(process.env.MLX_BUN_SEG_HEAD_CHUNK ?? "512");
86
+
87
+ /** [M,V]-bounded response-only CE from post-finalNorm hidden — the SFT
88
+ * segmented head (kernel-review backlog #8). Same span arithmetic and
89
+ * token-mean reduction as loss.ts responseOnlyCe, but the logits only ever
90
+ * exist per token-chunk ([chunk,V] Checkpoint recompute, or the fused
91
+ * CustomVjp head via MLX_BUN_SEG_HEAD) instead of the full [1,M,V] + its
92
+ * cotangent inside the head vjp. CE = −(mean response logp); per-position
93
+ * grads are bit-exact vs the whole-vocab head (chunking is over TOKEN
94
+ * positions; each position's full-V lse is computed unchanged — only the
95
+ * scalar loss VALUE can differ in fp association across chunks). */
96
+ export function boundedSftCe(
97
+ model: MiniCPM5Model | Gemma4Model, hn: MlxArray, batch: SftBatch,
98
+ sink: Array<{ dispose(): void }>, chunkSize = SFT_HEAD_CHUNK,
99
+ ): MlxArray {
100
+ const L = batch.ids[0]!.length;
101
+ const startT = Math.max(0, batch.promptLens[0]! - 1); // first supervised input position
102
+ const M = Math.min(rowLength(batch, 0), L) - 1 - startT;
103
+ if (M <= 0) throw new Error("sftLoss: no response tokens to supervise");
104
+ const mean = boundedHeadFromHidden(model, hn, batch.ids[0]!, [], chunkSize, sink, { startT, M });
105
+ const neg = ops.mulScalar(mean, -1); // CE = −(mean logp); exact sign flip
106
+ mean.dispose();
107
+ const loss = ops.sumAxis(neg, 0, false); // [1] -> scalar, matches the vjp cotangent `one`
108
+ neg.dispose();
109
+ return loss;
110
+ }
65
111
 
66
112
  /** Split `nLayers` into contiguous `[lo, hi)` ranges of at most `segSize`
67
113
  * layers each (the last range may be shorter). `segSize` is the memory knob:
@@ -73,24 +119,50 @@ export function planSegmentsBySize(nLayers: number, segSize: number): [number, n
73
119
  return ranges;
74
120
  }
75
121
 
122
+ // NOTE (2026-07-02, kernel-review backlog #2 REFUTED BY MEASUREMENT): a
123
+ // "full-attention isolation" planner (singleton segments for e4b's 7 full
124
+ // layers) was built and A/B'd @8K — ZERO peak win (18.09 vs 18.02 GB). The
125
+ // design §5 premise is false: mlx's sdpa BACKWARD materializes the O(L²)
126
+ // scores for EVERY layer (~3.5 GB/layer @8K on e4b — sliding's window is just
127
+ // an additive mask; measured: sliding pair +7.1 GB ≈ sliding+full +7.15). The
128
+ // worst segment is set by LAYER COUNT alone, so segSize IS the whole knob:
129
+ // segment_size 1 measures 14.59 GB @8K (+3% step time) vs 17.5 @seg2. Below
130
+ // that needs the [M,V]-bounded SFT head (backlog #8, ~3 GB head-vjp spike)
131
+ // and/or an O(L)-memory attention backward. Evidence:
132
+ // scripts/experiments/seg-isolation-smoke.ts + MLX_BUN_SEG_MEM_LOG=1 probes.
133
+
76
134
  /** Detach `a` into a graph-free LEAF holding its exact (evaluated) bytes, then
77
135
  * dispose `a`. mlx retains the upstream graph after `eval`, so a segment
78
136
  * boundary (or a value_and_grad output) would otherwise keep its whole layer
79
137
  * stack's activations alive. Copying the bytes into a fresh leaf frees that
80
138
  * graph. The leaf is a valid differentiable input for the per-segment vag. */
81
139
  function detachLeaf(a: MlxArray): MlxArray {
82
- // Force a row-major copy first: `rawBytes` reads the underlying buffer
83
- // linearly, so a NON-contiguous input (e.g. a transposed donor K/V view) would
84
- // be byte-scrambled. `ops.contiguous` is a no-op for already-contiguous arrays
140
+ // Force a row-major copy first: the buffer is read linearly, so a
141
+ // NON-contiguous input (e.g. a transposed donor K/V view) would be
142
+ // byte-scrambled. `ops.contiguous` is a no-op for already-contiguous arrays
85
143
  // (hidden boundaries) and materializes row-major for strided views.
144
+ // detachCopy = ONE mlx→mlx copy (backlog #9: the old
145
+ // fromBytesCopy(rawBytes()) route was two copies via the JS heap).
86
146
  const c = ops.contiguous(a);
87
- c.eval();
88
- const leaf = MlxArray.fromBytesCopy(c.rawBytes(), c.shape, c.dtype);
147
+ const leaf = c.detachCopy();
89
148
  c.dispose();
90
149
  a.dispose();
91
150
  return leaf;
92
151
  }
93
152
 
153
+ /** Batch detach: ONE evalAll barrier for the whole set (backlog #9 — each
154
+ * per-array eval was a separate blocking GPU drain; a segment's vjp returns
155
+ * dh + 2·consumed-donors + 2·targets arrays, so the per-segment sync count
156
+ * collapses from ~2+2n to 1), then one mlx→mlx copy each. Disposes inputs. */
157
+ function detachLeaves(arrs: MlxArray[]): MlxArray[] {
158
+ const cs = arrs.map((a) => ops.contiguous(a));
159
+ ops.evalAll(cs);
160
+ const leaves = cs.map((c) => c.detachCopy());
161
+ for (const c of cs) c.dispose();
162
+ for (const a of arrs) a.dispose();
163
+ return leaves;
164
+ }
165
+
94
166
  /** Layer index a target's modulePath belongs to, or -1 (e.g. embedding/head). */
95
167
  function targetLayer(modulePath: string): number {
96
168
  const m = modulePath.match(/\.layers\.(\d+)\./);
@@ -116,6 +188,10 @@ export class SegmentedBackward {
116
188
  private readonly one: MlxArray; // scalar cotangent 1.0 for the (scalar) head loss
117
189
  // Per-step context the (reused) closures read.
118
190
  private curBatch: SftBatch | null = null;
191
+ // Bounded-head per-step transients (chunk slices + Checkpoint/CustomVjp
192
+ // objects): the head backward recomputes through them, so they are disposed
193
+ // only AFTER the head vjp's outputs are detached (which forces the eval).
194
+ private headSink: Array<{ dispose(): void }> = [];
119
195
  private disposed = false;
120
196
 
121
197
  constructor(
@@ -136,11 +212,17 @@ export class SegmentedBackward {
136
212
  this.caches = Array.from({ length: this.nLayers }, () => new TrainingCache());
137
213
  this.one = MlxArray.fromFloat32(new Float32Array([1]), []); // d(loss)/d(loss) = 1
138
214
 
139
- // Loss head: vjp of finalNorm + responseOnlyCe over the last boundary leaf.
215
+ // Loss head: vjp of finalNorm + the [M,V]-BOUNDED response CE over the last
216
+ // boundary leaf (kernel-review backlog #8: the old responseOnlyCe here
217
+ // materialized [1,M,V] logits + cotangents inside the head vjp — measured
218
+ // ~3 GB @8K on e4b, the exact transient the segmentation exists to kill).
219
+ // CE = −(mean response logp); span and reduction identical to
220
+ // responseOnlyCe, per-position grads bit-exact (chunking is over token
221
+ // positions — each position's full-V lse/softmax is computed unchanged).
140
222
  // outputs[0] = loss scalar; vjps[0] = dLoss/d(last residual stream) = dh.
141
223
  this.headVjp = new Vjp((p) => {
142
224
  const hn = model.finalNorm.forward(p[0]!);
143
- const loss = responseOnlyCe(model, hn, this.curBatch!);
225
+ const loss = boundedSftCe(model, hn, this.curBatch!, this.headSink);
144
226
  hn.dispose();
145
227
  return [loss];
146
228
  }, 1);
@@ -200,9 +282,17 @@ export class SegmentedBackward {
200
282
  }
201
283
 
202
284
  // --- 2. Loss head over the last boundary (vjp with cotangent 1.0). ---
285
+ // The bounded head pushes per-step chunk slices + Checkpoints into
286
+ // headSink; the head BACKWARD recomputes through them, so materialize
287
+ // the full head-vjp graph BEFORE freeing the sink (lazy-eval UAF
288
+ // discipline, same as the ORPO classes).
289
+ this.headSink = [];
203
290
  const head = this.headVjp.apply([boundaries[nSeg]!], [this.one]);
291
+ ops.evalAll([head.outputs[0]!, head.vjps[0]!]);
204
292
  value = detachLeaf(head.outputs[0]!); // scalar loss, detached from the head graph (frees logits)
205
293
  dhOut = detachLeaf(head.vjps[0]!); // dLoss / d(last residual stream), graph-free
294
+ for (const d of this.headSink) d.dispose();
295
+ this.headSink = [];
206
296
  boundaries[nSeg]!.dispose();
207
297
  boundaries[nSeg] = null;
208
298
 
@@ -216,13 +306,15 @@ export class SegmentedBackward {
216
306
  res.outputs[0]!.dispose(); // segment output — unused (only its vjp matters)
217
307
  // vjps[0] = dh_in (grad w.r.t. this segment's input boundary) -> dh_out
218
308
  // for segment k-1; the rest scatter into the global flat vector. Detach
219
- // each to a graph-free leaf (eval doesn't detach; keeps peak bounded).
309
+ // to graph-free leaves (eval doesn't detach; keeps peak bounded) — ONE
310
+ // barrier for the segment's whole vjp set.
311
+ const leaves = detachLeaves(res.vjps);
220
312
  idxs.forEach((j, s) => {
221
- grads[j] = detachLeaf(res.vjps[1 + 2 * s]!); // dA
222
- grads[n + j] = detachLeaf(res.vjps[1 + 2 * s + 1]!); // dB
313
+ grads[j] = leaves[1 + 2 * s]!; // dA
314
+ grads[n + j] = leaves[1 + 2 * s + 1]!; // dB
223
315
  });
224
316
  dhOut!.dispose(); // consumed as this segment's cotangent
225
- dhOut = detachLeaf(res.vjps[0]!);
317
+ dhOut = leaves[0]!;
226
318
  boundaries[k]!.dispose();
227
319
  boundaries[k] = null;
228
320
  }
@@ -234,6 +326,8 @@ export class SegmentedBackward {
234
326
  } finally {
235
327
  inputIds.dispose();
236
328
  this.curBatch = null;
329
+ for (const d of this.headSink) d.dispose(); // no-op unless the head threw mid-step
330
+ this.headSink = [];
237
331
  dhOut?.dispose();
238
332
  for (const b of boundaries) b?.dispose();
239
333
  if (!transferred) {
@@ -291,6 +385,8 @@ export class SegmentedBackwardGemma4 {
291
385
  private curBatch: SftBatch | null = null;
292
386
  private curMasks: Map<string, Mask> | null = null;
293
387
  private curPerLayer: MlxArray | null = null;
388
+ // Bounded-head per-step transients — see SegmentedBackward.headSink.
389
+ private headSink: Array<{ dispose(): void }> = [];
294
390
  private disposed = false;
295
391
 
296
392
  constructor(
@@ -333,11 +429,11 @@ export class SegmentedBackwardGemma4 {
333
429
  this.caches = Array.from({ length: model.numDonors }, () => new TrainingCache());
334
430
  this.one = MlxArray.fromFloat32(new Float32Array([1]), []);
335
431
 
336
- // Loss head (identical to MiniCPM5): finalNorm + responseOnlyCe over the last
432
+ // Loss head (identical to MiniCPM5): finalNorm + bounded response CE over the last
337
433
  // boundary, vjp with cotangent 1.0 -> [loss], [dh].
338
434
  this.headVjp = new Vjp((p) => {
339
435
  const hn = model.finalNorm.forward(p[0]!);
340
- const loss = responseOnlyCe(model, hn, this.curBatch!);
436
+ const loss = boundedSftCe(model, hn, this.curBatch!, this.headSink);
341
437
  hn.dispose();
342
438
  return [loss];
343
439
  }, 1);
@@ -433,14 +529,22 @@ export class SegmentedBackwardGemma4 {
433
529
  for (const [d, kv] of donorKvOut)
434
530
  if (!produces[k]!.includes(d) && kv.kind === "plain") { kv.keys.dispose(); kv.values.dispose(); }
435
531
  boundaries.push(detachLeaf(h));
532
+ segMem(`fwd seg ${k} [${lo},${hi})`);
436
533
  }
437
534
 
438
- // --- 2. Loss head (vjp, cotangent 1.0). ---
535
+ // --- 2. Loss head (vjp, cotangent 1.0). Bounded-head sink discipline:
536
+ // materialize the head-vjp graph BEFORE freeing the sink (see the
537
+ // MiniCPM5 class / ORPO classes — lazy-eval UAF). ---
538
+ this.headSink = [];
439
539
  const head = this.headVjp.apply([boundaries[nSeg]!], [this.one]);
540
+ ops.evalAll([head.outputs[0]!, head.vjps[0]!]);
440
541
  value = detachLeaf(head.outputs[0]!);
441
542
  dhOut = detachLeaf(head.vjps[0]!);
543
+ for (const d of this.headSink) d.dispose();
544
+ this.headSink = [];
442
545
  boundaries[nSeg]!.dispose();
443
546
  boundaries[nSeg] = null;
547
+ segMem("head vjp");
444
548
 
445
549
  // --- 3. Backward, reverse over segments (vjp, cotangents dh + dKV). ---
446
550
  for (let k = nSeg - 1; k >= 0; k--) {
@@ -462,17 +566,17 @@ export class SegmentedBackwardGemma4 {
462
566
  const res = this.segVjps[k]!.apply(primals, cots);
463
567
  for (const o of res.outputs) o.dispose();
464
568
 
465
- // vjps[0] = dh_in; then (dk,dv) per consumed donor; then (da,db) per LoRA.
466
- const newDh = detachLeaf(res.vjps[0]!);
569
+ // vjps[0] = dh_in; then (dk,dv) per consumed donor; then (da,db) per
570
+ // LoRA — detached in ONE barrier (backlog #9).
571
+ const leaves = detachLeaves(res.vjps);
572
+ const newDh = leaves[0]!;
467
573
  cons.forEach((d, ci) => {
468
- const dk = detachLeaf(res.vjps[1 + 2 * ci]!);
469
- const dv = detachLeaf(res.vjps[1 + 2 * ci + 1]!);
470
- this.accumulateDKV(dKV, d, dk, dv);
574
+ this.accumulateDKV(dKV, d, leaves[1 + 2 * ci]!, leaves[1 + 2 * ci + 1]!);
471
575
  });
472
576
  const base = 1 + 2 * cons.length;
473
577
  idxs.forEach((j, s) => {
474
- grads[j] = detachLeaf(res.vjps[base + 2 * s]!);
475
- grads[n + j] = detachLeaf(res.vjps[base + 2 * s + 1]!);
578
+ grads[j] = leaves[base + 2 * s]!;
579
+ grads[n + j] = leaves[base + 2 * s + 1]!;
476
580
  });
477
581
 
478
582
  dh.dispose();
@@ -484,6 +588,7 @@ export class SegmentedBackwardGemma4 {
484
588
  }
485
589
  boundaries[k]!.dispose();
486
590
  boundaries[k] = null;
591
+ segMem(`bwd seg ${k} [${ranges[k]![0]},${ranges[k]![1]})`);
487
592
  }
488
593
 
489
594
  transferred = true;
@@ -494,6 +599,8 @@ export class SegmentedBackwardGemma4 {
494
599
  this.curPerLayer?.dispose();
495
600
  this.curPerLayer = null;
496
601
  if (this.curMasks) { for (const m of this.curMasks.values()) m.arr?.dispose(); this.curMasks = null; }
602
+ for (const d of this.headSink) d.dispose(); // no-op unless the head threw mid-step
603
+ this.headSink = [];
497
604
  dhOut?.dispose();
498
605
  for (const b of boundaries) b?.dispose();
499
606
  for (const kv of donorBnd.values()) { kv.keys.dispose(); kv.values.dispose(); }
@@ -582,6 +689,7 @@ export class SegmentedBackwardOrpo {
582
689
  private readonly ranges: [number, number][],
583
690
  private readonly lambda: number,
584
691
  private readonly fusedChunkSize = 0,
692
+ private readonly sftScope: SftScope = "response",
585
693
  ) {
586
694
  this.nLayers = model.layers.length;
587
695
  const lastHi = ranges[ranges.length - 1]?.[1] ?? 0;
@@ -609,9 +717,26 @@ export class SegmentedBackwardOrpo {
609
717
  const lr = cs > 0 // [1]
610
718
  ? boundedHeadFromHidden(model, hnR, this.curRejectedIds!, this.curRejectedMask!, cs, this.headSink)
611
719
  : responseOnlyLogpMean(model, hnR, this.curRejectedIds!, this.curRejectedMask!);
720
+ // sft_scope:"full": the chosen head additionally runs over the PROMPT span
721
+ // [0, startT) of the SAME hnC (same tier: bounded or whole-vocab), and the
722
+ // full-scope chosen NLL replaces mean(-ℓw). The prompt-position gradient
723
+ // rides into this vjp's dh_chosen automatically (the loss graph includes
724
+ // the prompt-span head). ℓw/ℓr stay response-only for the odds ratio.
725
+ let nllFull: MlxArray | null = null;
726
+ if (this.sftScope === "full") {
727
+ const { startT, M } = respSpanFromMask(this.curChosenMask!, this.curChosenIds!.length - 1);
728
+ let pm: MlxArray | null = null;
729
+ if (M > 0 && startT > 0)
730
+ pm = cs > 0
731
+ ? boundedHeadFromHidden(model, hnC, this.curChosenIds!, this.curChosenMask!, cs, this.headSink, { startT: 0, M: startT })
732
+ : spanLogpMeanFromHidden(model, hnC, this.curChosenIds!, 0, startT);
733
+ nllFull = combineFullNll(pm, lw, M > 0 ? startT : 0, M);
734
+ pm?.dispose();
735
+ }
612
736
  hnC.dispose(); hnR.dispose();
613
- const loss = orpoLossFromLogps(lw, lr, this.lambda);
737
+ const loss = orpoLossFromLogps(lw, lr, this.lambda, nllFull ?? undefined);
614
738
  lw.dispose(); lr.dispose();
739
+ nllFull?.dispose();
615
740
  return [loss];
616
741
  }, 1);
617
742
 
@@ -703,12 +828,13 @@ export class SegmentedBackwardOrpo {
703
828
  for (const j of idxs) params.push(lora.targets[j]!.lw.a, lora.targets[j]!.lw.b);
704
829
  const res = this.segVjps[k]!.apply(params, [dhC!]);
705
830
  res.outputs[0]!.dispose();
831
+ const leaves = detachLeaves(res.vjps); // one barrier (backlog #9)
706
832
  idxs.forEach((j, s) => {
707
- gradsC[j] = detachLeaf(res.vjps[1 + 2 * s]!); // dA
708
- gradsC[n + j] = detachLeaf(res.vjps[1 + 2 * s + 1]!); // dB
833
+ gradsC[j] = leaves[1 + 2 * s]!; // dA
834
+ gradsC[n + j] = leaves[1 + 2 * s + 1]!; // dB
709
835
  });
710
836
  dhC!.dispose();
711
- dhC = detachLeaf(res.vjps[0]!);
837
+ dhC = leaves[0]!;
712
838
  bndC[k]!.dispose(); bndC[k] = null;
713
839
  }
714
840
  dhC!.dispose(); dhC = null; // embedding grad, discarded (no LoRA on embed)
@@ -720,12 +846,13 @@ export class SegmentedBackwardOrpo {
720
846
  for (const j of idxs) params.push(lora.targets[j]!.lw.a, lora.targets[j]!.lw.b);
721
847
  const res = this.segVjps[k]!.apply(params, [dhR!]);
722
848
  res.outputs[0]!.dispose();
849
+ const leaves = detachLeaves(res.vjps); // one barrier (backlog #9)
723
850
  idxs.forEach((j, s) => {
724
- gradsR[j] = detachLeaf(res.vjps[1 + 2 * s]!); // dA
725
- gradsR[n + j] = detachLeaf(res.vjps[1 + 2 * s + 1]!); // dB
851
+ gradsR[j] = leaves[1 + 2 * s]!; // dA
852
+ gradsR[n + j] = leaves[1 + 2 * s + 1]!; // dB
726
853
  });
727
854
  dhR!.dispose();
728
- dhR = detachLeaf(res.vjps[0]!);
855
+ dhR = leaves[0]!;
729
856
  bndR[k]!.dispose(); bndR[k] = null;
730
857
  }
731
858
  dhR!.dispose(); dhR = null; // embedding grad, discarded
@@ -801,6 +928,7 @@ export class SegmentedBackwardOrpoPrefix {
801
928
  private curP = 0;
802
929
  private curRc = 0;
803
930
  private curRr = 0;
931
+ private curPromptIds: number[] | null = null;
804
932
  private curChosenResp: number[] | null = null;
805
933
  private curRejectedResp: number[] | null = null;
806
934
  private curChunk: ChunkCtx | undefined = undefined;
@@ -816,6 +944,7 @@ export class SegmentedBackwardOrpoPrefix {
816
944
  private readonly ranges: [number, number][],
817
945
  private readonly lambda: number,
818
946
  private readonly chunkCtx?: ChunkCtx,
947
+ private readonly sftScope: SftScope = "response",
819
948
  ) {
820
949
  this.nLayers = model.layers.length;
821
950
  const lastHi = ranges[ranges.length - 1]?.[1] ?? 0;
@@ -838,9 +967,16 @@ export class SegmentedBackwardOrpoPrefix {
838
967
  const { chosenIdx, rejectedIdx } = prefixGatherIdx(this.curP, this.curRc, this.curRr);
839
968
  const lw = branchLogpMeanGathered(model, hn, chosenIdx, this.curChosenResp!, this.curChunk);
840
969
  const lr = branchLogpMeanGathered(model, hn, rejectedIdx, this.curRejectedResp!, this.curChunk);
970
+ // sft_scope:"full": prompt predictions (H[0..P-2] → prompt[1..P-1]) from the
971
+ // same concat hiddens; the gather's takeAxis vjp scatter-adds the prompt
972
+ // cotangent into dh alongside the responses'. ℓw/ℓr stay response-only.
973
+ const nllFull = this.sftScope === "full"
974
+ ? prefixFullNll(model, hn, this.curPromptIds!, lw, this.curRc, this.curChunk)
975
+ : null;
841
976
  hn.dispose();
842
- const loss = orpoLossFromLogps(lw, lr, this.lambda);
977
+ const loss = orpoLossFromLogps(lw, lr, this.lambda, nllFull ?? undefined);
843
978
  lw.dispose(); lr.dispose();
979
+ nllFull?.dispose();
844
980
  return [loss];
845
981
  }, 1);
846
982
 
@@ -873,6 +1009,7 @@ export class SegmentedBackwardOrpoPrefix {
873
1009
  if (P < 1 || Rc < 1 || Rr < 1) throw new Error("SegmentedBackwardOrpoPrefix: need P,Rc,Rr >= 1");
874
1010
  const T = P + Rc + Rr;
875
1011
  this.curP = P; this.curRc = Rc; this.curRr = Rr;
1012
+ this.curPromptIds = promptIds;
876
1013
  this.curChosenResp = chosenResp; this.curRejectedResp = rejectedResp;
877
1014
  // Per-step chunk ctx pointing the head's CustomVjp/slice disposables at this
878
1015
  // step's headSink (the flash/fused head recompute runs during headVjp.apply;
@@ -880,7 +1017,7 @@ export class SegmentedBackwardOrpoPrefix {
880
1017
  this.headSink = [];
881
1018
  this.curChunk = this.chunkCtx ? { ...this.chunkCtx, sink: this.headSink } : undefined;
882
1019
  // One prefix cache per layer (captures P/Rc/Rr; makeMask -> block-sparse mask).
883
- this.curCaches = model.layers.map(() => new PrefixSharedCache(P, Rc, Rr));
1020
+ this.curCaches = prefixSharedCaches(model.layers.length, P, Rc, Rr);
884
1021
 
885
1022
  const nSeg = ranges.length;
886
1023
  const concat = new Int32Array(T);
@@ -925,12 +1062,13 @@ export class SegmentedBackwardOrpoPrefix {
925
1062
  for (const j of idxs) params.push(lora.targets[j]!.lw.a, lora.targets[j]!.lw.b);
926
1063
  const res = this.segVjps[k]!.apply(params, [dhOut!]);
927
1064
  res.outputs[0]!.dispose();
1065
+ const leaves = detachLeaves(res.vjps); // one barrier (backlog #9)
928
1066
  idxs.forEach((j, s) => {
929
- grads[j] = detachLeaf(res.vjps[1 + 2 * s]!); // dA
930
- grads[n + j] = detachLeaf(res.vjps[1 + 2 * s + 1]!); // dB
1067
+ grads[j] = leaves[1 + 2 * s]!; // dA
1068
+ grads[n + j] = leaves[1 + 2 * s + 1]!; // dB
931
1069
  });
932
1070
  dhOut!.dispose();
933
- dhOut = detachLeaf(res.vjps[0]!);
1071
+ dhOut = leaves[0]!;
934
1072
  boundaries[k]!.dispose(); boundaries[k] = null;
935
1073
  }
936
1074
  // Segment-0 dh_in = grad w.r.t. the embedding output (no LoRA on embed) — drop.
@@ -942,7 +1080,7 @@ export class SegmentedBackwardOrpoPrefix {
942
1080
  inputIds.dispose();
943
1081
  for (const c of this.curCaches) c.dispose();
944
1082
  this.curCaches = [];
945
- this.curChosenResp = this.curRejectedResp = null;
1083
+ this.curPromptIds = this.curChosenResp = this.curRejectedResp = null;
946
1084
  for (const d of this.headSink) d.dispose();
947
1085
  this.headSink = [];
948
1086
  dhOut?.dispose();
@@ -1024,6 +1162,7 @@ export class SegmentedBackwardOrpoGemma4 {
1024
1162
  private readonly ranges: [number, number][],
1025
1163
  private readonly lambda: number,
1026
1164
  private readonly fusedChunkSize = 0,
1165
+ private readonly sftScope: SftScope = "response",
1027
1166
  ) {
1028
1167
  this.nLayers = model.layers.length;
1029
1168
  const lastHi = ranges[ranges.length - 1]?.[1] ?? 0;
@@ -1072,9 +1211,23 @@ export class SegmentedBackwardOrpoGemma4 {
1072
1211
  const lr = cs > 0
1073
1212
  ? boundedHeadFromHidden(this.model, hnR, this.curRejectedIds!, this.curRejectedMask!, cs, this.headSink)
1074
1213
  : responseOnlyLogpMean(this.model, hnR, this.curRejectedIds!, this.curRejectedMask!);
1214
+ // sft_scope:"full": prompt-span head over the same hnC (see
1215
+ // SegmentedBackwardOrpo); ℓw/ℓr stay response-only for the odds ratio.
1216
+ let nllFull: MlxArray | null = null;
1217
+ if (this.sftScope === "full") {
1218
+ const { startT, M } = respSpanFromMask(this.curChosenMask!, this.curChosenIds!.length - 1);
1219
+ let pm: MlxArray | null = null;
1220
+ if (M > 0 && startT > 0)
1221
+ pm = cs > 0
1222
+ ? boundedHeadFromHidden(this.model, hnC, this.curChosenIds!, this.curChosenMask!, cs, this.headSink, { startT: 0, M: startT })
1223
+ : spanLogpMeanFromHidden(this.model, hnC, this.curChosenIds!, 0, startT);
1224
+ nllFull = combineFullNll(pm, lw, M > 0 ? startT : 0, M);
1225
+ pm?.dispose();
1226
+ }
1075
1227
  hnC.dispose(); hnR.dispose();
1076
- const loss = orpoLossFromLogps(lw, lr, this.lambda);
1228
+ const loss = orpoLossFromLogps(lw, lr, this.lambda, nllFull ?? undefined);
1077
1229
  lw.dispose(); lr.dispose();
1230
+ nllFull?.dispose();
1078
1231
  return [loss];
1079
1232
  }, 1);
1080
1233
 
@@ -1185,16 +1338,15 @@ export class SegmentedBackwardOrpoGemma4 {
1185
1338
  const res = this.segVjps[k]!.apply(primals, cots);
1186
1339
  for (const o of res.outputs) o.dispose();
1187
1340
 
1188
- const newDh = detachLeaf(res.vjps[0]!);
1341
+ const leaves = detachLeaves(res.vjps); // one barrier (backlog #9)
1342
+ const newDh = leaves[0]!;
1189
1343
  cons.forEach((d, ci) => {
1190
- const dk = detachLeaf(res.vjps[1 + 2 * ci]!);
1191
- const dv = detachLeaf(res.vjps[1 + 2 * ci + 1]!);
1192
- this.accumulateDKV(dKV, d, dk, dv);
1344
+ this.accumulateDKV(dKV, d, leaves[1 + 2 * ci]!, leaves[1 + 2 * ci + 1]!);
1193
1345
  });
1194
1346
  const base = 1 + 2 * cons.length;
1195
1347
  idxs.forEach((j, s) => {
1196
- grads[j] = detachLeaf(res.vjps[base + 2 * s]!);
1197
- grads[n + j] = detachLeaf(res.vjps[base + 2 * s + 1]!);
1348
+ grads[j] = leaves[base + 2 * s]!;
1349
+ grads[n + j] = leaves[base + 2 * s + 1]!;
1198
1350
  });
1199
1351
 
1200
1352
  dh.dispose();
@@ -1394,6 +1546,7 @@ export class SegmentedBackwardOrpoPrefixGemma4 {
1394
1546
  private curP = 0;
1395
1547
  private curRc = 0;
1396
1548
  private curRr = 0;
1549
+ private curPromptIds: number[] | null = null;
1397
1550
  private curChosenResp: number[] | null = null;
1398
1551
  private curRejectedResp: number[] | null = null;
1399
1552
  private curChunk: ChunkCtx | undefined = undefined;
@@ -1408,6 +1561,7 @@ export class SegmentedBackwardOrpoPrefixGemma4 {
1408
1561
  private readonly ranges: [number, number][],
1409
1562
  private readonly lambda: number,
1410
1563
  private readonly chunkCtx?: ChunkCtx,
1564
+ private readonly sftScope: SftScope = "response",
1411
1565
  ) {
1412
1566
  this.nLayers = model.layers.length;
1413
1567
  const lastHi = ranges[ranges.length - 1]?.[1] ?? 0;
@@ -1455,9 +1609,15 @@ export class SegmentedBackwardOrpoPrefixGemma4 {
1455
1609
  const { chosenIdx, rejectedIdx } = prefixGatherIdx(this.curP, this.curRc, this.curRr);
1456
1610
  const lw = branchLogpMeanGathered(model, hn, chosenIdx, this.curChosenResp!, this.curChunk);
1457
1611
  const lr = branchLogpMeanGathered(model, hn, rejectedIdx, this.curRejectedResp!, this.curChunk);
1612
+ // sft_scope:"full": prompt predictions from the same concat hiddens (see
1613
+ // SegmentedBackwardOrpoPrefix); ℓw/ℓr stay response-only.
1614
+ const nllFull = this.sftScope === "full"
1615
+ ? prefixFullNll(model, hn, this.curPromptIds!, lw, this.curRc, this.curChunk)
1616
+ : null;
1458
1617
  hn.dispose();
1459
- const loss = orpoLossFromLogps(lw, lr, this.lambda);
1618
+ const loss = orpoLossFromLogps(lw, lr, this.lambda, nllFull ?? undefined);
1460
1619
  lw.dispose(); lr.dispose();
1620
+ nllFull?.dispose();
1461
1621
  return [loss];
1462
1622
  }, 1);
1463
1623
 
@@ -1525,6 +1685,7 @@ export class SegmentedBackwardOrpoPrefixGemma4 {
1525
1685
  if (P < 1 || Rc < 1 || Rr < 1) throw new Error("SegmentedBackwardOrpoPrefixGemma4: need P,Rc,Rr >= 1");
1526
1686
  const T = P + Rc + Rr;
1527
1687
  this.curP = P; this.curRc = Rc; this.curRr = Rr;
1688
+ this.curPromptIds = promptIds;
1528
1689
  this.curChosenResp = chosenResp; this.curRejectedResp = rejectedResp;
1529
1690
  this.headSink = [];
1530
1691
  this.curChunk = this.chunkCtx ? { ...this.chunkCtx, sink: this.headSink } : undefined;
@@ -1607,16 +1768,15 @@ export class SegmentedBackwardOrpoPrefixGemma4 {
1607
1768
  const res = this.segVjps[k]!.apply(primals, cots);
1608
1769
  for (const o of res.outputs) o.dispose();
1609
1770
 
1610
- const newDh = detachLeaf(res.vjps[0]!);
1771
+ const leaves = detachLeaves(res.vjps); // one barrier (backlog #9)
1772
+ const newDh = leaves[0]!;
1611
1773
  cons.forEach((d, ci) => {
1612
- const dk = detachLeaf(res.vjps[1 + 2 * ci]!);
1613
- const dv = detachLeaf(res.vjps[1 + 2 * ci + 1]!);
1614
- this.accumulateDKV(dKV, d, dk, dv);
1774
+ this.accumulateDKV(dKV, d, leaves[1 + 2 * ci]!, leaves[1 + 2 * ci + 1]!);
1615
1775
  });
1616
1776
  const base = 1 + 2 * cons.length;
1617
1777
  idxs.forEach((j, s) => {
1618
- grads[j] = detachLeaf(res.vjps[base + 2 * s]!);
1619
- grads[n + j] = detachLeaf(res.vjps[base + 2 * s + 1]!);
1778
+ grads[j] = leaves[base + 2 * s]!;
1779
+ grads[n + j] = leaves[base + 2 * s + 1]!;
1620
1780
  });
1621
1781
 
1622
1782
  dh.dispose();
@@ -1640,7 +1800,7 @@ export class SegmentedBackwardOrpoPrefixGemma4 {
1640
1800
  this.curPerLayer?.dispose();
1641
1801
  this.curPerLayer = null;
1642
1802
  if (this.curMasks) { for (const m of this.curMasks.values()) m.arr?.dispose(); this.curMasks = null; }
1643
- this.curChosenResp = this.curRejectedResp = null;
1803
+ this.curPromptIds = this.curChosenResp = this.curRejectedResp = null;
1644
1804
  for (const d of this.headSink) d.dispose();
1645
1805
  this.headSink = [];
1646
1806
  dhOut?.dispose();