@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,691 @@
1
+ import type { QuantizedLinear } from "@mlx-bun/inference/layers";
2
+ // Native model quantizer: walk a model directory's weights, quantize every
3
+ // eligible Linear / embedding / lm_head weight to uniform affine N-bit, pass
4
+ // everything else through unchanged, and write a loadable quantized snapshot.
5
+ //
6
+ // No Python. The whole pipeline (load → dequantize-if-needed → cast bf16 →
7
+ // mx.quantize → save) runs through the mlx-c FFI on the CPU stream where the
8
+ // safetensors loader lives.
9
+ //
10
+ // Memory discipline: one module is materialized, quantized, written, and
11
+ // disposed at a time; the allocator cache is cleared every CACHE_CLEAR_EVERY
12
+ // modules so a large model never holds more than a few resident tensors.
13
+ //
14
+ // Uniform affine (every eligible weight gets the same bits/group_size) is the
15
+ // default. When `opts.targetBpw` is set, the OptiQ sensitivity-driven
16
+ // mixed-precision path runs: calibration → per-layer KL sensitivity → greedy
17
+ // knapsack → a heterogeneous per-module bit allocation fed to the same writer.
18
+
19
+ import { join } from "node:path";
20
+ import { mkdirSync } from "node:fs";
21
+ import { clearCache, Dtype } from "@mlx-bun/mlx/ffi";
22
+ import { cpuStream, type MlxArray } from "@mlx-bun/mlx/array";
23
+ import { quantize, dequantize } from "@mlx-bun/mlx/ops";
24
+ import { Weights } from "@mlx-bun/inference";
25
+ import { loadModelConfig, quantFor, type QuantizationConfig } from "@mlx-bun/inference/artifacts";
26
+ import { createModel } from "@mlx-bun/inference";
27
+ import { loadTokenizer } from "@mlx-bun/inference/input";
28
+ import {
29
+ writeShardedSafetensors,
30
+ type NamedTensor,
31
+ type WriteResult,
32
+ } from "@mlx-bun/inference/artifacts";
33
+ import {
34
+ buildQuantizationBlock,
35
+ writeQuantizedConfig,
36
+ type PerLayerEntry,
37
+ } from "./config-writer";
38
+ import { loadLlmCalibration } from "./calibration";
39
+ import { analyzeSensitivityExact, type SensitivityResult } from "./sensitivity";
40
+ import { optimizeMixedPrecision } from "./allocator";
41
+ import type {
42
+ WeightTransform,
43
+ WeightTransformContext,
44
+ WeightTransformPlan,
45
+ } from "./weight-transform";
46
+ import { writeAtomicDirectory } from "./atomic-output";
47
+
48
+ /** Quantize the allocator cache this often (in modules processed). */
49
+ const CACHE_CLEAR_EVERY = 16;
50
+
51
+ /** Bits used per group for affine scale + bias (one bf16 each = 32 bits). */
52
+ const AFFINE_GROUP_OVERHEAD_BITS = 32;
53
+
54
+ /** The floating dtypes `mlx_lm.convert --dtype` accepts. */
55
+ export type ConvertDtype = "float16" | "bfloat16" | "float32";
56
+ export const CONVERT_DTYPES: readonly ConvertDtype[] = ["float16", "bfloat16", "float32"];
57
+ const DTYPE_OF: Record<ConvertDtype, Dtype> = { float16: Dtype.float16, bfloat16: Dtype.bfloat16, float32: Dtype.float32 };
58
+ const FLOATING = new Set<Dtype>([Dtype.float16, Dtype.bfloat16, Dtype.float32, Dtype.float64]);
59
+
60
+ /** mlx-lm's per-model `cast_predicate`s, as one name rule: router/expert bias
61
+ * and state-space decay parameters stay in their source dtype. */
62
+ const KEEP_DTYPE = /e_score_correction_bias|expert_bias|A_log$|dt_bias$/;
63
+
64
+ /** Cast one floating tensor to `dtype` (lazy); anything else passes through. */
65
+ export function castTensor(name: string, array: MlxArray, dtype: ConvertDtype | undefined): MlxArray {
66
+ if (!dtype || KEEP_DTYPE.test(name) || !FLOATING.has(array.dtype) || array.dtype === DTYPE_OF[dtype]) return array;
67
+ return array.astype(DTYPE_OF[dtype], cpuStream);
68
+ }
69
+
70
+ export interface QuantizeOptions {
71
+ bits: 4 | 8;
72
+ groupSize: 32 | 64;
73
+ /** `mlx_lm.convert --dtype`: dtype of the non-quantized tensors and of the
74
+ * quantization scales/biases. Absent keeps bf16 scales/biases and the
75
+ * source dtype for everything else. */
76
+ dtype?: ConvertDtype;
77
+ /** Quantization scheme; v1 only exercises "affine". */
78
+ mode?: string;
79
+ /** Extra per-module gate on top of the shape-eligibility check: return
80
+ * false to keep a module full-precision even though its shape qualifies
81
+ * (e.g. the DeepSpec drafter's confidence_head — threshold-sensitive and
82
+ * tiny). Excluded modules are recorded as `false` in the quantization
83
+ * block (mlx's "not quantized" convention). Shape-ineligible modules
84
+ * never reach this. */
85
+ quantizePredicate?: (base: string, fullShape: number[]) => boolean;
86
+ /** Optional offline basis transform applied before quantization. */
87
+ weightTransform?: WeightTransform;
88
+
89
+ // --- mixed-precision (OptiQ sensitivity + knapsack) -------------------
90
+ // Setting targetBpw switches quantizeModelDir to the mixed path: it loads the
91
+ // model, runs calibration + per-layer KL sensitivity, knapsacks a per-module
92
+ // bit allocation, and feeds that heterogeneous map to the same writer.
93
+ /** Target bits-per-weight for sensitivity+knapsack mixed precision. */
94
+ targetBpw?: number;
95
+ /** Reference dtype for sensitivity scoring (e.g. "bf16"). Informational
96
+ * only — the native path dequantizes the running model's own weights as
97
+ * the self-contained bf16 source. */
98
+ reference?: string;
99
+ /** Candidate bit-widths the knapsack may choose from (e.g. [4, 8]). */
100
+ candidateBits?: number[];
101
+ /** Calibration data mixture spec ("optiq" or a JSONL path). */
102
+ calibrationMix?: string;
103
+ /** Number of calibration samples (forward passes per layer×bit probe). */
104
+ nCalibration?: number;
105
+ /** Dependency used to provide the loadable model for sensitivity sweeps. */
106
+ probeSource?: ProbeSource;
107
+ }
108
+
109
+ export interface PreparedProbe {
110
+ modelDir: string;
111
+ dispose(): void | Promise<void>;
112
+ }
113
+
114
+ /** Supplies a loadable sensitivity model without re-entering the top-level
115
+ * quantization orchestrator. Tests and callers may inject an existing probe. */
116
+ export interface ProbeSource {
117
+ prepare(
118
+ srcDir: string,
119
+ opts: QuantizeOptions,
120
+ onProgress?: (e: ProgressEvent) => void,
121
+ ): Promise<PreparedProbe>;
122
+ }
123
+
124
+ export interface ProgressEvent {
125
+ stage: string;
126
+ message: string;
127
+ /** 0..1 fraction of modules processed. */
128
+ progress: number;
129
+ }
130
+
131
+ export interface QuantizeResult {
132
+ outDir: string;
133
+ /** Achieved bits-per-weight over the quantized parameters (incl overhead). */
134
+ achievedBpw: number;
135
+ /** Number of modules quantized. */
136
+ nQuantized: number;
137
+ /** Sharding / size details from the writer. */
138
+ write: WriteResult;
139
+ }
140
+
141
+ type Suffix = "weight" | "scales" | "biases";
142
+
143
+ /** Split a tensor name into (modulePath, suffix) for `.weight|.scales|.biases`. */
144
+ function splitName(name: string): { base: string; suffix: Suffix } | null {
145
+ const m = /^(.*)\.(weight|scales|biases)$/.exec(name);
146
+ if (!m) return null;
147
+ return { base: m[1]!, suffix: m[2] as Suffix };
148
+ }
149
+
150
+ /**
151
+ * Eligibility predicate (port of mlx-lm's default class_predicate): a module
152
+ * is quantizable iff it has a 2D `.weight` whose last dim is divisible by the
153
+ * group size. This captures Linear projections, the token embedding, and the
154
+ * (untied) lm_head, and excludes norms/biases/1D tensors.
155
+ */
156
+ export function isQuantizable(weightShape: number[], groupSize: number): boolean {
157
+ return weightShape.length === 2 && weightShape[weightShape.length - 1]! % groupSize === 0;
158
+ }
159
+
160
+ /**
161
+ * Quantize a model directory and write a loadable snapshot to `outDir`.
162
+ *
163
+ * Uniform affine N-bit by default. When `opts.targetBpw` is set, runs the
164
+ * OptiQ mixed-precision path (calibration → sensitivity → knapsack) to choose
165
+ * per-module bit-widths before writing.
166
+ */
167
+ export async function quantizeModelDir(
168
+ srcDir: string,
169
+ outDir: string,
170
+ opts: QuantizeOptions,
171
+ onProgress?: (e: ProgressEvent) => void,
172
+ ): Promise<QuantizeResult> {
173
+ // Mixed-precision path: derive a per-module bit allocation first, then write.
174
+ let perLayerBits: Map<string, number> | undefined;
175
+ let mixedMeta: MixedMeta | undefined;
176
+ if (opts.targetBpw !== undefined) {
177
+ const alloc = await computeMixedAllocation(srcDir, opts, onProgress);
178
+ perLayerBits = alloc.perLayerBits;
179
+ mixedMeta = alloc.meta;
180
+ }
181
+
182
+ return writeAtomicDirectory(outDir, (stagingDir) =>
183
+ writeQuantizedModelDir(
184
+ srcDir,
185
+ stagingDir,
186
+ opts,
187
+ perLayerBits,
188
+ mixedMeta,
189
+ onProgress,
190
+ outDir,
191
+ ),
192
+ );
193
+ }
194
+
195
+ /** Lower-level writer used by the public orchestrator and its probe source.
196
+ * Deliberately does not perform sensitivity/allocation or call back into
197
+ * quantizeModelDir. */
198
+ async function writeQuantizedModelDir(
199
+ srcDir: string,
200
+ outDir: string,
201
+ opts: QuantizeOptions,
202
+ perLayerBits?: Map<string, number>,
203
+ mixedMeta?: MixedMeta,
204
+ onProgress?: (e: ProgressEvent) => void,
205
+ publishedOutDir = outDir,
206
+ ): Promise<QuantizeResult> {
207
+ const groupSize = opts.groupSize;
208
+ const mode = opts.mode ?? "affine";
209
+
210
+ const bits = opts.bits;
211
+ // Dtype of the quantization input (so of its scales/biases): bf16 unless --dtype names one.
212
+ const castDtype = opts.dtype ? DTYPE_OF[opts.dtype] : Dtype.bfloat16;
213
+ const cast = (tensor: NamedTensor): NamedTensor => ({ name: tensor.name, array: castTensor(tensor.name, tensor.array, opts.dtype) });
214
+
215
+ mkdirSync(outDir, { recursive: true });
216
+
217
+ const progress = (stage: string, message: string, frac: number) =>
218
+ onProgress?.({ stage, message, progress: frac });
219
+
220
+ progress("loading", `Reading ${srcDir}`, 0);
221
+
222
+ const config = await loadModelConfig(srcDir);
223
+ const srcQuant: QuantizationConfig | null = config.quantization;
224
+ const weights = await Weights.open(srcDir);
225
+ let transformPlan: WeightTransformPlan | undefined;
226
+ let transformContext: WeightTransformContext | undefined;
227
+ try {
228
+ if (opts.weightTransform && srcQuant)
229
+ throw new Error(
230
+ `weight transform ${opts.weightTransform.id} requires full-precision source weights`,
231
+ );
232
+ transformPlan = opts.weightTransform?.plan(weights.tensorNames, config);
233
+ if (transformPlan) {
234
+ validateTransformPlan(transformPlan, weights.tensorNames);
235
+ transformContext = opts.weightTransform!.createContext(weights, transformPlan);
236
+ progress("transforming", `Planned ${transformPlan.id}`, 0);
237
+ }
238
+ } catch (error) {
239
+ transformContext?.dispose();
240
+ weights.dispose();
241
+ throw error;
242
+ }
243
+
244
+ // Preserve the source tensor order so the output diff is mlx-lm-shaped.
245
+ // tensorNames is sorted; we walk modules in that order. For each module's
246
+ // `.weight` we decide quantize / pass-through, then emit its tensors. We
247
+ // skip the source `.scales`/`.biases` of already-quantized modules (they're
248
+ // regenerated) and pass through everything else verbatim.
249
+ const names = transformPlan?.outputNames ?? weights.tensorNames;
250
+
251
+ // Group names by module base so we can detect "already quantized" (has a
252
+ // sibling `.scales`) and process a module atomically.
253
+ const moduleSuffixes = new Map<string, Set<Suffix>>();
254
+ const passthroughNames: string[] = []; // non-(weight/scales/biases) tensors
255
+ for (const name of names) {
256
+ const sp = splitName(name);
257
+ if (!sp) {
258
+ passthroughNames.push(name);
259
+ continue;
260
+ }
261
+ let set = moduleSuffixes.get(sp.base);
262
+ if (!set) {
263
+ set = new Set();
264
+ moduleSuffixes.set(sp.base, set);
265
+ }
266
+ set.add(sp.suffix);
267
+ }
268
+
269
+ // Decide the per-module plan in source order.
270
+ const moduleBases = [...moduleSuffixes.keys()];
271
+ const totalModules = moduleBases.length;
272
+
273
+ const out: NamedTensor[] = [];
274
+ const perLayer = new Map<string, PerLayerEntry>();
275
+ let nQuantized = 0;
276
+ let quantizedParams = 0;
277
+ let quantizedBits = 0;
278
+ let processed = 0;
279
+
280
+ try {
281
+ for (const base of moduleBases) {
282
+ const suffixes = moduleSuffixes.get(base)!;
283
+ const alreadyQuant = suffixes.has("scales");
284
+ const hasWeight = suffixes.has("weight");
285
+
286
+ if (!hasWeight) {
287
+ // Module with only scales/biases and no weight — shouldn't happen, but
288
+ // pass any present tensors through unchanged.
289
+ for (const suf of suffixes)
290
+ out.push(cast(outputTensor(weights, transformPlan, transformContext, `${base}.${suf}`)));
291
+ bumpProgress();
292
+ continue;
293
+ }
294
+
295
+ // Materialize the full-precision weight for this module.
296
+ const weightName = `${base}.weight`;
297
+ let fullWeight = outputArray(weights, transformPlan, transformContext, weightName);
298
+ const shape = fullWeight.shape;
299
+
300
+ // Determine the *original* (full-precision) shape: if already quantized,
301
+ // the packed weight's last dim is compressed — recover the real width
302
+ // from the scales (groups × srcGroupSize) when dequantizing.
303
+ let materialized: MlxArray | null = null;
304
+
305
+ if (alreadyQuant) {
306
+ const srcSpec = quantFor(srcQuant, base);
307
+ if (!srcSpec) {
308
+ // Has scales but config says unquantized — fall back to passthrough.
309
+ for (const suf of suffixes)
310
+ out.push(cast(outputTensor(weights, transformPlan, transformContext, `${base}.${suf}`)));
311
+ bumpProgress();
312
+ continue;
313
+ }
314
+ const scales = weights.tensor(`${base}.scales`);
315
+ const biases = suffixes.has("biases") ? weights.tensor(`${base}.biases`) : null;
316
+ materialized = dequantize(
317
+ fullWeight, scales, biases,
318
+ { bits: srcSpec.bits, groupSize: srcSpec.groupSize, mode: srcSpec.mode },
319
+ cpuStream,
320
+ );
321
+ fullWeight = materialized;
322
+ }
323
+
324
+ // Recover the full-precision 2D shape for eligibility + param counting.
325
+ // For a freshly-loaded bf16 weight this is just `shape`; for a
326
+ // re-quantized weight it is the dequantized array's shape.
327
+ const fullShape = alreadyQuant ? materialized!.shape : shape;
328
+
329
+ const predicateExcluded =
330
+ isQuantizable(fullShape, groupSize) &&
331
+ opts.quantizePredicate !== undefined &&
332
+ !opts.quantizePredicate(base, fullShape);
333
+ if (!isQuantizable(fullShape, groupSize) || predicateExcluded) {
334
+ // Not eligible: pass the full-precision weight through unchanged
335
+ // (re-materialized to bf16 if it had been quantized).
336
+ if (alreadyQuant) {
337
+ const dense = materialized!.astype(castDtype, cpuStream);
338
+ out.push({ name: weightName, array: dense });
339
+ materialized!.dispose();
340
+ } else {
341
+ out.push(cast({ name: weightName, array: fullWeight }));
342
+ }
343
+ // Policy-excluded (shape-eligible) modules get an explicit `false`
344
+ // in the quantization block — the loader must not apply the block's
345
+ // default spec to them (mlx's "not quantized" convention).
346
+ if (predicateExcluded) perLayer.set(base, false);
347
+ bumpProgress();
348
+ continue;
349
+ }
350
+
351
+ // Resolve this module's bit-width: the allocator's per-layer choice in
352
+ // mixed mode, else the uniform `bits`. The allocation map is keyed by
353
+ // module base path (e.g. "model.layers.0.self_attn.q_proj").
354
+ const moduleBits = perLayerBits?.get(base) ?? bits;
355
+
356
+ // Cast before quantizing (mx.quantize expects a float weight; the input
357
+ // dtype is the scales/biases dtype on disk: bf16, or --dtype).
358
+ const bf16 = fullWeight.astype(castDtype, cpuStream);
359
+ if (materialized) materialized.dispose();
360
+ else if (transformContext) fullWeight.dispose();
361
+
362
+ const q = quantize(bf16, groupSize, moduleBits, mode, cpuStream);
363
+ bf16.dispose();
364
+
365
+ out.push({ name: `${base}.weight`, array: q.packed });
366
+ out.push({ name: `${base}.scales`, array: q.scales });
367
+ out.push({ name: `${base}.biases`, array: q.biases });
368
+
369
+ // Account: every element of the original 2D weight is now `moduleBits`
370
+ // bits, plus 32 bits (bf16 scale + bf16 bias) per group of `groupSize`.
371
+ const params = fullShape.reduce((a, b) => a * b, 1);
372
+ quantizedParams += params;
373
+ quantizedBits += params * moduleBits + (params / groupSize) * AFFINE_GROUP_OVERHEAD_BITS;
374
+ perLayer.set(base, { bits: moduleBits, groupSize });
375
+ nQuantized++;
376
+
377
+ bumpProgress();
378
+ }
379
+
380
+ // Pass through non-weight/scale/bias tensors (e.g. anything unusual).
381
+ for (const name of passthroughNames)
382
+ out.push(cast(outputTensor(weights, transformPlan, transformContext, name)));
383
+
384
+ progress("writing", `Writing ${out.length} tensors to ${publishedOutDir}`, 1);
385
+ const write = writeShardedSafetensors(outDir, out);
386
+
387
+ const achievedBpw = quantizedParams > 0 ? quantizedBits / quantizedParams : 0;
388
+
389
+ // Default block bits: the uniform `bits` normally; in mixed mode use the
390
+ // lowest allocated bit-width so the loader's per-module overrides describe
391
+ // every deviation above the floor.
392
+ let defaultBits: number = bits;
393
+ if (perLayerBits && perLayerBits.size > 0) {
394
+ defaultBits = Math.min(...perLayerBits.values());
395
+ }
396
+
397
+ // Build + write the config block. For uniform mode, let the config writer
398
+ // emit its standard optiq_metadata.json sidecar. For mixed mode we write a
399
+ // richer OptiQ-style sidecar ourselves below (method "mixed_precision").
400
+ const block = buildQuantizationBlock({ bits: defaultBits, groupSize, mode }, perLayer);
401
+ const outputConfig = transformedConfig(config.raw, transformPlan);
402
+ const weightTransforms = transformPlan ? [transformPlan.metadata] : undefined;
403
+ await writeQuantizedConfig(outputConfig, outDir, block, {
404
+ srcDir,
405
+ optiq: mixedMeta
406
+ ? undefined
407
+ : {
408
+ method: "uniform_affine",
409
+ base_model: srcDir,
410
+ bits,
411
+ group_size: groupSize,
412
+ achieved_bpw: achievedBpw,
413
+ per_layer_count: nQuantized,
414
+ weight_transforms: weightTransforms,
415
+ },
416
+ });
417
+
418
+ if (mixedMeta) {
419
+ await Bun.write(
420
+ join(outDir, "optiq_metadata.json"),
421
+ JSON.stringify(
422
+ {
423
+ method: "mixed_precision",
424
+ base_model: srcDir,
425
+ bits: defaultBits,
426
+ group_size: groupSize,
427
+ target_bpw: mixedMeta.targetBpw,
428
+ achieved_bpw: achievedBpw,
429
+ candidate_bits: mixedMeta.candidateBits,
430
+ n_high: mixedMeta.nHigh,
431
+ n_low: mixedMeta.nLow,
432
+ per_layer_count: nQuantized,
433
+ weight_transforms: weightTransforms,
434
+ per_layer: Object.fromEntries(
435
+ [...perLayer].map(([p, e]) => [
436
+ p,
437
+ e === false ? false : { bits: e.bits, group_size: e.groupSize },
438
+ ]),
439
+ ),
440
+ },
441
+ null,
442
+ 2,
443
+ ),
444
+ );
445
+ }
446
+
447
+ progress("done", `Quantized ${nQuantized} modules → ${publishedOutDir}`, 1);
448
+ return { outDir: publishedOutDir, achievedBpw, nQuantized, write };
449
+ } finally {
450
+ // Dispose every emitted array (their bytes are now on disk) and the source.
451
+ for (const t of out) {
452
+ try { t.array.dispose(); } catch {}
453
+ }
454
+ transformContext?.dispose();
455
+ weights.dispose();
456
+ clearCache();
457
+ }
458
+
459
+ function bumpProgress(): void {
460
+ processed++;
461
+ if (processed % CACHE_CLEAR_EVERY === 0) clearCache();
462
+ progress(
463
+ "quantizing",
464
+ `Module ${processed}/${totalModules}`,
465
+ totalModules > 0 ? processed / totalModules : 1,
466
+ );
467
+ }
468
+ }
469
+
470
+ /** Pass-through tensor: the lazy mlx array straight from the source weights. */
471
+ function outputTensor(
472
+ weights: Weights,
473
+ plan: WeightTransformPlan | undefined,
474
+ context: WeightTransformContext | undefined,
475
+ name: string,
476
+ ): NamedTensor {
477
+ return { name, array: outputArray(weights, plan, context, name) };
478
+ }
479
+
480
+ function outputArray(
481
+ weights: Weights,
482
+ plan: WeightTransformPlan | undefined,
483
+ context: WeightTransformContext | undefined,
484
+ name: string,
485
+ ): MlxArray {
486
+ if (!plan || !context) return weights.tensor(name);
487
+ const sourceName = plan.sourceByOutput.get(name);
488
+ if (!sourceName) throw new Error(`weight transform ${plan.id}: no source for ${name}`);
489
+ return context.apply(name, weights.tensor(sourceName));
490
+ }
491
+
492
+ function validateTransformPlan(
493
+ plan: WeightTransformPlan,
494
+ sourceNames: readonly string[],
495
+ ): void {
496
+ const seen = new Set<string>();
497
+ const sources = new Set(sourceNames);
498
+ for (const name of plan.outputNames) {
499
+ if (seen.has(name)) throw new Error(`weight transform ${plan.id}: duplicate output ${name}`);
500
+ seen.add(name);
501
+ const sourceName = plan.sourceByOutput.get(name);
502
+ if (!sourceName)
503
+ throw new Error(`weight transform ${plan.id}: no source for ${name}`);
504
+ if (!sources.has(sourceName))
505
+ throw new Error(`weight transform ${plan.id}: missing source tensor ${sourceName}`);
506
+ }
507
+ }
508
+
509
+ function transformedConfig(
510
+ raw: Record<string, unknown>,
511
+ plan: WeightTransformPlan | undefined,
512
+ ): Record<string, unknown> {
513
+ if (!plan?.untieWordEmbeddings) return raw;
514
+ const out: Record<string, unknown> = { ...raw, tie_word_embeddings: false };
515
+ if (raw.text_config && typeof raw.text_config === "object") {
516
+ out.text_config = {
517
+ ...(raw.text_config as Record<string, unknown>),
518
+ tie_word_embeddings: false,
519
+ };
520
+ }
521
+ return out;
522
+ }
523
+
524
+ /** Summary of a mixed-precision allocation, for the sidecar + return value. */
525
+ interface MixedMeta {
526
+ targetBpw: number;
527
+ candidateBits: number[];
528
+ nHigh: number;
529
+ nLow: number;
530
+ /** Knapsack's own BPW estimate over the analyzed layers (params-weighted,
531
+ * no group overhead) — distinct from the writer's achievedBpw. */
532
+ allocBpw: number;
533
+ }
534
+
535
+ const defaultProbeSource: ProbeSource = {
536
+ async prepare(srcDir, opts, onProgress) {
537
+ const config = await loadModelConfig(srcDir);
538
+ if (config.quantization) return { modelDir: srcDir, dispose() {} };
539
+
540
+ const { mkdtempSync, rmSync } = await import("node:fs");
541
+ const { tmpdir } = await import("node:os");
542
+ const tempDir = mkdtempSync(join(tmpdir(), "mlx-bun-qbaseline-"));
543
+ onProgress?.({
544
+ stage: "sensitivity",
545
+ message: "Unquantized source — building an 8-bit baseline for the sweep",
546
+ progress: 0,
547
+ });
548
+ try {
549
+ await writeQuantizedModelDir(
550
+ srcDir,
551
+ tempDir,
552
+ {
553
+ ...opts,
554
+ bits: 8,
555
+ targetBpw: undefined,
556
+ probeSource: undefined,
557
+ },
558
+ );
559
+ } catch (error) {
560
+ rmSync(tempDir, { recursive: true, force: true });
561
+ throw error;
562
+ }
563
+ return {
564
+ modelDir: tempDir,
565
+ dispose: () => rmSync(tempDir, { recursive: true, force: true }),
566
+ };
567
+ },
568
+ };
569
+
570
+ /** Acquire/use/release helper that pins the injected ProbeSource lifecycle. */
571
+ export async function withPreparedProbe<T>(
572
+ source: ProbeSource,
573
+ srcDir: string,
574
+ opts: QuantizeOptions,
575
+ run: (modelDir: string) => Promise<T>,
576
+ onProgress?: (e: ProgressEvent) => void,
577
+ ): Promise<T> {
578
+ const prepared = await source.prepare(srcDir, opts, onProgress);
579
+ try {
580
+ return await run(prepared.modelDir);
581
+ } finally {
582
+ await prepared.dispose();
583
+ }
584
+ }
585
+
586
+ /**
587
+ * Run the OptiQ mixed-precision pipeline for `srcDir`:
588
+ * load model + tokenizer → calibration → per-layer KL sensitivity →
589
+ * greedy knapsack → per-module bit allocation.
590
+ *
591
+ * Returns a `Map<modulePath, bits>` keyed by weight-file module base path
592
+ * (the same key the writer loop uses) plus a metadata summary. Modules not in
593
+ * the map fall back to the uniform `opts.bits` at write time.
594
+ */
595
+ async function computeMixedAllocation(
596
+ srcDir: string,
597
+ opts: QuantizeOptions,
598
+ onProgress?: (e: ProgressEvent) => void,
599
+ ): Promise<{ perLayerBits: Map<string, number>; meta: MixedMeta }> {
600
+ const targetBpw = opts.targetBpw!;
601
+ const groupSize = opts.groupSize;
602
+ const candidateBits = (opts.candidateBits ?? [4, 8]).slice().sort((a, b) => a - b);
603
+ const nCalibration = opts.nCalibration ?? 2;
604
+ const calibrationMix = opts.calibrationMix ?? "optiq";
605
+
606
+ const progress = (stage: string, message: string, frac: number) =>
607
+ onProgress?.({ stage, message, progress: frac });
608
+
609
+ progress("sensitivity", "Loading model for sensitivity analysis", 0.0);
610
+
611
+ // The source dependency owns preparation and cleanup. The default builds a
612
+ // temporary uniform 8-bit baseline from bf16 through the lower-level writer;
613
+ // an injected source can point at a prebuilt probe without orchestrator
614
+ // recursion.
615
+ return withPreparedProbe(
616
+ opts.probeSource ?? defaultProbeSource,
617
+ srcDir,
618
+ opts,
619
+ async (probeDir) => {
620
+ const config = await loadModelConfig(probeDir);
621
+ const weights = await Weights.open(probeDir);
622
+ try {
623
+ const model = createModel(weights, config);
624
+ const tokenizer = await loadTokenizer(srcDir);
625
+
626
+ // Calibration: short sequences keep per-probe forward memory modest.
627
+ progress("sensitivity", "Building calibration set", 0.02);
628
+ const calibrationFn = loadLlmCalibration(tokenizer, {
629
+ nSamples: nCalibration,
630
+ seqLen: 128,
631
+ mix: calibrationMix,
632
+ });
633
+ const calIds = calibrationFn();
634
+
635
+ // Quantizable linears to probe: the model's LoRA-target linears
636
+ // (attention + MLP projections), keyed by full module path.
637
+ const layers = new Map([...model.loraTargets()].filter((entry): entry is [string, QuantizedLinear] =>
638
+ "exchangeQuantizedState" in entry[1]));
639
+
640
+ progress(
641
+ "sensitivity",
642
+ `Probing ${layers.size} layers × ${candidateBits.length} bit-widths`,
643
+ 0.05,
644
+ );
645
+ const sensitivity: SensitivityResult[] = analyzeSensitivityExact(
646
+ model,
647
+ layers,
648
+ calIds,
649
+ { candidateBits, groupSize },
650
+ (done, total, layerName) =>
651
+ progress(
652
+ "sensitivity",
653
+ `Sensitivity ${done}/${total}: ${layerName}`,
654
+ 0.05 + 0.85 * (done / Math.max(total, 1)),
655
+ ),
656
+ );
657
+
658
+ // Greedy knapsack → per-layer bit allocation.
659
+ progress("allocating", "Optimizing per-layer bit allocation", 0.92);
660
+ const opt = optimizeMixedPrecision(sensitivity, {
661
+ targetBpw,
662
+ candidateBits,
663
+ groupSize,
664
+ });
665
+
666
+ const perLayerBits = new Map<string, number>();
667
+ for (const c of opt.configs) perLayerBits.set(c.layerName, c.bits);
668
+
669
+ const meta: MixedMeta = {
670
+ targetBpw,
671
+ candidateBits,
672
+ nHigh: opt.nHighBits,
673
+ nLow: opt.nLowBits,
674
+ allocBpw: opt.achievedBpw,
675
+ };
676
+
677
+ progress(
678
+ "allocating",
679
+ `Allocated ${opt.nHighBits} high / ${opt.nLowBits} low (alloc bpw ${opt.achievedBpw.toFixed(2)})`,
680
+ 0.95,
681
+ );
682
+
683
+ return { perLayerBits, meta };
684
+ } finally {
685
+ weights.dispose();
686
+ clearCache();
687
+ }
688
+ },
689
+ onProgress,
690
+ );
691
+ }