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.
- package/README.md +166 -93
- package/docs/design/adapters-end-to-end.md +50 -46
- package/docs/design/compat-cli-surface-design.md +10 -8
- package/docs/design/docs-reorg-plan.md +14 -1
- package/docs/design/hlg-sampling.md +12 -2
- package/docs/design/memory-system.md +1 -1
- package/docs/design/optimization_plan.md +4 -2
- package/docs/design/orpo-future-enhancements.md +298 -0
- package/docs/design/orpo-training.md +1199 -0
- package/docs/design/parallel-slots.md +34 -26
- package/docs/design/runtime-isolation.md +1 -1
- package/docs/design/segmented-backward-training.md +58 -39
- package/docs/design/tauri-desktop-app.md +127 -0
- package/docs/{segmented-backward-handoff.md → investigations/segmented-backward-handoff.md} +1 -1
- package/docs/investigations/starter-model-port-handoff.md +32 -13
- package/docs/investigations/steel-flash-cce-handoff.md +431 -0
- package/docs/planning/IDEAS.md +29 -0
- package/docs/planning/PRODUCT_ROADMAP.md +147 -27
- package/docs/planning/chunk-task-roadmap.md +1 -1
- package/docs/planning/journal.md +161 -0
- package/docs/reference/distribution.md +61 -5
- package/docs/reference/library-api.md +29 -14
- package/docs/reference/orpo-quickstart.md +91 -0
- package/docs/reference/server-api.md +105 -7
- package/docs/reference/server-config.md +12 -5
- package/docs/reference/training.md +292 -0
- package/package.json +1 -1
- package/src/eval/ifeval.ts +138 -0
- package/src/eval/ultrafeedback.ts +60 -0
- package/src/lora.ts +12 -7
- package/src/mlx/metal-kernel.ts +18 -4
- package/src/model/flash-attention.ts +32 -16
- package/src/model/fused-geglu-kernel.ts +111 -0
- package/src/model/gemma4-base.ts +39 -1
- package/src/model/gemma4.ts +196 -21
- package/src/model/minicpm5.ts +44 -3
- package/src/server.ts +3 -3
- package/src/train/dataset.ts +18 -3
- package/src/train/flash-cce.ts +1162 -0
- package/src/train/job.ts +26 -2
- package/src/train/lora-params.ts +53 -4
- package/src/train/loss.ts +941 -0
- package/src/train/optimizer.ts +10 -3
- package/src/train/prefix-shared.ts +435 -0
- package/src/train/rank.ts +36 -0
- package/src/train/segmented.ts +1148 -3
- package/src/train/steel-qmm-header.ts +1461 -0
- package/src/train/trainer.ts +509 -52
- package/src/web/app.html +21 -5
- /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
|
-
|
|
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
|
-
|
|
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(
|
package/src/mlx/metal-kernel.ts
CHANGED
|
@@ -29,8 +29,16 @@ export interface MetalKernelSpec {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export interface MetalKernelCall {
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
//
|
|
537
|
-
// (
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
|
|
541
|
-
|
|
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
|
|
611
|
-
//
|
|
612
|
-
//
|
|
613
|
-
//
|
|
614
|
-
//
|
|
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
|
+
}
|
package/src/model/gemma4-base.ts
CHANGED
|
@@ -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(
|
|
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
|
}
|