mlx-bun 0.0.4 → 0.0.5

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 (50) hide show
  1. package/README.md +166 -93
  2. package/docs/design/adapters-end-to-end.md +50 -46
  3. package/docs/design/compat-cli-surface-design.md +10 -8
  4. package/docs/design/docs-reorg-plan.md +14 -1
  5. package/docs/design/hlg-sampling.md +12 -2
  6. package/docs/design/memory-system.md +1 -1
  7. package/docs/design/optimization_plan.md +4 -2
  8. package/docs/design/orpo-future-enhancements.md +298 -0
  9. package/docs/design/orpo-training.md +1199 -0
  10. package/docs/design/parallel-slots.md +34 -26
  11. package/docs/design/runtime-isolation.md +1 -1
  12. package/docs/design/segmented-backward-training.md +58 -39
  13. package/docs/design/tauri-desktop-app.md +127 -0
  14. package/docs/{segmented-backward-handoff.md → investigations/segmented-backward-handoff.md} +1 -1
  15. package/docs/investigations/starter-model-port-handoff.md +32 -13
  16. package/docs/investigations/steel-flash-cce-handoff.md +431 -0
  17. package/docs/planning/IDEAS.md +29 -0
  18. package/docs/planning/PRODUCT_ROADMAP.md +147 -27
  19. package/docs/planning/chunk-task-roadmap.md +1 -1
  20. package/docs/planning/journal.md +161 -0
  21. package/docs/reference/distribution.md +61 -5
  22. package/docs/reference/library-api.md +29 -14
  23. package/docs/reference/orpo-quickstart.md +91 -0
  24. package/docs/reference/server-api.md +105 -7
  25. package/docs/reference/server-config.md +12 -5
  26. package/docs/reference/training.md +292 -0
  27. package/package.json +1 -1
  28. package/src/eval/ifeval.ts +138 -0
  29. package/src/eval/ultrafeedback.ts +60 -0
  30. package/src/lora.ts +12 -7
  31. package/src/mlx/metal-kernel.ts +18 -4
  32. package/src/model/flash-attention.ts +32 -16
  33. package/src/model/fused-geglu-kernel.ts +111 -0
  34. package/src/model/gemma4-base.ts +39 -1
  35. package/src/model/gemma4.ts +196 -21
  36. package/src/model/minicpm5.ts +44 -3
  37. package/src/server.ts +3 -3
  38. package/src/train/dataset.ts +18 -3
  39. package/src/train/flash-cce.ts +1162 -0
  40. package/src/train/job.ts +26 -2
  41. package/src/train/lora-params.ts +53 -4
  42. package/src/train/loss.ts +941 -0
  43. package/src/train/optimizer.ts +10 -3
  44. package/src/train/prefix-shared.ts +435 -0
  45. package/src/train/rank.ts +36 -0
  46. package/src/train/segmented.ts +1148 -3
  47. package/src/train/steel-qmm-header.ts +1461 -0
  48. package/src/train/trainer.ts +509 -52
  49. package/src/web/app.html +21 -5
  50. /package/{docs → src/assets}/curve-designer.html +0 -0
