@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.
@@ -0,0 +1,299 @@
1
+ // Quantizer-facing offline weight-transform seam. Recipe math lives in
2
+ // rotate.ts; this module owns planning, family adaptation, and tensor ownership
3
+ // so the artifact writer has one contract for every fold implementation.
4
+
5
+ import type { ModelConfig } from "@mlx-bun/inference/artifacts";
6
+ import type { MlxArray } from "@mlx-bun/mlx/array";
7
+ import type { Weights } from "@mlx-bun/inference";
8
+ import {
9
+ foldLlamaWeights,
10
+ planQwen35Fold,
11
+ planQwenMtpFold,
12
+ QwenFoldContext,
13
+ type FoldGeometry,
14
+ type FoldOptions,
15
+ type QwenFoldOptions,
16
+ type QwenFoldPlan,
17
+ } from "./rotate";
18
+
19
+ /** Serialized alongside a quantized artifact so a folded basis is a property
20
+ * of the artifact, not an out-of-band experiment-script convention. */
21
+ export interface WeightTransformMetadata {
22
+ id: string;
23
+ seed: number;
24
+ family: string;
25
+ deviations: string[];
26
+ }
27
+
28
+ /** Pure output plan. `sourceByOutput` makes cloning/untie operations explicit:
29
+ * most outputs map to themselves; a synthesized head maps to its embedding. */
30
+ export interface WeightTransformPlan {
31
+ readonly id: string;
32
+ readonly outputNames: readonly string[];
33
+ readonly sourceByOutput: ReadonlyMap<string, string>;
34
+ readonly untieWordEmbeddings: boolean;
35
+ readonly metadata: WeightTransformMetadata;
36
+ }
37
+
38
+ /** Per-run executor. `apply` returns a lazy, owned array and never disposes
39
+ * `source`; the quantizer owns the result and the Weights object owns source. */
40
+ export interface WeightTransformContext {
41
+ apply(outputName: string, source: MlxArray): MlxArray;
42
+ dispose(): void;
43
+ }
44
+
45
+ /** Common seam for the Llama, Qwen3.5 trunk, and Qwen MTP fold recipes. */
46
+ export interface WeightTransform {
47
+ readonly id: string;
48
+ /** Whether this transform's recipe reads a checkpoint with these tensor names and this config. */
49
+ accepts(names: readonly string[], config: ModelConfig): boolean;
50
+ /** Name/config analysis only: no mlx arrays, model load, or device work. */
51
+ plan(names: readonly string[], config: ModelConfig): WeightTransformPlan;
52
+ createContext(weights: Weights, plan: WeightTransformPlan): WeightTransformContext;
53
+ }
54
+
55
+ export interface LlamaWeightTransformPlan extends WeightTransformPlan {
56
+ readonly kind: "llama";
57
+ readonly geometry: FoldGeometry;
58
+ readonly options: FoldOptions;
59
+ }
60
+
61
+ export interface QwenWeightTransformPlan extends WeightTransformPlan {
62
+ readonly kind: "qwen3_5" | "qwen3_5_mtp";
63
+ readonly hiddenSize: number;
64
+ readonly seed: number;
65
+ readonly fold: QwenFoldPlan;
66
+ }
67
+
68
+ function sourceMap(names: readonly string[]): Map<string, string> {
69
+ return new Map(names.map((name) => [name, name]));
70
+ }
71
+
72
+ function assertPow2(n: number, what: string): void {
73
+ if (n < 2 || (n & (n - 1)) !== 0)
74
+ throw new Error(
75
+ `rotation fold: ${what}=${n} is not a power of two — the Kronecker ` +
76
+ "Hadamard path is not implemented",
77
+ );
78
+ }
79
+
80
+ /** Pure Llama fold plan. Array work remains inside createContext/apply. */
81
+ export function planLlamaWeightTransform(
82
+ names: readonly string[],
83
+ config: ModelConfig,
84
+ options: FoldOptions,
85
+ ): LlamaWeightTransformPlan {
86
+ const has = new Set(names);
87
+ for (const required of ["model.embed_tokens.weight", "model.norm.weight"])
88
+ if (!has.has(required)) throw new Error(`rotation fold: missing tensor ${required}`);
89
+ if (has.has("lm_head.weight"))
90
+ throw new Error("rotation fold: llama adapter currently requires tied embeddings");
91
+
92
+ for (let i = 0; i < config.text.numHiddenLayers; i++) {
93
+ const layer = `model.layers.${i}`;
94
+ for (const required of [
95
+ `${layer}.input_layernorm.weight`,
96
+ `${layer}.post_attention_layernorm.weight`,
97
+ `${layer}.self_attn.q_proj.weight`,
98
+ `${layer}.self_attn.k_proj.weight`,
99
+ `${layer}.self_attn.v_proj.weight`,
100
+ `${layer}.self_attn.o_proj.weight`,
101
+ `${layer}.mlp.gate_proj.weight`,
102
+ `${layer}.mlp.up_proj.weight`,
103
+ `${layer}.mlp.down_proj.weight`,
104
+ ]) {
105
+ if (!has.has(required)) throw new Error(`rotation fold: missing tensor ${required}`);
106
+ }
107
+ }
108
+
109
+ const geometry: FoldGeometry = {
110
+ numLayers: config.text.numHiddenLayers,
111
+ hiddenSize: config.text.hiddenSize,
112
+ numHeads: config.text.numAttentionHeads,
113
+ numKvHeads: config.text.numKeyValueHeads,
114
+ headDim: config.text.headDim,
115
+ };
116
+ if (options.r1 ?? true) assertPow2(geometry.hiddenSize, "hidden_size");
117
+ if (options.r2 ?? true) assertPow2(geometry.headDim, "head_dim");
118
+
119
+ const outputNames = [...names, "lm_head.weight"];
120
+ const sourceByOutput = sourceMap(names);
121
+ sourceByOutput.set("lm_head.weight", "model.embed_tokens.weight");
122
+ return {
123
+ kind: "llama",
124
+ id: "rotation.llama",
125
+ outputNames,
126
+ sourceByOutput,
127
+ untieWordEmbeddings: true,
128
+ geometry,
129
+ options: { ...options },
130
+ metadata: {
131
+ id: "rotation.llama",
132
+ seed: options.seed,
133
+ family: "llama",
134
+ deviations: [
135
+ "no-embedding-mean-centering",
136
+ "no-R4-downproj-input-fold",
137
+ "no-R3",
138
+ "gamma-kept-in-module-as-ones (eps preserved)",
139
+ "fold-precision-f32",
140
+ ],
141
+ },
142
+ };
143
+ }
144
+
145
+ function createLlamaContext(
146
+ weights: Weights,
147
+ plan: LlamaWeightTransformPlan,
148
+ ): WeightTransformContext {
149
+ const folded = foldLlamaWeights(weights, plan.geometry, plan.options);
150
+ const pending = new Map(folded.tensors.map((tensor) => [tensor.name, tensor.array]));
151
+ return {
152
+ apply(outputName, source) {
153
+ const transformed = pending.get(outputName);
154
+ if (transformed) {
155
+ pending.delete(outputName);
156
+ return transformed;
157
+ }
158
+ // Preserve family-specific tensors outside the fold corridor.
159
+ return source.astype(source.dtype);
160
+ },
161
+ dispose() {
162
+ for (const array of pending.values()) array.dispose();
163
+ pending.clear();
164
+ },
165
+ };
166
+ }
167
+
168
+ function qwenTransformPlan(
169
+ names: readonly string[],
170
+ config: ModelConfig,
171
+ seed: number,
172
+ companion: boolean,
173
+ prefix?: string,
174
+ ): QwenWeightTransformPlan {
175
+ const fold = companion
176
+ ? planQwenMtpFold(names)
177
+ : planQwen35Fold(
178
+ names,
179
+ prefix ?? (names.includes("language_model.model.norm.weight") ? "language_model." : ""),
180
+ );
181
+ const outputNames = [...names];
182
+ const sourceByOutput = sourceMap(names);
183
+ if (fold.extraHead) {
184
+ outputNames.push(fold.extraHead.name);
185
+ sourceByOutput.set(fold.extraHead.name, fold.extraHead.from);
186
+ }
187
+ const family = companion ? "qwen3_5_mtp" : "qwen3_5";
188
+ return {
189
+ kind: family,
190
+ id: `rotation.${family}`,
191
+ outputNames,
192
+ sourceByOutput,
193
+ untieWordEmbeddings: fold.extraHead !== null,
194
+ hiddenSize: config.text.hiddenSize,
195
+ seed,
196
+ fold,
197
+ metadata: {
198
+ id: `rotation.${family}`,
199
+ seed,
200
+ family,
201
+ deviations: [...fold.deviations],
202
+ },
203
+ };
204
+ }
205
+
206
+ function createQwenContext(
207
+ weights: Weights,
208
+ plan: QwenWeightTransformPlan,
209
+ ): WeightTransformContext {
210
+ // Lazy mode composes into the quantizer's existing writer. The experiment
211
+ // script keeps QwenFoldContext's eager default because it releases shards.
212
+ const context = new QwenFoldContext(
213
+ weights,
214
+ plan.hiddenSize,
215
+ plan.seed,
216
+ plan.fold.gammaNames,
217
+ false,
218
+ );
219
+ return {
220
+ apply(outputName, source) {
221
+ const extra = plan.fold.extraHead;
222
+ if (extra && outputName === extra.name)
223
+ return context.apply({ kind: "input", gamma: extra.gamma }, source);
224
+ const op = plan.fold.ops.get(outputName);
225
+ if (!op) throw new Error(`weight transform ${plan.id}: unplanned output ${outputName}`);
226
+ return context.apply(op, source);
227
+ },
228
+ dispose: () => context.dispose(),
229
+ };
230
+ }
231
+
232
+ /** A plan produced by the transform `id`, or a loud refusal: plans are not interchangeable. */
233
+ function ownPlan<T extends WeightTransformPlan>(id: string, plan: WeightTransformPlan): T {
234
+ if (plan.id !== id) throw new Error(`weight transform ${id} received plan ${plan.id}`);
235
+ return plan as T;
236
+ }
237
+
238
+ /** The Qwen3.5 MTP companion's tensors: the fold recipe recognizes it by schema, not by config. */
239
+ const isQwenMtpCompanion = (names: readonly string[]): boolean =>
240
+ names.includes("fc.weight") && names.includes("pre_fc_norm_hidden.weight");
241
+
242
+ /** `model_type`s each fold recipe reads. A recipe declares its own; a config outside them is refused. */
243
+ const llamaModelTypes: readonly string[] = ["llama"];
244
+ const qwen35ModelTypes: readonly string[] = ["qwen3_5"];
245
+
246
+ /** Llama-family γ+R1+R2 adapter. */
247
+ export function llamaWeightTransform(options: FoldOptions): WeightTransform {
248
+ const id = "rotation.llama";
249
+ return {
250
+ id,
251
+ accepts: (names, config) => llamaModelTypes.includes(config.modelType) && !isQwenMtpCompanion(names),
252
+ plan: (names, config) => planLlamaWeightTransform(names, config, options),
253
+ createContext: (weights, plan) => createLlamaContext(weights, ownPlan<LlamaWeightTransformPlan>(id, plan)),
254
+ };
255
+ }
256
+
257
+ /** Qwen3.5 trunk/VL adapter (R1 only because of the attention output gate). */
258
+ export function qwen35WeightTransform(options: QwenFoldOptions): WeightTransform {
259
+ const id = "rotation.qwen3_5";
260
+ return {
261
+ id,
262
+ accepts: (names, config) => qwen35ModelTypes.includes(config.modelType) && !isQwenMtpCompanion(names),
263
+ plan: (names, config) => qwenTransformPlan(names, config, options.seed, false, options.prefix),
264
+ createContext: (weights, plan) => createQwenContext(weights, ownPlan<QwenWeightTransformPlan>(id, plan)),
265
+ };
266
+ }
267
+
268
+ /** Qwen3.5 MTP companion adapter. Must use the same seed as its trunk. */
269
+ export function qwenMtpWeightTransform(seed: number): WeightTransform {
270
+ const id = "rotation.qwen3_5_mtp";
271
+ return {
272
+ id,
273
+ accepts: names => isQwenMtpCompanion(names),
274
+ plan: (names, config) => qwenTransformPlan(names, config, seed, true),
275
+ createContext: (weights, plan) => createQwenContext(weights, ownPlan<QwenWeightTransformPlan>(id, plan)),
276
+ };
277
+ }
278
+
279
+ /** Select the adapter that accepts the source's tensor schema and config; each declares its own. */
280
+ export function automaticRotationWeightTransform(
281
+ options: FoldOptions & { prefix?: string },
282
+ ): WeightTransform {
283
+ const adapters = [qwenMtpWeightTransform(options.seed), qwen35WeightTransform(options), llamaWeightTransform(options)];
284
+ const owner = (id: string): WeightTransform => {
285
+ const adapter = adapters.find(item => item.id === id);
286
+ if (!adapter) throw new Error(`rotation transform: invalid plan ${id}`);
287
+ return adapter;
288
+ };
289
+ return {
290
+ id: "rotation.auto",
291
+ accepts: (names, config) => adapters.some(adapter => adapter.accepts(names, config)),
292
+ plan(names, config) {
293
+ const adapter = adapters.find(item => item.accepts(names, config));
294
+ if (!adapter) throw new Error(`rotation transform: unsupported model_type ${config.modelType}`);
295
+ return adapter.plan(names, config);
296
+ },
297
+ createContext: (weights, plan) => owner(plan.id).createContext(weights, plan),
298
+ };
299
+ }