@mlx-bun/quantize 0.0.0-stage → 1.0.0
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/LICENSE +21 -0
- package/README.md +56 -2
- package/package.json +44 -4
- package/src/allocator.ts +628 -0
- package/src/atomic-output.ts +38 -0
- package/src/calibration.ts +182 -0
- package/src/config-writer.ts +109 -0
- package/src/convert.ts +79 -0
- package/src/drafter.ts +94 -0
- package/src/index.ts +86 -0
- package/src/quantizer.ts +691 -0
- package/src/qwen35-hf-fold.ts +103 -0
- package/src/rotate.ts +610 -0
- package/src/sensitivity.ts +216 -0
- package/src/trellis-allocation.ts +111 -0
- package/src/trellis-encoder.ts +328 -0
- package/src/trellis-quantizer.ts +405 -0
- package/src/trellis.ts +570 -0
- package/src/weight-transform.ts +299 -0
package/src/rotate.ts
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
// Rotation folding for weight quantization (TurboQuant weights, W0/W2).
|
|
2
|
+
//
|
|
3
|
+
// Folds an orthogonal rotation offline into producer/consumer weight pairs so
|
|
4
|
+
// weights are quantized in a basis where outlier channels are smeared into
|
|
5
|
+
// near-Gaussian marginals. Recipe, fold table, and the decided deviations from
|
|
6
|
+
// the QuaRot/SpinQuant references live in `02d723a:docs/design/turboquant.md` —
|
|
7
|
+
// notably: γ-fold FIRST (R1 only commutes with gain-free RMSNorm), SpinQuant's
|
|
8
|
+
// fully-offline per-head R2 (NOT QuaRot's, which needs a runtime op), NO R4
|
|
9
|
+
// half-fold on down_proj's input, NO embedding mean-centering (not an exact
|
|
10
|
+
// invariance for RMSNorm models; our gate is logit parity).
|
|
11
|
+
//
|
|
12
|
+
// R1 = diag(s)·H_n/√n (randomized Hadamard, QuIP#-style), R2 = per-layer
|
|
13
|
+
// diag(s₂)·H_d/√d over head_dim. Both require power-of-two dims (mlx
|
|
14
|
+
// hadamard_transform); the Kronecker path for other sizes is not implemented
|
|
15
|
+
// yet — callers get a clear throw. Folds run in f32 (weights are bf16; the
|
|
16
|
+
// references' f64 is fp16-era caution) and cast back to bf16 at the end.
|
|
17
|
+
//
|
|
18
|
+
// Memory discipline matches the quantizer: every returned array is a LAZY
|
|
19
|
+
// graph over the source mmap — the safetensors writer materializes one tensor
|
|
20
|
+
// at a time at insert.
|
|
21
|
+
|
|
22
|
+
import type { MlxArray } from "@mlx-bun/mlx/array";
|
|
23
|
+
import { MlxArray as Arr } from "@mlx-bun/mlx/array";
|
|
24
|
+
import { Dtype } from "@mlx-bun/mlx/ffi";
|
|
25
|
+
import * as ops from "@mlx-bun/mlx/ops";
|
|
26
|
+
import type { Weights } from "@mlx-bun/inference";
|
|
27
|
+
import type { NamedTensor } from "@mlx-bun/inference/artifacts";
|
|
28
|
+
|
|
29
|
+
/** Deterministic ±1 sign vector — splitmix32 over (seed, lane), so a given
|
|
30
|
+
* (seed, n, lane) always yields the same signs on every machine. `lane`
|
|
31
|
+
* separates R1 from each layer's R2 without sign reuse. */
|
|
32
|
+
export function signVector(seed: number, n: number, lane: number): Float32Array {
|
|
33
|
+
let state = (seed ^ Math.imul(lane + 1, 0x9e3779b9)) >>> 0;
|
|
34
|
+
const out = new Float32Array(n);
|
|
35
|
+
for (let i = 0; i < n; i++) {
|
|
36
|
+
state = (state + 0x9e3779b9) >>> 0;
|
|
37
|
+
let z = state;
|
|
38
|
+
z = Math.imul(z ^ (z >>> 16), 0x21f0aaad);
|
|
39
|
+
z = Math.imul(z ^ (z >>> 15), 0x735a2d97);
|
|
40
|
+
z ^= z >>> 15;
|
|
41
|
+
out[i] = (z & 1) === 0 ? 1 : -1;
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function assertPow2(n: number, what: string): void {
|
|
47
|
+
if (n < 2 || (n & (n - 1)) !== 0)
|
|
48
|
+
throw new Error(
|
|
49
|
+
`rotation fold: ${what}=${n} is not a power of two — the Kronecker ` +
|
|
50
|
+
`Hadamard path is not implemented`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Dispose every argument; returns nothing. Chain hygiene for lazy folds. */
|
|
55
|
+
function drop(...arrays: MlxArray[]): void {
|
|
56
|
+
for (const a of arrays) a.dispose();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** x @ (diag(vec)·H/√n) along the LAST axis, in f32. `vec` carries the R1/R2
|
|
60
|
+
* signs, optionally pre-multiplied with a folded γ. Input may be any float
|
|
61
|
+
* dtype; output is f32 (caller casts once at the end of its chain). */
|
|
62
|
+
function foldLastAxis(x: MlxArray, vec: MlxArray): MlxArray {
|
|
63
|
+
const n = x.shape[x.shape.length - 1]!;
|
|
64
|
+
const f32 = x.astype(Dtype.float32);
|
|
65
|
+
const scaled = ops.mul(f32, vec);
|
|
66
|
+
const h = ops.hadamardTransform(scaled, 1 / Math.sqrt(n));
|
|
67
|
+
drop(f32, scaled);
|
|
68
|
+
return h;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** W' = W @ R1 (input-dim fold; readers of the residual stream). */
|
|
72
|
+
function foldInputDim(w: MlxArray, vec: MlxArray): MlxArray {
|
|
73
|
+
return foldLastAxis(w, vec);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** W' = R1ᵀ @ W (output-dim fold; writers of the residual stream). H is
|
|
77
|
+
* symmetric (Sylvester), so this is the last-axis fold applied to Wᵀ. */
|
|
78
|
+
function foldOutputDim(w: MlxArray, vec: MlxArray): MlxArray {
|
|
79
|
+
const t = ops.transposeAxes(w, [1, 0]);
|
|
80
|
+
const h = foldLastAxis(t, vec);
|
|
81
|
+
const back = ops.transposeAxes(h, [1, 0]);
|
|
82
|
+
drop(t, h);
|
|
83
|
+
return back;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Per-head R2 on the last axis: reshape [..., heads·d] → [..., heads, d],
|
|
87
|
+
* fold, reshape back. blockdiag(R2) with the same R2 for every head. */
|
|
88
|
+
function foldHeadsLastAxis(x: MlxArray, s2: MlxArray, headDim: number): MlxArray {
|
|
89
|
+
const shape = x.shape;
|
|
90
|
+
const last = shape[shape.length - 1]!;
|
|
91
|
+
const heads = last / headDim;
|
|
92
|
+
if (!Number.isInteger(heads))
|
|
93
|
+
throw new Error(`rotation fold: last axis ${last} not divisible by head_dim ${headDim}`);
|
|
94
|
+
const split = ops.reshape(x, [...shape.slice(0, -1), heads, headDim]);
|
|
95
|
+
const h = foldLastAxis(split, s2);
|
|
96
|
+
const merged = ops.reshape(h, shape);
|
|
97
|
+
drop(split, h);
|
|
98
|
+
return merged;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface FoldGeometry {
|
|
102
|
+
numLayers: number;
|
|
103
|
+
hiddenSize: number;
|
|
104
|
+
numHeads: number;
|
|
105
|
+
numKvHeads: number;
|
|
106
|
+
headDim: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface FoldOptions {
|
|
110
|
+
seed: number;
|
|
111
|
+
/** Apply the R1 residual-stream fold (default true). */
|
|
112
|
+
r1?: boolean;
|
|
113
|
+
/** Apply the per-layer SpinQuant-style R2 v/o fold (default true). */
|
|
114
|
+
r2?: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface FoldResult {
|
|
118
|
+
tensors: NamedTensor[];
|
|
119
|
+
/** Provenance for the sidecar: what was folded, with which signs. */
|
|
120
|
+
meta: {
|
|
121
|
+
seed: number;
|
|
122
|
+
r1: boolean;
|
|
123
|
+
r2: boolean;
|
|
124
|
+
hiddenSize: number;
|
|
125
|
+
headDim: number;
|
|
126
|
+
deviations: string[];
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Lane ids for signVector: R1 is lane 0, layer i's R2 is lane i+1. */
|
|
131
|
+
const R1_LANE = 0;
|
|
132
|
+
|
|
133
|
+
/** R1ᵀ·b for a 1-D bias vector: bᵀ·R1 holds the same numbers. */
|
|
134
|
+
function foldBias(b: MlxArray, vec: MlxArray): MlxArray {
|
|
135
|
+
return foldLastAxis(b, vec);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Fold a Llama-family model (tensor names model.embed_tokens / model.layers.N.
|
|
140
|
+
* {input_layernorm, post_attention_layernorm, self_attn.{q,k,v,o}_proj,
|
|
141
|
+
* mlp.{gate,up,down}_proj} / model.norm) per the turboquant-weights recipe:
|
|
142
|
+
* untie → γ-fold → R1 → R2. Returns lazy bf16 tensors for the writer, with
|
|
143
|
+
* norm gains replaced by ones and a separate folded lm_head (untied).
|
|
144
|
+
*/
|
|
145
|
+
export function foldLlamaWeights(
|
|
146
|
+
weights: Weights,
|
|
147
|
+
geo: FoldGeometry,
|
|
148
|
+
opts: FoldOptions,
|
|
149
|
+
): FoldResult {
|
|
150
|
+
const r1 = opts.r1 ?? true;
|
|
151
|
+
const r2 = opts.r2 ?? true;
|
|
152
|
+
const { numLayers, hiddenSize, headDim } = geo;
|
|
153
|
+
if (r1) assertPow2(hiddenSize, "hidden_size");
|
|
154
|
+
if (r2) assertPow2(headDim, "head_dim");
|
|
155
|
+
|
|
156
|
+
const required = ["model.embed_tokens.weight", "model.norm.weight"];
|
|
157
|
+
for (const name of required)
|
|
158
|
+
if (!weights.has(name)) throw new Error(`rotation fold: missing tensor ${name}`);
|
|
159
|
+
if (weights.has("lm_head.weight"))
|
|
160
|
+
throw new Error(
|
|
161
|
+
"rotation fold: model already has an untied lm_head — the tied-embedding " +
|
|
162
|
+
"untie step in this fold would clobber it; teach foldLlamaWeights the " +
|
|
163
|
+
"untied case before folding this model",
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// R1 signs (f32 leaf, shared by every R1 fold site). With r1 off, the fold
|
|
167
|
+
// degenerates to identity via an all-ones "signs" vector and no Hadamard —
|
|
168
|
+
// handled by branching instead, to keep the off-arm byte-identical.
|
|
169
|
+
const s1 = Arr.fromFloat32(signVector(opts.seed, hiddenSize, R1_LANE), [hiddenSize]);
|
|
170
|
+
|
|
171
|
+
/** γ (bf16 [n]) → f32, optionally ⊙ signs — the per-column premultiplier. */
|
|
172
|
+
const gammaVec = (normName: string, withSigns: boolean): MlxArray => {
|
|
173
|
+
const g = weights.tensor(normName).astype(Dtype.float32);
|
|
174
|
+
if (!withSigns) return g;
|
|
175
|
+
const gs = ops.mul(g, s1);
|
|
176
|
+
g.dispose();
|
|
177
|
+
return gs;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** Finish a chain: cast to bf16 and drop the f32 head. */
|
|
181
|
+
const toBf16 = (x: MlxArray): MlxArray => {
|
|
182
|
+
const out = x.astype(Dtype.bfloat16);
|
|
183
|
+
x.dispose();
|
|
184
|
+
return out;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** Input-side reader fold: W ⊙ γcols then @R1 (or γ-only when r1 off). */
|
|
188
|
+
const readerFold = (w: MlxArray, vec: MlxArray): MlxArray => {
|
|
189
|
+
if (r1) return foldLastAxis(w, vec);
|
|
190
|
+
const f32 = w.astype(Dtype.float32);
|
|
191
|
+
const scaled = ops.mul(f32, vec);
|
|
192
|
+
f32.dispose();
|
|
193
|
+
return scaled;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const onesLike = (n: number): MlxArray => {
|
|
197
|
+
const ones = Arr.fromFloat32(new Float32Array(n).fill(1), [n]);
|
|
198
|
+
const out = ones.astype(Dtype.bfloat16);
|
|
199
|
+
ones.dispose();
|
|
200
|
+
return out;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const tensors: NamedTensor[] = [];
|
|
204
|
+
|
|
205
|
+
// Embeddings: write the residual stream → @R1, no γ. lm_head: read the
|
|
206
|
+
// residual after the final norm → γ_final then @R1 — from the SAME source
|
|
207
|
+
// tensor (untie), two independent lazy chains.
|
|
208
|
+
const embed = weights.tensor("model.embed_tokens.weight");
|
|
209
|
+
if (r1) {
|
|
210
|
+
tensors.push({ name: "model.embed_tokens.weight", array: toBf16(foldInputDim(embed, s1)) });
|
|
211
|
+
} else {
|
|
212
|
+
tensors.push({ name: "model.embed_tokens.weight", array: embed });
|
|
213
|
+
}
|
|
214
|
+
const gFinal = gammaVec("model.norm.weight", r1);
|
|
215
|
+
tensors.push({ name: "lm_head.weight", array: toBf16(readerFold(embed, gFinal)) });
|
|
216
|
+
gFinal.dispose();
|
|
217
|
+
tensors.push({ name: "model.norm.weight", array: onesLike(hiddenSize) });
|
|
218
|
+
|
|
219
|
+
for (let i = 0; i < numLayers; i++) {
|
|
220
|
+
const L = `model.layers.${i}`;
|
|
221
|
+
const s2 = r2
|
|
222
|
+
? Arr.fromFloat32(signVector(opts.seed, headDim, i + 1), [headDim])
|
|
223
|
+
: null;
|
|
224
|
+
|
|
225
|
+
const gIn = gammaVec(`${L}.input_layernorm.weight`, r1);
|
|
226
|
+
const gPost = gammaVec(`${L}.post_attention_layernorm.weight`, r1);
|
|
227
|
+
|
|
228
|
+
for (const p of ["q_proj", "k_proj"]) {
|
|
229
|
+
const w = weights.tensor(`${L}.self_attn.${p}.weight`);
|
|
230
|
+
tensors.push({ name: `${L}.self_attn.${p}.weight`, array: toBf16(readerFold(w, gIn)) });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// v_proj: reader fold on the input dim, then per-head R2ᵀ on the output
|
|
234
|
+
// dim (via the transposed layout, matching SpinQuant's apply order).
|
|
235
|
+
{
|
|
236
|
+
const w = weights.tensor(`${L}.self_attn.v_proj.weight`);
|
|
237
|
+
const folded = readerFold(w, gIn);
|
|
238
|
+
if (s2) {
|
|
239
|
+
const t = ops.transposeAxes(folded, [1, 0]);
|
|
240
|
+
const h = foldHeadsLastAxis(t, s2, headDim);
|
|
241
|
+
const back = ops.transposeAxes(h, [1, 0]);
|
|
242
|
+
drop(folded, t, h);
|
|
243
|
+
tensors.push({ name: `${L}.self_attn.v_proj.weight`, array: toBf16(back) });
|
|
244
|
+
} else {
|
|
245
|
+
tensors.push({ name: `${L}.self_attn.v_proj.weight`, array: toBf16(folded) });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// o_proj: per-head R2 on the input dim, R1ᵀ on the output dim. No γ (its
|
|
250
|
+
// input is the attention context, not a norm output). NO full-Hadamard
|
|
251
|
+
// input fold — that is QuaRot's online-op pairing, excluded by design.
|
|
252
|
+
{
|
|
253
|
+
const w = weights.tensor(`${L}.self_attn.o_proj.weight`);
|
|
254
|
+
let cur: MlxArray = w;
|
|
255
|
+
const owned: MlxArray[] = [];
|
|
256
|
+
if (s2) {
|
|
257
|
+
cur = foldHeadsLastAxis(cur, s2, headDim);
|
|
258
|
+
owned.push(cur);
|
|
259
|
+
}
|
|
260
|
+
if (r1) {
|
|
261
|
+
cur = foldOutputDim(cur, s1);
|
|
262
|
+
owned.push(cur);
|
|
263
|
+
}
|
|
264
|
+
if (owned.length === 0) {
|
|
265
|
+
tensors.push({ name: `${L}.self_attn.o_proj.weight`, array: w });
|
|
266
|
+
} else {
|
|
267
|
+
const final = toBf16(cur);
|
|
268
|
+
drop(...owned.slice(0, -1));
|
|
269
|
+
tensors.push({ name: `${L}.self_attn.o_proj.weight`, array: final });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
for (const p of ["gate_proj", "up_proj"]) {
|
|
274
|
+
const w = weights.tensor(`${L}.mlp.${p}.weight`);
|
|
275
|
+
tensors.push({ name: `${L}.mlp.${p}.weight`, array: toBf16(readerFold(w, gPost)) });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// down_proj: R1ᵀ on the output dim ONLY. Deliberately no input-dim
|
|
279
|
+
// Hadamard — the references' rotate_mlp_output folds the offline half of
|
|
280
|
+
// R4 here, which is only valid with their runtime activation Hadamard.
|
|
281
|
+
{
|
|
282
|
+
const w = weights.tensor(`${L}.mlp.down_proj.weight`);
|
|
283
|
+
if (r1) {
|
|
284
|
+
tensors.push({ name: `${L}.mlp.down_proj.weight`, array: toBf16(foldOutputDim(w, s1)) });
|
|
285
|
+
} else {
|
|
286
|
+
tensors.push({ name: `${L}.mlp.down_proj.weight`, array: w });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// γ is folded into consumers in EVERY mode (r1 only decides whether the
|
|
291
|
+
// signs ride along) — the module gains are always ones afterward.
|
|
292
|
+
tensors.push({ name: `${L}.input_layernorm.weight`, array: onesLike(hiddenSize) });
|
|
293
|
+
tensors.push({ name: `${L}.post_attention_layernorm.weight`, array: onesLike(hiddenSize) });
|
|
294
|
+
|
|
295
|
+
drop(gIn, gPost);
|
|
296
|
+
s2?.dispose();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
s1.dispose();
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
tensors,
|
|
303
|
+
meta: {
|
|
304
|
+
seed: opts.seed,
|
|
305
|
+
r1,
|
|
306
|
+
r2,
|
|
307
|
+
hiddenSize,
|
|
308
|
+
headDim,
|
|
309
|
+
deviations: [
|
|
310
|
+
"no-embedding-mean-centering",
|
|
311
|
+
"no-R4-downproj-input-fold",
|
|
312
|
+
"no-R3",
|
|
313
|
+
"gamma-kept-in-module-as-ones (eps preserved)",
|
|
314
|
+
"fold-precision-f32",
|
|
315
|
+
],
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// qwen3_5 (Qwen 3.8 family): hybrid DeltaNet/full-attention VL trunk + MTP
|
|
322
|
+
// companion. R1-ONLY (+γ): the attention output gate (`o_proj(out · σ(gate))`,
|
|
323
|
+
// elementwise in head space) does not commute with a per-head rotation, so R2
|
|
324
|
+
// is architecturally off for this family — recorded in the design doc.
|
|
325
|
+
//
|
|
326
|
+
// Residual corridors (`02d723a:docs/design/turboquant.md`, W1 map):
|
|
327
|
+
// readers (@R1 input dim, γ premultiplied): q/k/v_proj,
|
|
328
|
+
// linear_attn.in_proj_{qkv,z,b,a}, mlp.gate/up_proj, lm_head
|
|
329
|
+
// writers (R1ᵀ output dim): self_attn.o_proj, linear_attn.out_proj,
|
|
330
|
+
// mlp.down_proj, vision_tower.merger.linear_fc2 (weight + bias — the ONLY
|
|
331
|
+
// place the vision tower touches the residual basis; deepstack is empty
|
|
332
|
+
// for qwen3_5 and not ported)
|
|
333
|
+
// untouched (internal bases): q/k_norm, linear_attn.{norm,A_log,conv1d,
|
|
334
|
+
// dt_bias}, the whole pre-merger vision tower
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
export interface QwenFoldOptions {
|
|
338
|
+
seed: number;
|
|
339
|
+
/** Tensor-name prefix of the language model ("language_model." for the VL
|
|
340
|
+
* wrapper trunk; "" would be a bare text model). */
|
|
341
|
+
prefix?: string;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Fold a qwen3_5 VL trunk (or text-only model when it has no vision tower).
|
|
345
|
+
* Handles both tied (clones an untied folded lm_head) and untied heads. */
|
|
346
|
+
export type FoldOp =
|
|
347
|
+
| { kind: "passthrough" }
|
|
348
|
+
| { kind: "ones"; n: number }
|
|
349
|
+
/** Reader: fold the last (input) axis; γ (by tensor name) premultiplied. */
|
|
350
|
+
| { kind: "input"; gamma?: string }
|
|
351
|
+
/** Writer: R1ᵀ on the output axis of a 2-D [out, in] weight. */
|
|
352
|
+
| { kind: "output" }
|
|
353
|
+
/** Writer bias: R1ᵀ on a 1-D vector. */
|
|
354
|
+
| { kind: "bias" }
|
|
355
|
+
/** MTP fc [H, 2H]: per-block input folds (embed γ, hidden γ) + output fold. */
|
|
356
|
+
| { kind: "mtp-fc"; gammaEmbed: string; gammaHidden: string };
|
|
357
|
+
|
|
358
|
+
export interface QwenFoldPlan {
|
|
359
|
+
ops: Map<string, FoldOp>;
|
|
360
|
+
/** Names of the γ tensors the executor must preload (tiny, held for the
|
|
361
|
+
* whole walk so shard release never invalidates them). */
|
|
362
|
+
gammaNames: string[];
|
|
363
|
+
/** Tied-embedding case: emit an extra folded lm_head cloned from `from`. */
|
|
364
|
+
extraHead: { name: string; from: string; gamma: string } | null;
|
|
365
|
+
deviations: string[];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Classify every tensor of a qwen3_5 VL trunk (or its MTP companion) into a
|
|
370
|
+
* FoldOp — pure name analysis, no arrays. R1-ONLY (+γ): the attention output
|
|
371
|
+
* gate (`o_proj(out · σ(gate))`, elementwise in head space) does not commute
|
|
372
|
+
* with a per-head rotation, so R2 is architecturally off for this family.
|
|
373
|
+
* Corridor map: `02d723a:docs/design/turboquant.md` §W1.
|
|
374
|
+
*/
|
|
375
|
+
export function planQwen35Fold(names: readonly string[], prefix = "language_model."): QwenFoldPlan {
|
|
376
|
+
const P = prefix;
|
|
377
|
+
const has = new Set(names);
|
|
378
|
+
const ops_ = new Map<string, FoldOp>();
|
|
379
|
+
const gammaNames = new Set<string>();
|
|
380
|
+
const finalNorm = `${P}model.norm.weight`;
|
|
381
|
+
const embed = `${P}model.embed_tokens.weight`;
|
|
382
|
+
const head = `${P}lm_head.weight`;
|
|
383
|
+
if (!has.has(embed) || !has.has(finalNorm))
|
|
384
|
+
throw new Error(`qwen fold plan: missing ${embed} or ${finalNorm}`);
|
|
385
|
+
|
|
386
|
+
ops_.set(embed, { kind: "input" });
|
|
387
|
+
ops_.set(finalNorm, { kind: "ones", n: 0 });
|
|
388
|
+
gammaNames.add(finalNorm);
|
|
389
|
+
let extraHead: QwenFoldPlan["extraHead"] = null;
|
|
390
|
+
if (has.has(head)) ops_.set(head, { kind: "input", gamma: finalNorm });
|
|
391
|
+
else extraHead = { name: head, from: embed, gamma: finalNorm };
|
|
392
|
+
|
|
393
|
+
for (let i = 0; ; i++) {
|
|
394
|
+
const L = `${P}model.layers.${i}`;
|
|
395
|
+
const inNorm = `${L}.input_layernorm.weight`;
|
|
396
|
+
if (!has.has(inNorm)) break;
|
|
397
|
+
const postNorm = `${L}.post_attention_layernorm.weight`;
|
|
398
|
+
ops_.set(inNorm, { kind: "ones", n: 0 });
|
|
399
|
+
ops_.set(postNorm, { kind: "ones", n: 0 });
|
|
400
|
+
gammaNames.add(inNorm);
|
|
401
|
+
gammaNames.add(postNorm);
|
|
402
|
+
|
|
403
|
+
const attnReaders = has.has(`${L}.self_attn.q_proj.weight`)
|
|
404
|
+
? ["self_attn.q_proj", "self_attn.k_proj", "self_attn.v_proj"]
|
|
405
|
+
: ["linear_attn.in_proj_qkv", "linear_attn.in_proj_z", "linear_attn.in_proj_b", "linear_attn.in_proj_a"];
|
|
406
|
+
const writer = attnReaders[0]!.startsWith("self_attn")
|
|
407
|
+
? "self_attn.o_proj" : "linear_attn.out_proj";
|
|
408
|
+
for (const r of attnReaders) ops_.set(`${L}.${r}.weight`, { kind: "input", gamma: inNorm });
|
|
409
|
+
ops_.set(`${L}.${writer}.weight`, { kind: "output" });
|
|
410
|
+
for (const r of ["mlp.gate_proj", "mlp.up_proj"])
|
|
411
|
+
ops_.set(`${L}.${r}.weight`, { kind: "input", gamma: postNorm });
|
|
412
|
+
ops_.set(`${L}.mlp.down_proj.weight`, { kind: "output" });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Vision: the merger's final projection is the ONLY vision→residual seam
|
|
416
|
+
// (deepstack is empty for qwen3_5). Everything else passes through bf16.
|
|
417
|
+
if (has.has("vision_tower.merger.linear_fc2.weight")) {
|
|
418
|
+
ops_.set("vision_tower.merger.linear_fc2.weight", { kind: "output" });
|
|
419
|
+
ops_.set("vision_tower.merger.linear_fc2.bias", { kind: "bias" });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
for (const n of names) if (!ops_.has(n)) ops_.set(n, { kind: "passthrough" });
|
|
423
|
+
return {
|
|
424
|
+
ops: ops_,
|
|
425
|
+
gammaNames: [...gammaNames],
|
|
426
|
+
extraHead,
|
|
427
|
+
deviations: [
|
|
428
|
+
"no-R2 (attn_output_gate does not commute with per-head rotation)",
|
|
429
|
+
"no-embedding-mean-centering", "no-R4-downproj-input-fold", "no-R3",
|
|
430
|
+
"gamma-kept-in-module-as-ones (eps preserved)", "fold-precision-f32",
|
|
431
|
+
"vision folded at merger.linear_fc2 only (deepstack empty)",
|
|
432
|
+
],
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Plan for the MTP companion (same seed/R1 as its trunk — shared residual
|
|
437
|
+
* basis). The final `norm` γ is DROPPED (→ ones): it feeds the shared trunk
|
|
438
|
+
* lm_head which already carries the trunk's final γ; draft logits see
|
|
439
|
+
* γ_trunk instead of γ_mtp — draft-quality-only (target path exact). */
|
|
440
|
+
export function planQwenMtpFold(names: readonly string[]): QwenFoldPlan {
|
|
441
|
+
const has = new Set(names);
|
|
442
|
+
const ops_ = new Map<string, FoldOp>();
|
|
443
|
+
const gammaNames = new Set<string>();
|
|
444
|
+
for (const req of ["fc.weight", "pre_fc_norm_embedding.weight", "pre_fc_norm_hidden.weight", "norm.weight"])
|
|
445
|
+
if (!has.has(req)) throw new Error(`mtp fold plan: missing ${req}`);
|
|
446
|
+
|
|
447
|
+
ops_.set("fc.weight", {
|
|
448
|
+
kind: "mtp-fc",
|
|
449
|
+
gammaEmbed: "pre_fc_norm_embedding.weight",
|
|
450
|
+
gammaHidden: "pre_fc_norm_hidden.weight",
|
|
451
|
+
});
|
|
452
|
+
gammaNames.add("pre_fc_norm_embedding.weight");
|
|
453
|
+
gammaNames.add("pre_fc_norm_hidden.weight");
|
|
454
|
+
ops_.set("pre_fc_norm_embedding.weight", { kind: "ones", n: 0 });
|
|
455
|
+
ops_.set("pre_fc_norm_hidden.weight", { kind: "ones", n: 0 });
|
|
456
|
+
ops_.set("norm.weight", { kind: "ones", n: 0 });
|
|
457
|
+
|
|
458
|
+
for (let i = 0; ; i++) {
|
|
459
|
+
const L = `layers.${i}`;
|
|
460
|
+
const inNorm = `${L}.input_layernorm.weight`;
|
|
461
|
+
if (!has.has(inNorm)) break;
|
|
462
|
+
const postNorm = `${L}.post_attention_layernorm.weight`;
|
|
463
|
+
ops_.set(inNorm, { kind: "ones", n: 0 });
|
|
464
|
+
ops_.set(postNorm, { kind: "ones", n: 0 });
|
|
465
|
+
gammaNames.add(inNorm);
|
|
466
|
+
gammaNames.add(postNorm);
|
|
467
|
+
for (const r of ["self_attn.q_proj", "self_attn.k_proj", "self_attn.v_proj"])
|
|
468
|
+
ops_.set(`${L}.${r}.weight`, { kind: "input", gamma: inNorm });
|
|
469
|
+
ops_.set(`${L}.self_attn.o_proj.weight`, { kind: "output" });
|
|
470
|
+
for (const r of ["mlp.gate_proj", "mlp.up_proj"])
|
|
471
|
+
ops_.set(`${L}.${r}.weight`, { kind: "input", gamma: postNorm });
|
|
472
|
+
ops_.set(`${L}.mlp.down_proj.weight`, { kind: "output" });
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
for (const n of names) if (!ops_.has(n)) ops_.set(n, { kind: "passthrough" });
|
|
476
|
+
return {
|
|
477
|
+
ops: ops_,
|
|
478
|
+
gammaNames: [...gammaNames],
|
|
479
|
+
extraHead: null,
|
|
480
|
+
deviations: [
|
|
481
|
+
"mtp-final-gamma-dropped (draft logits see trunk final γ — draft-quality-only)",
|
|
482
|
+
"no-R2", "fold-precision-f32",
|
|
483
|
+
],
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Executor state for a streaming qwen fold: owns s1 and the (tiny, owned-f32)
|
|
488
|
+
* γ table, so source shards can be released freely mid-walk. */
|
|
489
|
+
export class QwenFoldContext {
|
|
490
|
+
readonly #s1: MlxArray;
|
|
491
|
+
readonly #gammas = new Map<string, MlxArray>();
|
|
492
|
+
readonly #gammaTimesS1 = new Map<string, MlxArray>();
|
|
493
|
+
readonly hiddenSize: number;
|
|
494
|
+
readonly #eager: boolean;
|
|
495
|
+
|
|
496
|
+
constructor(
|
|
497
|
+
weights: Pick<Weights, "tensor">,
|
|
498
|
+
hiddenSize: number,
|
|
499
|
+
seed: number,
|
|
500
|
+
gammaNames: string[],
|
|
501
|
+
eager = true,
|
|
502
|
+
) {
|
|
503
|
+
assertPow2OrKron(hiddenSize);
|
|
504
|
+
this.hiddenSize = hiddenSize;
|
|
505
|
+
this.#eager = eager;
|
|
506
|
+
this.#s1 = Arr.fromFloat32(signVector(seed, hiddenSize, R1_LANE), [hiddenSize]);
|
|
507
|
+
for (const name of gammaNames) {
|
|
508
|
+
// Owned f32 copy. Streaming scripts evaluate it immediately before
|
|
509
|
+
// releasing source shards; the integrated quantizer keeps it lazy.
|
|
510
|
+
const g = weights.tensor(name).astype(Dtype.float32);
|
|
511
|
+
if (this.#eager) g.eval();
|
|
512
|
+
this.#gammas.set(name, g);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** The preloaded γ named `gamma`, read back: the gain every fold op naming it scales by. */
|
|
517
|
+
gain(gamma: string): Float32Array {
|
|
518
|
+
const g = this.#gammas.get(gamma);
|
|
519
|
+
if (!g) throw new Error(`fold context: γ ${gamma} not preloaded`);
|
|
520
|
+
return g.toFloat32();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
#vec(gamma?: string): MlxArray {
|
|
524
|
+
if (!gamma) return this.#s1;
|
|
525
|
+
let v = this.#gammaTimesS1.get(gamma);
|
|
526
|
+
if (!v) {
|
|
527
|
+
const g = this.#gammas.get(gamma);
|
|
528
|
+
if (!g) throw new Error(`fold context: γ ${gamma} not preloaded`);
|
|
529
|
+
v = ops.mul(g, this.#s1);
|
|
530
|
+
if (this.#eager) v.eval();
|
|
531
|
+
this.#gammaTimesS1.set(gamma, v);
|
|
532
|
+
}
|
|
533
|
+
return v;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Apply `op` to `src`, returning an EVALUATED bf16 array the caller owns.
|
|
537
|
+
* Never disposes src (the source shard map owns it). */
|
|
538
|
+
apply(op: FoldOp, src: MlxArray): MlxArray {
|
|
539
|
+
switch (op.kind) {
|
|
540
|
+
case "passthrough": {
|
|
541
|
+
// Copy out of the shard map so the shard can be released: astype to
|
|
542
|
+
// the same dtype is a cheap materializing copy.
|
|
543
|
+
const copy = src.astype(src.dtype);
|
|
544
|
+
if (this.#eager) copy.eval();
|
|
545
|
+
return copy;
|
|
546
|
+
}
|
|
547
|
+
case "ones": {
|
|
548
|
+
const n = src.shape[0]!;
|
|
549
|
+
const ones = Arr.fromFloat32(new Float32Array(n).fill(1), [n]);
|
|
550
|
+
const out = ones.astype(Dtype.bfloat16);
|
|
551
|
+
if (this.#eager) out.eval();
|
|
552
|
+
ones.dispose();
|
|
553
|
+
return out;
|
|
554
|
+
}
|
|
555
|
+
case "input": {
|
|
556
|
+
const h = foldLastAxis(src, this.#vec(op.gamma));
|
|
557
|
+
const out = h.astype(Dtype.bfloat16);
|
|
558
|
+
if (this.#eager) out.eval();
|
|
559
|
+
h.dispose();
|
|
560
|
+
return out;
|
|
561
|
+
}
|
|
562
|
+
case "output": {
|
|
563
|
+
const h = foldOutputDim(src, this.#s1);
|
|
564
|
+
const out = h.astype(Dtype.bfloat16);
|
|
565
|
+
if (this.#eager) out.eval();
|
|
566
|
+
h.dispose();
|
|
567
|
+
return out;
|
|
568
|
+
}
|
|
569
|
+
case "bias": {
|
|
570
|
+
const h = foldBias(src, this.#s1);
|
|
571
|
+
const out = h.astype(Dtype.bfloat16);
|
|
572
|
+
if (this.#eager) out.eval();
|
|
573
|
+
h.dispose();
|
|
574
|
+
return out;
|
|
575
|
+
}
|
|
576
|
+
case "mtp-fc": {
|
|
577
|
+
const H = this.hiddenSize;
|
|
578
|
+
const [b0, b1] = ops.split(src, [H], 1) as [MlxArray, MlxArray];
|
|
579
|
+
const f0 = foldLastAxis(b0, this.#vec(op.gammaEmbed));
|
|
580
|
+
const f1 = foldLastAxis(b1, this.#vec(op.gammaHidden));
|
|
581
|
+
const joined = ops.concatAxis([f0, f1], 1);
|
|
582
|
+
const h = foldOutputDim(joined, this.#s1);
|
|
583
|
+
const out = h.astype(Dtype.bfloat16);
|
|
584
|
+
if (this.#eager) out.eval();
|
|
585
|
+
drop(b0, b1, f0, f1, joined, h);
|
|
586
|
+
return out;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
dispose(): void {
|
|
592
|
+
this.#s1.dispose();
|
|
593
|
+
for (const g of this.#gammas.values()) g.dispose();
|
|
594
|
+
for (const v of this.#gammaTimesS1.values()) v.dispose();
|
|
595
|
+
this.#gammas.clear();
|
|
596
|
+
this.#gammaTimesS1.clear();
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** mlx hadamard_transform accepts n = m·2^k for m ∈ {1, 12, 20, 28} —
|
|
601
|
+
* equivalently: odd part ∈ {1, 3, 5, 7} with at least 4 | n when odd > 1
|
|
602
|
+
* (verified live at n=5120 = 20·256). */
|
|
603
|
+
function assertPow2OrKron(n: number): void {
|
|
604
|
+
let odd = n;
|
|
605
|
+
let twos = 0;
|
|
606
|
+
while (odd % 2 === 0) { odd /= 2; twos++; }
|
|
607
|
+
const ok = odd === 1 || ([3, 5, 7].includes(odd) && twos >= 2);
|
|
608
|
+
if (!ok)
|
|
609
|
+
throw new Error(`rotation fold: hidden_size ${n} is not m·2^k with m ∈ {1,12,20,28}`);
|
|
610
|
+
}
|