@@ -0,0 +1,138 @@
1
+ // IFEval — programmatically-verifiable instruction-following scoring (Zhou et
2
+ // al. 2023, "Instruction-Following Evaluation for Large Language Models"). Each
3
+ // instance pairs a prompt with one or more *verifiable* instructions; a response
4
+ // is scored by deterministic checks (no LLM judge), so before/after ORPO
5
+ // comparisons are exact and reproducible.
6
+ //
7
+ // This implements the high-frequency instruction families (a working subset of
8
+ // the ~25 in the reference suite, extensible via INSTRUCTIONS). Strict scoring:
9
+ // an instance passes (strict) iff every instruction passes; we also report the
10
+ // per-instruction pass rate. The official metrics are prompt-level and
11
+ // instruction-level accuracy — both derived from `scoreInstance`.
12
+
13
+ export interface IFEvalInstance {
14
+ /** The prompt text (used by instructions that reference it, e.g. "repeat the request"). */
15
+ prompt: string;
16
+ /** Instruction ids, e.g. "keywords:existence". */
17
+ instruction_id_list: string[];
18
+ /** Per-instruction kwargs, aligned with instruction_id_list. */
19
+ kwargs: Array<Record<string, unknown>>;
20
+ }
21
+
22
+ export interface InstanceResult {
23
+ /** One pass/fail per instruction (aligned with instruction_id_list). */
24
+ perInstruction: boolean[];
25
+ /** Strict: all instructions passed. */
26
+ followedAll: boolean;
27
+ }
28
+
29
+ type Checker = (response: string, kw: Record<string, unknown>, inst: IFEvalInstance) => boolean;
30
+
31
+ const words = (s: string): string[] => s.trim().split(/\s+/).filter(Boolean);
32
+ const sentences = (s: string): string[] =>
33
+ s.split(/(?<=[.!?])\s+/).map((x) => x.trim()).filter(Boolean);
34
+ const paragraphs = (s: string): string[] =>
35
+ s.split(/\n\s*\n/).map((x) => x.trim()).filter(Boolean);
36
+ const norm = (s: string): string => s.toLowerCase();
37
+
38
+ const cmp = (n: number, rel: string | undefined, target: number): boolean => {
39
+ switch (rel) {
40
+ case "at least": return n >= target;
41
+ case "at most": return n <= target;
42
+ case "less than": return n < target;
43
+ case "exactly": return n === target;
44
+ default: return n >= target; // IFEval default for "around"/unspecified is lenient ≥
45
+ }
46
+ };
47
+
48
+ /** The verifiable-instruction registry. Keyed by IFEval instruction_id. */
49
+ export const INSTRUCTIONS: Record<string, Checker> = {
50
+ // ---- keywords ----
51
+ "keywords:existence": (r, kw) =>
52
+ (kw.keywords as string[]).every((k) => norm(r).includes(norm(k))),
53
+ "keywords:frequency": (r, kw) => {
54
+ const k = norm(kw.keyword as string);
55
+ const count = norm(r).split(k).length - 1;
56
+ return cmp(count, kw.relation as string, kw.frequency as number);
57
+ },
58
+ "keywords:forbidden_words": (r, kw) =>
59
+ !(kw.forbidden_words as string[]).some((k) => norm(r).includes(norm(k))),
60
+
61
+ // ---- length constraints ----
62
+ "length_constraints:number_words": (r, kw) =>
63
+ cmp(words(r).length, kw.relation as string, kw.num_words as number),
64
+ "length_constraints:number_sentences": (r, kw) =>
65
+ cmp(sentences(r).length, kw.relation as string, kw.num_sentences as number),
66
+ "length_constraints:number_paragraphs": (r, kw) =>
67
+ cmp(paragraphs(r).length, kw.relation as string, kw.num_paragraphs as number),
68
+
69
+ // ---- detectable format ----
70
+ "detectable_format:number_bullet_lists": (r, kw) => {
71
+ const bullets = r.split("\n").filter((l) => /^\s*[*\-]\s+/.test(l)).length;
72
+ return cmp(bullets, kw.relation as string, kw.num_bullets as number);
73
+ },
74
+ "detectable_format:number_highlighted_sections": (r, kw) => {
75
+ const hi = (r.match(/\*[^*\n]+\*/g) ?? []).length;
76
+ return cmp(hi, kw.relation as string, kw.num_highlights as number);
77
+ },
78
+ "detectable_format:title": (r) => /<<[^>\n]+>>/.test(r),
79
+ "detectable_format:json_format": (r) => {
80
+ const t = r.trim().replace(/^```(?:json)?\s*|\s*```$/g, "").trim();
81
+ try { JSON.parse(t); return true; } catch { return false; }
82
+ },
83
+
84
+ // ---- case ----
85
+ "change_case:english_lowercase": (r) => r === r.toLowerCase(),
86
+ "change_case:english_capital": (r) => r === r.toUpperCase(),
87
+
88
+ // ---- start / end ----
89
+ "startend:end_checker": (r, kw) =>
90
+ norm(r.trimEnd()).endsWith(norm((kw.end_phrase as string).trim())),
91
+ "startend:quotation": (r) => {
92
+ const t = r.trim();
93
+ return t.startsWith('"') && t.endsWith('"') && t.length >= 2;
94
+ },
95
+
96
+ // ---- punctuation ----
97
+ "punctuation:no_comma": (r) => !r.includes(","),
98
+ };
99
+
100
+ /** Score one IFEval instance (strict). Unknown instruction ids count as failed
101
+ * (so coverage gaps never inflate the score) — filter the dataset to the
102
+ * supported ids, or extend INSTRUCTIONS, to avoid that. */
103
+ export function scoreInstance(instance: IFEvalInstance, response: string): InstanceResult {
104
+ const perInstruction = instance.instruction_id_list.map((id, i) => {
105
+ const check = INSTRUCTIONS[id];
106
+ if (!check) return false;
107
+ try { return check(response, instance.kwargs[i] ?? {}, instance); } catch { return false; }
108
+ });
109
+ return { perInstruction, followedAll: perInstruction.every(Boolean) };
110
+ }
111
+
112
+ export interface IFEvalReport {
113
+ n: number;
114
+ /** Fraction of instances where ALL instructions were followed. */
115
+ promptAccuracy: number;
116
+ /** Fraction of individual instructions followed. */
117
+ instructionAccuracy: number;
118
+ }
119
+
120
+ /** Aggregate the official prompt-level + instruction-level accuracies over a set
121
+ * of (instance, response) pairs. */
122
+ export function aggregate(pairs: Array<{ instance: IFEvalInstance; response: string }>): IFEvalReport {
123
+ let prompts = 0, instrFollowed = 0, instrTotal = 0;
124
+ for (const { instance, response } of pairs) {
125
+ const r = scoreInstance(instance, response);
126
+ if (r.followedAll) prompts++;
127
+ instrFollowed += r.perInstruction.filter(Boolean).length;
128
+ instrTotal += r.perInstruction.length;
129
+ }
130
+ return {
131
+ n: pairs.length,
132
+ promptAccuracy: pairs.length ? prompts / pairs.length : 0,
133
+ instructionAccuracy: instrTotal ? instrFollowed / instrTotal : 0,
134
+ };
135
+ }
136
+
137
+ /** Instruction ids this implementation supports (filter datasets to these). */
138
+ export const SUPPORTED_INSTRUCTIONS = new Set(Object.keys(INSTRUCTIONS));
@@ -0,0 +1,60 @@
1
+ // UltraFeedback (binarized) → mlx-bun preference rows. The binarized schema
2
+ // (HuggingFaceH4/ultrafeedback_binarized) gives each row a `prompt` plus
3
+ // `chosen` / `rejected` as message lists; ORPO wants {prompt, chosen, rejected}
4
+ // where chosen/rejected are the assistant response *strings*. Pure transform so
5
+ // it is unit-testable without downloading the dataset.
6
+
7
+ export interface PreferenceRow {
8
+ prompt: string;
9
+ chosen: string;
10
+ rejected: string;
11
+ }
12
+
13
+ type Msg = { role?: string; content?: string };
14
+
15
+ /** Last assistant message content in a binarized message list (the response). */
16
+ function lastAssistant(messages: unknown): string | null {
17
+ if (!Array.isArray(messages)) return null;
18
+ for (let i = messages.length - 1; i >= 0; i--) {
19
+ const m = messages[i] as Msg;
20
+ if (m && m.role === "assistant" && typeof m.content === "string" && m.content.trim())
21
+ return m.content;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ /** Convert one binarized UltraFeedback row → a preference row, or null if it is
27
+ * unusable (missing fields, or chosen == rejected so there is no signal). */
28
+ export function toPreferenceRow(row: Record<string, unknown>): PreferenceRow | null {
29
+ const prompt = typeof row.prompt === "string" ? row.prompt : null;
30
+ const chosen = lastAssistant(row.chosen);
31
+ const rejected = lastAssistant(row.rejected);
32
+ if (!prompt || !chosen || !rejected) return null;
33
+ if (chosen.trim() === rejected.trim()) return null;
34
+ return { prompt, chosen, rejected };
35
+ }
36
+
37
+ /** Rough token estimate (≈4 chars/token) for a cheap length pre-filter before
38
+ * tokenization — keeps the curated set within a target context without loading
39
+ * a tokenizer. */
40
+ export function approxTokens(text: string): number {
41
+ return Math.ceil(text.length / 4);
42
+ }
43
+
44
+ /** Filter + convert a stream of binarized rows. Drops unusable rows and ones
45
+ * whose prompt+chosen or prompt+rejected approx-exceeds `maxApproxTokens`. */
46
+ export function curate(
47
+ rows: Record<string, unknown>[],
48
+ maxApproxTokens = 2048,
49
+ ): PreferenceRow[] {
50
+ const out: PreferenceRow[] = [];
51
+ for (const row of rows) {
52
+ const p = toPreferenceRow(row);
53
+ if (!p) continue;
54
+ const base = approxTokens(p.prompt);
55
+ if (base + approxTokens(p.chosen) > maxApproxTokens) continue;
56
+ if (base + approxTokens(p.rejected) > maxApproxTokens) continue;
57
+ out.push(p);
58
+ }
59
+ return out;
60
+ }
package/src/lora.ts CHANGED
@@ -37,7 +37,7 @@ export interface AdapterInfo {
37
37
  * materialized mlx arrays. Tensor names come from our header parser;
38
38
  * arrays from mlx's native loader via the proven map-get pattern
39
39
  * (weights.ts). The map and header mmap are freed before returning. */
40
- function loadAdapterTensors(file: string): Map<string, MlxArray> {
40
+ export function loadAdapterTensors(file: string): Map<string, MlxArray> {
41
41
  const sf = SafetensorsFile.open(file);
42
42
  const names = [...sf.tensors.keys()];
43
43
  sf.mmap.unmap();
@@ -74,18 +74,21 @@ function loadAdapterTensors(file: string): Map<string, MlxArray> {
74
74
  }
75
75
 
76
76
  /** Adapter scale from its config: mlx-lm writes lora_parameters.scale;
77
- * PEFT writes lora_alpha + r (scale = alpha / r). Reference: mount.py. */
78
- async function readAdapterScale(dir: string): Promise<{ scale: number; rank: number | null }> {
77
+ * PEFT writes lora_alpha + r (scale = alpha / r). Reference: mount.py.
78
+ * `rsLora` (recorded at train time) means the effective per-layer scale is
79
+ * α/√rank — the caller divides by √(that layer's rank). */
80
+ async function readAdapterScale(dir: string): Promise<{ scale: number; rank: number | null; rsLora: boolean }> {
79
81
  for (const name of ["optiq_lora_config.json", "adapter_config.json"]) {
80
82
  const f = Bun.file(`${dir}/${name}`);
81
83
  if (await f.exists()) {
82
84
  const cfg = (await f.json()) as Record<string, any>;
83
85
  const lp = cfg.lora_parameters;
86
+ const rsLora = Boolean(cfg.rs_lora ?? lp?.rs_lora ?? false);
84
87
  if (lp && typeof lp === "object")
85
- return { scale: Number(lp.scale ?? 20.0), rank: lp.rank ?? null };
88
+ return { scale: Number(lp.scale ?? 20.0), rank: lp.rank ?? null, rsLora };
86
89
  const alpha = Number(cfg.lora_alpha ?? 16);
87
90
  const r = Number(cfg.r ?? 8);
88
- return { scale: r ? alpha / r : 1.0, rank: r || null };
91
+ return { scale: r ? alpha / r : 1.0, rank: r || null, rsLora };
89
92
  }
90
93
  }
91
94
  throw new Error(`no adapter_config.json in ${dir}`);
@@ -200,7 +203,7 @@ export class AdapterManager {
200
203
  if (!existsSync(path)) throw new Error(`adapter dir not found: ${path}`);
201
204
 
202
205
  const weightsFile = adapterWeightsFile(path);
203
- const { scale, rank: configRank } = await readAdapterScale(path);
206
+ const { scale, rank: configRank, rsLora } = await readAdapterScale(path);
204
207
  const tensors = loadAdapterTensors(weightsFile);
205
208
  const targets = this.#model.loraTargets();
206
209
 
@@ -243,7 +246,9 @@ export class AdapterManager {
243
246
  `vs base [in ${linear.inFeatures}, out ${linear.outFeatures}]; ` +
244
247
  `was this adapter trained for a different base model?`,
245
248
  );
246
- validated.push({ linear, lw: { a, b, scale, rank } });
249
+ // rsLoRA: effective per-layer scale is α/√rank (matches training).
250
+ const effScale = rsLora ? scale / Math.sqrt(rank) : scale;
251
+ validated.push({ linear, lw: { a, b, scale: effScale, rank } });
247
252
  }
248
253
  if (validated.length === 0)
249
254
  throw new Error(
@@ -29,8 +29,16 @@ export interface MetalKernelSpec {
29
29
  }
30
30
 
31
31
  export interface MetalKernelCall {
32
- outputs: { shape: number[]; dtype: Dtype }[];
33
- grid: [number, number, number];
32
+ /** Concrete output shapes/dtypes. Provide this OR `outputShapeFn` (the latter
33
+ * makes the kernel derive its outputs from the input shapes, like a real MLX
34
+ * primitive — required to compose inside an mx.compile'd / shapeless closure,
35
+ * where the wrapper must answer "given these input shapes, what comes out?"). */
36
+ outputs?: { shape: number[]; dtype: Dtype }[];
37
+ /** Derive output shapes/dtypes from the (current/traced) input shapes. */
38
+ outputShapeFn?: (inputs: MlxArray[]) => { shape: number[]; dtype: Dtype }[];
39
+ /** Concrete launch grid, OR derive it from inputs (so it tracks shape under
40
+ * compile replay the same way `outputShapeFn` tracks output shapes). */
41
+ grid: [number, number, number] | ((inputs: MlxArray[]) => [number, number, number]);
34
42
  threadGroup: [number, number, number];
35
43
  templateInts?: Record<string, number>;
36
44
  templateDtypes?: Record<string, Dtype>;
@@ -60,12 +68,18 @@ export class MetalKernel {
60
68
  if (this.#disposed) throw new Error("MetalKernel used after dispose");
61
69
  const cfg = C.mlx_fast_metal_kernel_config_new();
62
70
  try {
63
- for (const o of call.outputs) {
71
+ // Derive outputs + grid from the (current) input shapes when a function is
72
+ // given — so the SAME kernel object answers correctly for any input shape
73
+ // during a compile/replay trace, instead of baking in one call's shapes.
74
+ const outputs = call.outputShapeFn ? call.outputShapeFn(inputs) : call.outputs;
75
+ if (!outputs) throw new Error("metal_kernel: provide outputs or outputShapeFn");
76
+ const grid = typeof call.grid === "function" ? call.grid(inputs) : call.grid;
77
+ for (const o of outputs) {
64
78
  const shape = new Int32Array(o.shape);
65
79
  if (C.mlx_fast_metal_kernel_config_add_output_arg(cfg, ptr(shape), BigInt(o.shape.length), o.dtype) !== 0)
66
80
  throw new Error(`metal_kernel add_output_arg failed: ${takeMlxError() ?? ""}`);
67
81
  }
68
- C.mlx_fast_metal_kernel_config_set_grid(cfg, call.grid[0], call.grid[1], call.grid[2]);
82
+ C.mlx_fast_metal_kernel_config_set_grid(cfg, grid[0], grid[1], grid[2]);
69
83
  C.mlx_fast_metal_kernel_config_set_thread_group(cfg, call.threadGroup[0], call.threadGroup[1], call.threadGroup[2]);
70
84
  if (call.initValue !== undefined)
71
85
  C.mlx_fast_metal_kernel_config_set_init_value(cfg, call.initValue);
@@ -406,7 +406,13 @@ for (uint kj = 0u; kj < n_kv_tiles; ++kj) {
406
406
  }
407
407
  threadgroup_barrier(mem_flags::mem_threadgroup);
408
408
 
409
- if (is_causal && kv_base_row > q_row_global) {
409
+ // Skip a KV tile only when it is entirely beyond this Q tile's last row
410
+ // (fully causally masked). The condition is UNIFORM across the threadgroup
411
+ // (q_tile_idx·BQ is the same for every thread), so the barrier below is hit
412
+ // by all threads — using the per-thread q_row_global here would diverge the
413
+ // barrier (undefined behavior). The per-element mask below still handles the
414
+ // partial diagonal tile.
415
+ if (is_causal && kv_base_row >= (q_tile_idx * BQ + BQ)) {
410
416
  threadgroup_barrier(mem_flags::mem_threadgroup);
411
417
  continue;
412
418
  }
@@ -533,16 +539,12 @@ export function flashBackward(
533
539
  grid: [nQTiles * bq, Hq, B],
534
540
  threadGroup: [bq, 1, 1],
535
541
  });
536
- // The dKV kernel emits dK with its (Tkv, D) axes transposed in the buffer
537
- // (verified: dK[i][j] == correct[j][i] at T=D, a [Tkv,D]↔[D,Tkv] swap in
538
- // general — scripts/flash-dk-debug.ts JS-FIX 0.07% vs autograd). dV is
539
- // unaffected. Recover the correct layout: transpose → materialize → reshape.
540
- const dkFixed = ops.reshape(
541
- ops.contiguous(ops.transposeAxes(dkv[0]!, [0, 1, 3, 2])),
542
- [B, Hkv, Tkv, D],
543
- );
544
- dkv[0]!.dispose();
545
- return [dq[0]!, dkFixed, dkv[1]!];
542
+ // dK is written by the kernel in the correct [B,Hkv,Tkv,D] layout
543
+ // (dK_j[d] → [kv_row][d]); no post-transpose. (An earlier "fix" transposed
544
+ // it on the false belief the buffer was swapped — that transpose is only the
545
+ // identity at Tkv==D and corrupted dK for Tkv≠D; removed after
546
+ // scripts/experiments/flash-dkv-debug.ts showed the raw output is correct.)
547
+ return [dq[0]!, dkv[0]!, dkv[1]!];
546
548
  } finally {
547
549
  Dvec.dispose();
548
550
  shape.dispose();
@@ -607,11 +609,25 @@ export function flashAttention(
607
609
  }
608
610
 
609
611
  // ---------------------------------------------------------------------------
610
- // Training-attention routing. ops.sdpa's vjp gives a wrong dK (it's an
611
- // inference op), so the training forward must use a known-correct attention:
612
- // the flash kernel for causal/full layers (memory-efficient, validated), and a
613
- // manual materialized SDPA for sliding-window (array-mask) layers. Set by the
614
- // trainer; null = inference (use ops.sdpa as before).
612
+ // Training-attention routing (opt-in; default null = ops.sdpa).
613
+ //
614
+ // Parity oracle, made explicit:
615
+ // - DEFAULT (ops.sdpa) is the L1 path: mlx-lm's tuner differentiates the same
616
+ // mx.fast.scaled_dot_product_attention, so training through it is bit-faithful
617
+ // to mlx-lm. Exact dQ/dK/dV (finite-difference cross-checked), fast, but
618
+ // O(L²) backward memory (materializes the score matrix).
619
+ // - This flash kernel is the L2 path: a port of mlx-optiq's
620
+ // flash_attention_metal (O(L) backward memory). Its parity oracle is OPTIQ,
621
+ // not finite differences. Confirmed: optiq's flash dK == ops.sdpa to f16
622
+ // (scripts/flash-optiq-check.py, rel 0.0%), and mlx-bun's flash == ops.sdpa
623
+ // (tests/flash-attention.test.ts) ⟹ mlx-bun flash == optiq flash (L2 parity).
624
+ //
625
+ // Two real port bugs were fixed to reach that parity (the original diverged
626
+ // ~100% from optiq on dK): a spurious dK transpose in flashBackward (corrupted
627
+ // dK for Tkv≠D), and a per-thread (divergent) threadgroup_barrier in the dQ
628
+ // causal tile-skip. flash is opt-in (MLX_BUN_TRAIN_ATTN=flash) for memory-bound
629
+ // long context; it is ~30× slower than ops.sdpa. flashSupported gates
630
+ // head_dim/seq; sliding-window (array-mask) layers fall back to manualSdpa.
615
631
  // ---------------------------------------------------------------------------
616
632
 
617
633
  let trainAttnMode: "flash" | "manual" | null = null;
@@ -16,6 +16,8 @@
16
16
  import { MlxArray } from "../mlx/array";
17
17
  import { Dtype } from "../mlx/ffi";
18
18
  import { MetalKernel } from "../mlx/metal-kernel";
19
+ import { CustomVjp } from "../mlx/custom-vjp";
20
+ import * as ops from "../mlx/ops";
19
21
 
20
22
  /** ON by default: the match-compat kernel is BIT-EXACT with the spelled-out
21
23
  * path (kl --decode = 0 on e4b), so per the bit-parity-floor principle it
@@ -28,6 +30,24 @@ export function fusedGeluEnabled(): boolean {
28
30
  /** Dispatch counter (a gate test asserts the kernel actually ran). */
29
31
  export let fusedGeluCalls = 0;
30
32
 
33
+ // Training-mode flag: when set, model GeGLU sites use the DIFFERENTIABLE wrapper
34
+ // (Metal-kernel forward + hand-derived vjp) instead of the plain `fusedGeglu`
35
+ // (a bare CustomKernel with no vjp — feeding it to autograd throws
36
+ // "[Primitive::vjp] Not implemented for CustomKernel"). The differentiable
37
+ // wrapper also recomputes the gelu in the backward from the primal `a` instead
38
+ // of retaining the ~9 forward intermediates, so it lowers the MLP activation
39
+ // memory the backward holds — the dominant resident at long context under
40
+ // ops.sdpa (which never materializes the O(T²) scores). Set by the trainer
41
+ // around the train loop; cleared in its finally. Off → inference behaviour
42
+ // (plain `fusedGeglu`) is byte-for-byte unchanged.
43
+ let _trainingGeglu = false;
44
+ export function setFusedGeluTraining(on: boolean): void {
45
+ _trainingGeglu = on;
46
+ }
47
+ export function fusedGeluTraining(): boolean {
48
+ return _trainingGeglu;
49
+ }
50
+
31
51
  // Pointwise: one thread per element. gelu_approx =
32
52
  // 0.5 x (1 + tanh(√(2/π)·(x + 0.044715 x³))) — composed in f32.
33
53
  const SOURCE = String.raw`
@@ -85,3 +105,94 @@ export function fusedGeglu(a: MlxArray, b: MlxArray): MlxArray {
85
105
  });
86
106
  return out!;
87
107
  }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Differentiable wrapper: Metal kernel forward + pure-MLX backward
111
+ // ---------------------------------------------------------------------------
112
+
113
+ // GELU tanh-approx constants
114
+ const SQRT_2_PI = 0.7978845608028654;
115
+ const C_CUBE = 0.044715;
116
+
117
+ /** Compute gelu_approx(a) = 0.5 * a * (1 + tanh(sqrt(2/pi) * (a + 0.044715*a^3)))
118
+ * and gelu'(a) using the MLX ops layer. All intermediates are disposed
119
+ * before returning; caller owns the two returned arrays. */
120
+ function geluAndGrad(a: MlxArray): { gelu: MlxArray; geluGrad: MlxArray } {
121
+ // a^3 = a * a * a
122
+ const a2 = ops.mul(a, a);
123
+ const a3 = ops.mul(a2, a);
124
+ // inner = a + 0.044715 * a^3
125
+ const cx3 = ops.mulScalar(a3, C_CUBE);
126
+ const inner = ops.add(a, cx3);
127
+ // z = sqrt(2/pi) * inner
128
+ const z = ops.mulScalar(inner, SQRT_2_PI);
129
+ // t = tanh(z)
130
+ const t = ops.tanh(z);
131
+ // gelu = 0.5 * a * (1 + t)
132
+ const one = ops.scalarLike(1, a);
133
+ const t1 = ops.add(one, t);
134
+ const halfA = ops.mulScalar(a, 0.5);
135
+ const gelu = ops.mul(halfA, t1);
136
+
137
+ // gelu'(a) = 0.5*(1+t) + 0.5*a*(1-t^2)*dz/da
138
+ // dz/da = sqrt(2/pi) * (1 + 3*0.044715*a^2)
139
+ // 1 - t^2 = sech^2(z)
140
+ const halfT1 = ops.mulScalar(t1, 0.5); // 0.5*(1+t)
141
+ const t2 = ops.mul(t, t); // t^2
142
+ const sech2 = ops.sub(one, t2); // 1 - t^2
143
+ const halfASech2 = ops.mul(halfA, sech2); // 0.5*a*(1-t^2)
144
+ // dz/da
145
+ const a2scaled = ops.mulScalar(a2, 3 * C_CUBE); // 3*0.044715*a^2
146
+ const dzda = ops.add(one, a2scaled); // 1 + 3*0.044715*a^2
147
+ const dzdaScaled = ops.mulScalar(dzda, SQRT_2_PI);// sqrt(2/pi)*dz_unnorm
148
+ // chain: 0.5*a*(1-t^2)*dz/da
149
+ const chainTerm = ops.mul(halfASech2, dzdaScaled);
150
+ const geluGrad = ops.add(halfT1, chainTerm); // 0.5*(1+t) + chain
151
+
152
+ // dispose all intermediates
153
+ for (const arr of [a2, a3, cx3, inner, z, t, one, t1, halfA, halfT1, t2, sech2,
154
+ halfASech2, a2scaled, dzda, dzdaScaled, chainTerm]) {
155
+ arr.dispose();
156
+ }
157
+
158
+ return { gelu, geluGrad };
159
+ }
160
+
161
+ let _differentiableKernel: CustomVjp | null = null;
162
+
163
+ function getDifferentiableKernel(): CustomVjp {
164
+ if (!_differentiableKernel) {
165
+ _differentiableKernel = new CustomVjp(
166
+ // forward: use the Metal kernel
167
+ ([a, b]: MlxArray[]) => [fusedGeglu(a!, b!)],
168
+ // backward
169
+ (primals: MlxArray[], cotangents: MlxArray[], _outputs: MlxArray[]) => {
170
+ const [a, b] = primals as [MlxArray, MlxArray];
171
+ const [dc] = cotangents as [MlxArray];
172
+
173
+ const { gelu, geluGrad } = geluAndGrad(a);
174
+
175
+ // grad_b = dc * gelu(a)
176
+ const grad_b = ops.mul(dc, gelu);
177
+
178
+ // grad_a = dc * b * gelu'(a)
179
+ const dcb = ops.mul(dc, b);
180
+ const grad_a = ops.mul(dcb, geluGrad);
181
+
182
+ gelu.dispose();
183
+ geluGrad.dispose();
184
+ dcb.dispose();
185
+
186
+ return [grad_a, grad_b];
187
+ },
188
+ );
189
+ }
190
+ return _differentiableKernel;
191
+ }
192
+
193
+ /** gelu_approx(a) · b with a hand-derived vjp. Uses the Metal kernel forward,
194
+ * MLX-ops backward. For training only (inference uses the non-differentiable
195
+ * fusedGeglu). */
196
+ export function fusedGegluDifferentiable(a: MlxArray, b: MlxArray): MlxArray {
197
+ return getDifferentiableKernel().apply([a, b])[0]!;
198
+ }
@@ -46,6 +46,31 @@ export interface LoraWeights {
46
46
  * (PLAN Phase 8 decision). Set/restored by generate(). */
47
47
  export class LoraState {
48
48
  active: string[] = [];
49
+ /** Training-only LoRA-input dropout. `rate` is the drop probability; `seed` is
50
+ * set per micro-step by the trainer and is CONSTANT across that step's forward
51
+ * and any recompute (segmented / gradient-checkpoint), so each layer's mask —
52
+ * keyed by (seed, the linear's dropoutId) — is reproduced exactly in the
53
+ * backward. `seed === null` (the default, and for inference) disables it. */
54
+ dropoutRate = 0;
55
+ dropoutSeed: number | null = null;
56
+ }
57
+
58
+ /** Inverted LoRA-input dropout, keyed by (seed, id) so it is deterministic —
59
+ * the same (seed, id, shape) reproduces the mask, which is what makes the
60
+ * segmented / gradient-checkpoint recompute correct. kept ⇒ x/(1-p),
61
+ * dropped ⇒ 0 (preserves the expectation). Caller owns the result. */
62
+ export function loraInputDropout(x: MlxArray, p: number, seed: number, id: number): MlxArray {
63
+ const key = ops.randomKey(BigInt(seed) * 100003n + BigInt(id));
64
+ const u = ops.randomUniform(x.shape, Dtype.float32, 0, 1, key); // [shape] in [0,1)
65
+ key.dispose();
66
+ const pArr = ops.scalarLike(p, u);
67
+ const keep = ops.less(pArr, u); // u > p ⇒ keep (bool [shape])
68
+ const scaleX = ops.scalarLike(1 / (1 - p), x);
69
+ const zeroX = ops.scalarLike(0, x);
70
+ const mask = ops.where(keep, scaleX, zeroX); // x.dtype
71
+ const xd = ops.mul(x, mask);
72
+ for (const a of [u, pArr, keep, scaleX, zeroX, mask]) a.dispose();
73
+ return xd;
49
74
  }
50
75
 
51
76
  export class QuantizedLinear {
@@ -53,6 +78,9 @@ export class QuantizedLinear {
53
78
  adapters: Map<string, LoraWeights> | null = null;
54
79
  /** Shared per-model active state (wired by AdapterManager.mount). */
55
80
  loraState: LoraState | null = null;
81
+ /** Stable per-target index for keying training-only LoRA dropout (set by
82
+ * attachForTraining). Gives each adapted linear an independent dropout mask. */
83
+ dropoutId = 0;
56
84
 
57
85
  constructor(
58
86
  readonly w: MlxArray,
@@ -92,10 +120,19 @@ export class QuantizedLinear {
92
120
  // cast form is what the adapters were trained behind.)
93
121
  const st = this.loraState;
94
122
  if (st && st.active.length > 0 && this.adapters && this.adapters.size > 0) {
123
+ // Training-only LoRA-input dropout (PEFT applies dropout to x before A;
124
+ // the base quantized path is untouched). Keyed by (seed, dropoutId) so the
125
+ // backward recompute reproduces the exact mask.
126
+ let xLora = x;
127
+ let xDrop: MlxArray | null = null;
128
+ if (st.dropoutSeed !== null && st.dropoutRate > 0) {
129
+ xDrop = loraInputDropout(x, st.dropoutRate, st.dropoutSeed, this.dropoutId);
130
+ xLora = xDrop;
131
+ }
95
132
  for (const id of st.active) {
96
133
  const lw = this.adapters.get(id);
97
134
  if (!lw) continue;
98
- const xa = ops.matmul(x, lw.a);
135
+ const xa = ops.matmul(xLora, lw.a);
99
136
  const z = ops.matmul(xa, lw.b);
100
137
  xa.dispose();
101
138
  const zs = ops.mulScalar(z, lw.scale);
@@ -105,6 +142,7 @@ export class QuantizedLinear {
105
142
  out = disposing(out, ops.add(out, zc));
106
143
  zc.dispose();
107
144
  }
145
+ xDrop?.dispose();
108
146
  }
109
147
  return out;
110
148
  }