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
@@ -0,0 +1,23 @@
1
+ // mlx-bun memory — synthesis stage 5: WIKIFY (deterministic). ⚠️ M1 STUB.
2
+ //
3
+ // Non-model editorial cleanup after synthesis: canonicalize [[wikilinks]]
4
+ // (spaces↔underscores, case, resolve to existing stems), repair footnotes, and
5
+ // run the deterministic editorial gate. Non-fatal — a failure here logs and
6
+ // continues; it never corrupts an article. Mirrors lucien's wikify.ts +
7
+ // normalize-wikilinks.ts + normalize-footnotes.ts.
8
+ //
9
+ // See docs/design/memory-system.md → "The nightly pipeline" (wikify/normalize).
10
+
11
+ import type { SynthesisEvent } from "./pipeline";
12
+
13
+ export interface WikifyResult {
14
+ articlesNormalized: number;
15
+ linksFixed: number;
16
+ }
17
+
18
+ /** Deterministic editorial gate + link/footnote normalize. STUB. */
19
+ export async function wikify(_onEvent?: (e: SynthesisEvent) => void): Promise<WikifyResult> {
20
+ // TODO(M1): resolveWikilinkToStem across touched articles (vault.ts already
21
+ // has the resolver), footnote renumber/repair, {{stub}} marking.
22
+ throw new Error("memory.wikify is not implemented yet (M1)");
23
+ }
@@ -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>;
@@ -59,31 +67,50 @@ export class MetalKernel {
59
67
  apply(inputs: MlxArray[], call: MetalKernelCall): MlxArray[] {
60
68
  if (this.#disposed) throw new Error("MetalKernel used after dispose");
61
69
  const cfg = C.mlx_fast_metal_kernel_config_new();
70
+ // bun:ffi ptr() does NOT retain its argument, so any JS buffer whose pointer
71
+ // crosses an FFI call below is anchored here until that call returns —
72
+ // otherwise GC could free it mid-call (the lifetime class behind the
73
+ // flash-CCE pin bug). Pure-additive: holds references, changes no logic.
74
+ const keepAlive: unknown[] = [];
62
75
  try {
63
- for (const o of call.outputs) {
76
+ // Derive outputs + grid from the (current) input shapes when a function is
77
+ // given — so the SAME kernel object answers correctly for any input shape
78
+ // during a compile/replay trace, instead of baking in one call's shapes.
79
+ const outputs = call.outputShapeFn ? call.outputShapeFn(inputs) : call.outputs;
80
+ if (!outputs) throw new Error("metal_kernel: provide outputs or outputShapeFn");
81
+ const grid = typeof call.grid === "function" ? call.grid(inputs) : call.grid;
82
+ for (const o of outputs) {
64
83
  const shape = new Int32Array(o.shape);
84
+ keepAlive.push(shape);
65
85
  if (C.mlx_fast_metal_kernel_config_add_output_arg(cfg, ptr(shape), BigInt(o.shape.length), o.dtype) !== 0)
66
86
  throw new Error(`metal_kernel add_output_arg failed: ${takeMlxError() ?? ""}`);
67
87
  }
68
- C.mlx_fast_metal_kernel_config_set_grid(cfg, call.grid[0], call.grid[1], call.grid[2]);
88
+ C.mlx_fast_metal_kernel_config_set_grid(cfg, grid[0], grid[1], grid[2]);
69
89
  C.mlx_fast_metal_kernel_config_set_thread_group(cfg, call.threadGroup[0], call.threadGroup[1], call.threadGroup[2]);
70
90
  if (call.initValue !== undefined)
71
91
  C.mlx_fast_metal_kernel_config_set_init_value(cfg, call.initValue);
72
92
  for (const [k, v] of Object.entries(call.templateInts ?? {})) {
73
93
  const kb = Buffer.from(k + "\0", "utf8");
94
+ keepAlive.push(kb);
74
95
  C.mlx_fast_metal_kernel_config_add_template_arg_int(cfg, ptr(kb), v);
75
96
  }
76
97
  for (const [k, v] of Object.entries(call.templateDtypes ?? {})) {
77
98
  const kb = Buffer.from(k + "\0", "utf8");
99
+ keepAlive.push(kb);
78
100
  C.mlx_fast_metal_kernel_config_add_template_arg_dtype(cfg, ptr(kb), v);
79
101
  }
80
102
 
81
103
  const handles = new BigUint64Array(inputs.map((a) => a.handle));
104
+ keepAlive.push(handles);
82
105
  const inVec = C.mlx_vector_array_new_data(ptr(handles), BigInt(inputs.length));
83
106
  const slot = new BigUint64Array([C.mlx_vector_array_new()]);
107
+ keepAlive.push(slot);
84
108
  const slotPtr = ptr(slot);
85
109
  const status = C.mlx_fast_metal_kernel_apply(slotPtr, this.#kernel, inVec, cfg, call.stream ?? gpuStream);
86
110
  const outVec = read.u64(slotPtr, 0);
111
+ // anchor: all ptr()'d JS buffers above are done being read by native code by
112
+ // this point; touching keepAlive keeps the JIT from freeing them earlier.
113
+ if (keepAlive.length < 0) throw new Error("unreachable");
87
114
  C.mlx_vector_array_free(inVec);
88
115
  if (status !== 0) {
89
116
  C.mlx_vector_array_free(outVec);
@@ -29,10 +29,12 @@ function forwardTiles(headDim: number): [number, number] {
29
29
  throw new Error(`unsupported head_dim=${headDim}`);
30
30
  }
31
31
 
32
- /** uint32 [n] array (matches optiq's shape/causal input dtype). */
32
+ /** uint32 [n] array (matches optiq's shape/causal input dtype). COPY into an
33
+ * mlx-owned array (not the pinning fromView) — these tiny kernel-arg arrays are
34
+ * disposed before the lazy kernel evaluates; pinning leaks the host buffer per
35
+ * call (see flash-cce.ts u32 for the full diagnosis). */
33
36
  function u32(values: number[]): MlxArray {
34
- const u = new Uint32Array(values);
35
- return MlxArray.fromView(new Uint8Array(u.buffer.slice(0)), [values.length], Dtype.uint32);
37
+ return MlxArray.fromBytesCopy(new Uint8Array(new Uint32Array(values).buffer), [values.length], Dtype.uint32);
36
38
  }
37
39
 
38
40
  // Forward shader — verbatim from optiq flash_attention_metal.py _KERNEL_SOURCE.
@@ -406,7 +408,13 @@ for (uint kj = 0u; kj < n_kv_tiles; ++kj) {
406
408
  }
407
409
  threadgroup_barrier(mem_flags::mem_threadgroup);
408
410
 
409
- if (is_causal && kv_base_row > q_row_global) {
411
+ // Skip a KV tile only when it is entirely beyond this Q tile's last row
412
+ // (fully causally masked). The condition is UNIFORM across the threadgroup
413
+ // (q_tile_idx·BQ is the same for every thread), so the barrier below is hit
414
+ // by all threads — using the per-thread q_row_global here would diverge the
415
+ // barrier (undefined behavior). The per-element mask below still handles the
416
+ // partial diagonal tile.
417
+ if (is_causal && kv_base_row >= (q_tile_idx * BQ + BQ)) {
410
418
  threadgroup_barrier(mem_flags::mem_threadgroup);
411
419
  continue;
412
420
  }
@@ -533,16 +541,12 @@ export function flashBackward(
533
541
  grid: [nQTiles * bq, Hq, B],
534
542
  threadGroup: [bq, 1, 1],
535
543
  });
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]!];
544
+ // dK is written by the kernel in the correct [B,Hkv,Tkv,D] layout
545
+ // (dK_j[d] → [kv_row][d]); no post-transpose. (An earlier "fix" transposed
546
+ // it on the false belief the buffer was swapped — that transpose is only the
547
+ // identity at Tkv==D and corrupted dK for Tkv≠D; removed after
548
+ // scripts/experiments/flash-dkv-debug.ts showed the raw output is correct.)
549
+ return [dq[0]!, dkv[0]!, dkv[1]!];
546
550
  } finally {
547
551
  Dvec.dispose();
548
552
  shape.dispose();
@@ -607,11 +611,25 @@ export function flashAttention(
607
611
  }
608
612
 
609
613
  // ---------------------------------------------------------------------------
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).
614
+ // Training-attention routing (opt-in; default null = ops.sdpa).
615
+ //
616
+ // Parity oracle, made explicit:
617
+ // - DEFAULT (ops.sdpa) is the L1 path: mlx-lm's tuner differentiates the same
618
+ // mx.fast.scaled_dot_product_attention, so training through it is bit-faithful
619
+ // to mlx-lm. Exact dQ/dK/dV (finite-difference cross-checked), fast, but
620
+ // O(L²) backward memory (materializes the score matrix).
621
+ // - This flash kernel is the L2 path: a port of mlx-optiq's
622
+ // flash_attention_metal (O(L) backward memory). Its parity oracle is OPTIQ,
623
+ // not finite differences. Confirmed: optiq's flash dK == ops.sdpa to f16
624
+ // (scripts/flash-optiq-check.py, rel 0.0%), and mlx-bun's flash == ops.sdpa
625
+ // (tests/flash-attention.test.ts) ⟹ mlx-bun flash == optiq flash (L2 parity).
626
+ //
627
+ // Two real port bugs were fixed to reach that parity (the original diverged
628
+ // ~100% from optiq on dK): a spurious dK transpose in flashBackward (corrupted
629
+ // dK for Tkv≠D), and a per-thread (divergent) threadgroup_barrier in the dQ
630
+ // causal tile-skip. flash is opt-in (MLX_BUN_TRAIN_ATTN=flash) for memory-bound
631
+ // long context; it is ~30× slower than ops.sdpa. flashSupported gates
632
+ // head_dim/seq; sliding-window (array-mask) layers fall back to manualSdpa.
615
633
  // ---------------------------------------------------------------------------
616
634
 
617
635
  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
  }