@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,216 @@
1
+ // Per-layer quantization sensitivity analysis (native TS port of
2
+ // optiq/core/sensitivity.py `analyze_sensitivity_exact`, uniform-baseline path).
3
+ //
4
+ // For each quantizable Linear in a loaded (already-quantized) model:
5
+ // 1. dequantize its packed weight back to bf16 — this is the bf16 "source"
6
+ // the OptIQ uniform_4bit path streams off disk; here it's self-contained
7
+ // because the running model already carries quantized weights.
8
+ // 2. for each candidate bit-width, re-quantize that bf16 source at the new
9
+ // bits and SWAP it into the running QuantizedLinear (mutate w/scales/biases
10
+ // + the QuantSpec bits/groupSize).
11
+ // 3. run a no-KV full-sequence forward on each calibration sample, compute
12
+ // KL(reference || current) (port of `_kl_from_ref`), average over samples.
13
+ // 4. restore the layer's original quantized weight.
14
+ //
15
+ // Reference logits = the UNMODIFIED running model (taken once up front). When a
16
+ // candidate bit equals the layer's baseline bit-width, re-quantizing reproduces
17
+ // exactly what the layer already is, so KL is 0 by construction — we record 0.0
18
+ // and skip the forward passes (port of the OptIQ baseline-bit short-circuit).
19
+ //
20
+ // Faithful to: `_simulate_quantize` (quantize→dequantize via MlxArray ops),
21
+ // `_mutate_quantized_layer_to_bits`, `_kl_from_ref`, the uniform-4bit-baseline
22
+ // path, and the bits==baseline ⇒ 0 short-circuit.
23
+
24
+ import { Dtype } from "@mlx-bun/mlx/ffi";
25
+ import { cpuStream, MlxArray } from "@mlx-bun/mlx/array";
26
+ import * as ops from "@mlx-bun/mlx/ops";
27
+ import { forwardSequence as trainForward } from "@mlx-bun/inference/scoring";
28
+ import type { RuntimeModel } from "@mlx-bun/inference";
29
+ import type {
30
+ QuantizedLinear,
31
+ QuantizedLinearState,
32
+ } from "@mlx-bun/inference/layers";
33
+
34
+ /** Per-layer KL sensitivity at each candidate bit-width (port of
35
+ * optiq SensitivityResult). */
36
+ export interface SensitivityResult {
37
+ layerName: string;
38
+ /** bit-width → mean KL(reference || that-bit) over calibration samples. */
39
+ sensitivities: Record<number, number>;
40
+ paramCount: number;
41
+ }
42
+
43
+ export interface AnalyzeSensitivityOptions {
44
+ candidateBits?: number[];
45
+ groupSize?: number;
46
+ }
47
+
48
+ // --------------------------------------------------------------------------
49
+ // Scoped state exchange for sensitivity probes. QuantizedLinear owns the only
50
+ // mutation capability; inference consumers retain a read-only getter surface.
51
+ // --------------------------------------------------------------------------
52
+
53
+ /** Snapshot of a QuantizedLinear's quantized state (for restore). */
54
+ type LayerState = QuantizedLinearState;
55
+
56
+ function captureState(layer: QuantizedLinear): LayerState {
57
+ return { w: layer.w, scales: layer.scales, biases: layer.biases, spec: layer.spec };
58
+ }
59
+
60
+ function restoreState(layer: QuantizedLinear, state: LayerState): void {
61
+ const transient = layer.exchangeQuantizedState(state);
62
+ // Dispose the transient swapped-in tensors before restoring the originals.
63
+ if (transient.w !== state.w) transient.w.dispose();
64
+ if (transient.scales !== state.scales) transient.scales.dispose();
65
+ if (transient.biases && transient.biases !== state.biases) transient.biases.dispose();
66
+ }
67
+
68
+ /** Quantize `bf16Weight` at `newBits`/`groupSize` and swap it into `layer`
69
+ * in place (port of `_mutate_quantized_layer_to_bits`). The previously
70
+ * swapped-in tensors (if any beyond the captured original) are released by
71
+ * the caller via restoreState; this only installs the new ones. */
72
+ function mutateLayerToBits(
73
+ layer: QuantizedLinear,
74
+ bf16Weight: MlxArray,
75
+ newBits: number,
76
+ groupSize: number,
77
+ original: LayerState,
78
+ ): void {
79
+ const q = ops.quantize(bf16Weight, groupSize, newBits, "affine", cpuStream);
80
+ const previous = layer.exchangeQuantizedState({
81
+ w: q.packed,
82
+ scales: q.scales,
83
+ biases: q.biases,
84
+ spec: { bits: newBits, groupSize, mode: "affine" },
85
+ });
86
+ // Free any previously swapped-in transient tensors (not the original).
87
+ if (previous.w !== original.w) previous.w.dispose();
88
+ if (previous.scales !== original.scales) previous.scales.dispose();
89
+ if (previous.biases && previous.biases !== original.biases) previous.biases.dispose();
90
+ ops.evalAll([q.packed, q.scales, q.biases]);
91
+ }
92
+
93
+ // --------------------------------------------------------------------------
94
+ // KL primitive (port of _kl_from_ref)
95
+ // --------------------------------------------------------------------------
96
+
97
+ /** KL(reference || current) averaged across batch/seq (port of `_kl_from_ref`):
98
+ * log_cur = cur - logsumexp(cur); log_ref = ref - logsumexp(ref)
99
+ * ref_probs = softmax(ref)
100
+ * mean( sum( ref_probs * (log_ref - log_cur), axis=-1 ) )
101
+ * Returns the scalar KL as a JS number. Casts to f32 for stability. */
102
+ export function klFromRef(curLogits: MlxArray, refLogits: MlxArray): number {
103
+ const cur = curLogits.dtype === Dtype.float32 ? curLogits : curLogits.astype(Dtype.float32);
104
+ const ref = refLogits.dtype === Dtype.float32 ? refLogits : refLogits.astype(Dtype.float32);
105
+
106
+ const lseCur = ops.logsumexpAxis(cur, -1, true);
107
+ const lseRef = ops.logsumexpAxis(ref, -1, true);
108
+ const logCur = ops.sub(cur, lseCur);
109
+ const logRef = ops.sub(ref, lseRef);
110
+ const refProbs = ops.softmaxAxis(ref, -1, true);
111
+
112
+ const diff = ops.sub(logRef, logCur);
113
+ const weighted = ops.mul(refProbs, diff);
114
+ const summed = ops.sumAxis(weighted, -1, false);
115
+ const meaned = ops.meanAll(summed, false);
116
+
117
+ const val = meaned.toFloat32()[0]!;
118
+
119
+ for (const a of [lseCur, lseRef, logCur, logRef, refProbs, diff, weighted, summed, meaned]) {
120
+ a.dispose();
121
+ }
122
+ if (cur !== curLogits) cur.dispose();
123
+ if (ref !== refLogits) ref.dispose();
124
+ return val;
125
+ }
126
+
127
+ // --------------------------------------------------------------------------
128
+ // Public entry: analyzeSensitivityExact
129
+ // --------------------------------------------------------------------------
130
+
131
+ /** Per-layer KL sensitivity of an already-quantized MLX model.
132
+ *
133
+ * @param model loaded RuntimeModel (its QuantizedLinears are the
134
+ * uniform baseline; their dequantized weights are the
135
+ * self-contained bf16 source).
136
+ * @param layers the quantizable linears to probe, keyed by module path
137
+ * (typically `model.loraTargets()`).
138
+ * @param calIds calibration samples — each a row of int32 token ids.
139
+ * @param options candidateBits (default [4,8]) and groupSize (default 64).
140
+ */
141
+ export function analyzeSensitivityExact(
142
+ model: RuntimeModel,
143
+ layers: Map<string, QuantizedLinear>,
144
+ calIds: number[][],
145
+ options: AnalyzeSensitivityOptions = {},
146
+ onProgress?: (done: number, total: number, layerName: string) => void,
147
+ ): SensitivityResult[] {
148
+ const candidateBits = [...(options.candidateBits ?? [4, 8])];
149
+ const groupSize = options.groupSize ?? 64;
150
+
151
+ // Calibration sample arrays (int32 [1, L]).
152
+ const calArrays = calIds.map((ids) =>
153
+ MlxArray.fromInt32(new Int32Array(ids), [1, ids.length]),
154
+ );
155
+
156
+ try {
157
+ // Reference logits from the unmodified running model (taken once).
158
+ const refLogits: MlxArray[] = calArrays.map((ids) => trainForward(model, ids));
159
+ ops.evalAll(refLogits);
160
+
161
+ const entries = [...layers.entries()];
162
+ const results: SensitivityResult[] = [];
163
+
164
+ try {
165
+ for (let li = 0; li < entries.length; li++) {
166
+ const [layerName, layer] = entries[li]!;
167
+ const original = captureState(layer);
168
+ const baselineBits = layer.spec.bits;
169
+
170
+ // Self-contained bf16 source: dequantize the layer's own packed weight.
171
+ const bf16Source = ops.dequantize(
172
+ original.w, original.scales, original.biases, original.spec, cpuStream,
173
+ ).astype(Dtype.bfloat16, cpuStream);
174
+ ops.evalAll([bf16Source]);
175
+
176
+ const sensitivities: Record<number, number> = {};
177
+ try {
178
+ for (const bits of candidateBits) {
179
+ if (bits === baselineBits) {
180
+ // KL is 0 by construction (re-quantizing to the baseline bit-width
181
+ // reproduces the running model). Skip the forwards.
182
+ sensitivities[bits] = 0.0;
183
+ continue;
184
+ }
185
+ mutateLayerToBits(layer, bf16Source, bits, groupSize, original);
186
+
187
+ let klSum = 0;
188
+ for (let i = 0; i < calArrays.length; i++) {
189
+ const cur = trainForward(model, calArrays[i]!);
190
+ klSum += klFromRef(cur, refLogits[i]!);
191
+ cur.dispose();
192
+ }
193
+ sensitivities[bits] = klSum / Math.max(calArrays.length, 1);
194
+ }
195
+ } finally {
196
+ // Restore even when quantization or a calibration forward throws.
197
+ restoreState(layer, original);
198
+ bf16Source.dispose();
199
+ }
200
+
201
+ // param count = product of the dequantized (full) weight shape.
202
+ const fullShape = [layer.outFeatures, layer.inFeatures];
203
+ const paramCount = fullShape.reduce((a, b) => a * b, 1);
204
+
205
+ results.push({ layerName, sensitivities, paramCount });
206
+ onProgress?.(li + 1, entries.length, layerName);
207
+ }
208
+ } finally {
209
+ for (const r of refLogits) r.dispose();
210
+ }
211
+
212
+ return results;
213
+ } finally {
214
+ for (const a of calArrays) a.dispose();
215
+ }
216
+ }
@@ -0,0 +1,111 @@
1
+ // Which tensors the packed-Trellis producer codes, along which axis and at how
2
+ // many bits. Pure: names, shapes and an allocation file in, treatments out.
3
+ //
4
+ // Shipped-compact layout for the Qwen3.5-family MLP: gate/up/down are
5
+ // trellis-coded, embed/lm_head/attention stay affine at the protection tier,
6
+ // everything else rides the base tier, and the vision tower stays bf16.
7
+
8
+ /** Affine base tier and the protection tier (embed, lm_head, attention). */
9
+ export const TRELLIS_BASE_BITS = 3;
10
+ export const TRELLIS_PROTECT_BITS = 4;
11
+ /** Affine group size of the non-trellis tiers. */
12
+ export const TRELLIS_AFFINE_GROUP = 64;
13
+
14
+ /** Language-model tensor prefix of the HF (transformers 5) Qwen3.5 layout. */
15
+ export const LM_PREFIX = "model.language_model.";
16
+ export const VISION_PREFIX = "model.visual.";
17
+ export const MTP_PREFIX = "mtp.";
18
+
19
+ /** Which dim of down_proj the trellis runs along: `out` codes the ROTATED dim
20
+ * (the fold's R1 acts on down's output) and decodes through a column gather;
21
+ * `in` codes the un-rotated intermediate dim so down decodes with the same
22
+ * plain reduce kernel as gate/up. */
23
+ export type DownAxis = "out" | "in";
24
+
25
+ export type Treatment =
26
+ | { kind: "bf16" }
27
+ | { kind: "affine"; bits: number }
28
+ | { kind: "trellis"; axis: 0 | 1; k: number };
29
+
30
+ /** Per-tensor allocation read from an allocation file's budget block. Keys are
31
+ * module base names in the artifact's tensor space (`model.language_model.…`). */
32
+ export interface TrellisKMap {
33
+ /** Trellis bits per coded tensor. */
34
+ k: Map<string, number>;
35
+ /** Affine bit overrides for protected (non-MLP) modules. */
36
+ affineBits: Map<string, number>;
37
+ }
38
+
39
+ /** Parse `budgets[budget]` of an allocation document. The file names tensors
40
+ * `model.layers.N.…`; they are mapped into the language-model prefix. */
41
+ export function parseKMap(doc: unknown, budget: string, source = "k-map"): TrellisKMap {
42
+ const budgets = (doc as { budgets?: Record<string, { kmap?: Record<string, number>; affine_map?: Record<string, number> }> } | null)?.budgets;
43
+ const selected = budgets?.[budget];
44
+ if (!selected?.kmap)
45
+ throw new Error(`${source}: no budgets["${budget}"].kmap (have ${Object.keys(budgets ?? {}).join(", ")})`);
46
+ const rename = (name: string) => name.replace(/^model\.layers\./, `${LM_PREFIX}layers.`);
47
+ const k = new Map<string, number>();
48
+ for (const [name, bits] of Object.entries(selected.kmap)) {
49
+ if (!Number.isInteger(bits) || bits < 1 || bits > 8) throw new Error(`${source}: bad k=${bits} for ${name}`);
50
+ k.set(rename(name), bits);
51
+ }
52
+ const affineBits = new Map<string, number>(
53
+ Object.entries(selected.affine_map ?? {}).map(([name, bits]) => [rename(name), bits]),
54
+ );
55
+ return { k, affineBits };
56
+ }
57
+
58
+ export interface TreatmentPolicy {
59
+ /** Trellis bits for every coded tensor without a k-map entry. */
60
+ bits: number;
61
+ kMap?: TrellisKMap;
62
+ downAxis: DownAxis;
63
+ /** Only the first N decoder layers are trellis-coded; the rest ride the affine base tier. */
64
+ layers?: number;
65
+ }
66
+
67
+ /** The treatment of module `base` (no `.weight` suffix) under `policy`. The
68
+ * trellis axis is the ROTATED axis: R1 acts on the input dim of gate/up and on
69
+ * the output dim of down_proj, so the coded sequence runs along the direction
70
+ * the Hadamard gaussianized. */
71
+ export function treatmentFor(base: string, policy: TreatmentPolicy): Treatment {
72
+ if (base.startsWith(VISION_PREFIX)) return { kind: "bf16" };
73
+ const kOf = (): number => {
74
+ if (!policy.kMap) return policy.bits;
75
+ const k = policy.kMap.k.get(base);
76
+ if (k === undefined) throw new Error(`k-map: no entry for in-scope trellis tensor ${base}`);
77
+ return k;
78
+ };
79
+ if (base.startsWith(`${LM_PREFIX}layers.`)) {
80
+ const rest = base.slice(`${LM_PREFIX}layers.`.length);
81
+ const layer = Number(rest.slice(0, rest.indexOf(".")));
82
+ const mod = rest.slice(rest.indexOf(".") + 1);
83
+ const inScope = policy.layers === undefined || layer < policy.layers;
84
+ if (mod === "mlp.gate_proj" || mod === "mlp.up_proj")
85
+ return inScope ? { kind: "trellis", axis: 1, k: kOf() } : { kind: "affine", bits: TRELLIS_BASE_BITS };
86
+ if (mod === "mlp.down_proj")
87
+ return inScope
88
+ ? { kind: "trellis", axis: policy.downAxis === "in" ? 1 : 0, k: kOf() }
89
+ : { kind: "affine", bits: TRELLIS_BASE_BITS };
90
+ const mapped = policy.kMap?.affineBits.get(base);
91
+ return { kind: "affine", bits: mapped ?? TRELLIS_PROTECT_BITS };
92
+ }
93
+ if (base === "lm_head" || base === `${LM_PREFIX}embed_tokens`) return { kind: "affine", bits: TRELLIS_PROTECT_BITS };
94
+ return { kind: "affine", bits: TRELLIS_BASE_BITS }; // mtp.* rides the base tier
95
+ }
96
+
97
+ /** Packed cost of a coded tensor in bits per weight: k bits (the tail-biting
98
+ * block carries no initial state) plus one fp16 scale per coded row. */
99
+ export function trellisBpw(cols: number, k: number): number {
100
+ return k + 16 / cols;
101
+ }
102
+
103
+ /** The mlx-lm key space of a tensor base: our qwen3_5 graph resolves module
104
+ * paths there (`language_model.model.layers…`) while the artifact stores
105
+ * tensors in the HF space (`model.language_model.layers…`). Null when the
106
+ * module has no mlx-lm counterpart (vision tower, mtp). */
107
+ export function mlxLmPath(base: string): string | null {
108
+ if (base.startsWith(VISION_PREFIX) || base.startsWith(MTP_PREFIX)) return null;
109
+ if (base.startsWith("model.language_model")) return base.replace("model.language_model", "language_model.model");
110
+ return `language_model.${base}`;
111
+ }
@@ -0,0 +1,328 @@
1
+ // Packed-Trellis tensor encoders: fold-basis weight in, packed codes + fp16 row
2
+ // scales out. Two objectives share one Viterbi codec:
3
+ //
4
+ // * `encode` unweighted squared error (QTIP TCQ), one tail-biting
5
+ // trellis per T-block of each coded row;
6
+ // * `encodeLdlq` BlockLDLQ error feedback against a per-layer Hessian
7
+ // factor L, a port of Cornell-RelaxML/qtip lib/algo/ldlq.py
8
+ // at block size T with `for_kernel=False` semantics — QTIP's
9
+ // own non-kernel path, which quantizes a trellis of length T
10
+ // along the INPUT dim with one independent sequence per
11
+ // output row. Choosing T = 256 makes the LDLQ column blocks
12
+ // coincide with the trellis blocks, so this arm changes ONLY
13
+ // the distortion objective.
14
+ //
15
+ // The packed form of a coded tensor: `.weight` uint32 [rows, C·k/32] with the
16
+ // coded axis LAST (axis-0 tensors are stored transposed), reversed-time symbols
17
+ // in a tail-biting window, plus `.scales` fp16 [rows].
18
+ //
19
+ // Handle discipline matters here: an undisposed view pins its parent buffer,
20
+ // which is what OOM-killed the first LDLQ build at 86 GiB.
21
+
22
+ import { ptr, read } from "bun:ffi";
23
+ import { MlxArray as Arr, cpuStream, gpuStream, type MlxArray } from "@mlx-bun/mlx/array";
24
+ import { C, Dtype, activeMemory, clearCache } from "@mlx-bun/mlx/ffi";
25
+ import * as ops from "@mlx-bun/mlx/ops";
26
+ import { TRELLIS_L, TRELLIS_T, Trellis, packStates, wordsPerBlock } from "./trellis";
27
+
28
+ const S = gpuStream;
29
+
30
+ /** The packed form of a coded tensor. */
31
+ export interface PackedTrellisTensor {
32
+ codes: MlxArray;
33
+ scales: MlxArray;
34
+ }
35
+
36
+ export interface LdlqEncoding extends PackedTrellisTensor {
37
+ /** The feedback guard fired: the codes are the unweighted encoding instead. */
38
+ tripped: boolean;
39
+ }
40
+
41
+ /** tq-gptq.py's guard shape: the weighted path must stay within this multiple
42
+ * of the unweighted normalized magnitude. */
43
+ const LDLQ_CEILING = 4.0;
44
+ /** Hard abort when MLX's live allocation runs away: turns a silent OOM kill
45
+ * into a diagnosable failure. A down_proj tensor's working set is ~4 GiB. */
46
+ const DEFAULT_MEMORY_ABORT = 20 * 2 ** 30;
47
+
48
+ export interface TrellisEncoderOptions {
49
+ /** Coded rows per Viterbi batch (a memory knob; never changes the codes). */
50
+ batch?: number;
51
+ /** Abort LDLQ when MLX's live allocation exceeds this many bytes. */
52
+ memoryAbortBytes?: number;
53
+ }
54
+
55
+ /** Load the `L` tensor of a Hessian factor file (a safetensors with one tensor). */
56
+ export function loadHessianFactor(path: string): MlxArray {
57
+ const slot = new BigUint64Array([C.mlx_map_string_to_array_new()]);
58
+ const meta = new BigUint64Array([C.mlx_map_string_to_string_new()]);
59
+ try {
60
+ if (C.mlx_load_safetensors(ptr(slot), ptr(meta), ptr(Buffer.from(path + "\0", "utf8")), cpuStream) !== 0)
61
+ throw new Error(`mlx_load_safetensors(${path}) failed`);
62
+ const out = new BigUint64Array([C.mlx_array_new()]);
63
+ if (C.mlx_map_string_to_array_get(ptr(out), read.u64(ptr(slot), 0), ptr(Buffer.from("L\0", "utf8"))) !== 0)
64
+ throw new Error(`tensor L missing from ${path}`);
65
+ return new Arr(read.u64(ptr(out), 0));
66
+ } finally {
67
+ C.mlx_map_string_to_string_free(read.u64(ptr(meta), 0));
68
+ C.mlx_map_string_to_array_free(read.u64(ptr(slot), 0));
69
+ }
70
+ }
71
+
72
+ /** contiguous(transpose(x)) with the intermediate VIEW handle disposed. */
73
+ function tContig(x: MlxArray): MlxArray {
74
+ const t = ops.transposeAxes(x, [1, 0], S);
75
+ const c = ops.contiguous(t, S);
76
+ t.dispose();
77
+ return c;
78
+ }
79
+
80
+ /** Owned contiguous copy of a row range; the slice view is disposed. */
81
+ function rows(a: MlxArray, r0: number, r1: number, cols: number): MlxArray {
82
+ const sl = a.slice([r0, 0], [r1, cols], S);
83
+ const c = ops.contiguous(sl, S);
84
+ sl.dispose();
85
+ return c;
86
+ }
87
+
88
+ function maxAbs(a: MlxArray): number {
89
+ const ab = ops.abs(a, S);
90
+ const flat = ops.reshape(ab, [a.shape[0]! * a.shape[1]!], S);
91
+ const mx = ops.maxAxis(flat, 0, false, S);
92
+ const v = mx.toFloat32()[0]!;
93
+ ab.dispose(); flat.dispose(); mx.dispose();
94
+ return v;
95
+ }
96
+
97
+ /** [T, m] LDLQ tile <-> [m, T] trellis batch. axis 1: the trellis axis IS the
98
+ * LDLQ axis (transpose); axis 0 (down_proj): the tile splits into T-long runs
99
+ * down `m` (reshape only). Both reproduce the unweighted block partition. */
100
+ function tileToBatch(x: MlxArray, axis: 0 | 1, m: number, T: number): MlxArray {
101
+ return axis === 1 ? tContig(x) : ops.reshape(x, [m, T], S);
102
+ }
103
+ function batchToTile(b: MlxArray, axis: 0 | 1, m: number, T: number): MlxArray {
104
+ return axis === 1 ? tContig(b) : ops.reshape(b, [T, m], S);
105
+ }
106
+
107
+ /** Per-batch-row scale for one LDLQ block. gate/up: indexed by output row
108
+ * (block-independent). down_proj: indexed by input column, each of which owns
109
+ * m/T consecutive batch rows. */
110
+ function blockScale(scale: MlxArray, axis: 0 | 1, k: number, m: number, T: number): MlxArray {
111
+ if (axis === 1) return scale;
112
+ const sl = scale.slice([k * T, 0], [(k + 1) * T, 1], S);
113
+ const z = ops.zeros([T, m / T], Dtype.float32, S);
114
+ const b = ops.add(z, sl, S);
115
+ const out = ops.reshape(b, [m, 1], S);
116
+ sl.dispose(); z.dispose(); b.dispose();
117
+ return out;
118
+ }
119
+
120
+ /** Pack one T-block of states (row `i` of a [B,T] host state matrix) into `out`
121
+ * at word offset `at`. */
122
+ function packOne(states: Int32Array, i: number, T: number, k: number, out: Uint32Array, at: number): void {
123
+ packStates(states.subarray(i * T, (i + 1) * T), 1, T, k, out.subarray(at, at + wordsPerBlock(T, k)));
124
+ }
125
+
126
+ /** One codec per distinct k (the L=12 1MAD codebook is shared; k only changes
127
+ * the branching), created lazily so a uniform run builds exactly one. */
128
+ export class TrellisEncoder {
129
+ readonly L = TRELLIS_L;
130
+ readonly T = TRELLIS_T;
131
+ readonly #codecs = new Map<number, Trellis>();
132
+ readonly #batch: number;
133
+ readonly #memoryAbort: number;
134
+
135
+ constructor(opts: TrellisEncoderOptions = {}) {
136
+ this.#batch = opts.batch ?? 16384;
137
+ this.#memoryAbort = opts.memoryAbortBytes ?? DEFAULT_MEMORY_ABORT;
138
+ }
139
+
140
+ codec(k: number): Trellis {
141
+ let t = this.#codecs.get(k);
142
+ if (!t) this.#codecs.set(k, (t = new Trellis({ L: this.L, K: k, T: this.T, code: "1mad", tailBiting: true })));
143
+ return t;
144
+ }
145
+
146
+ dispose(): void {
147
+ for (const t of this.#codecs.values()) t.dispose();
148
+ this.#codecs.clear();
149
+ }
150
+
151
+ /** Unweighted encoding of a folded [out, in] weight along `axis`. */
152
+ encode(folded: MlxArray, axis: 0 | 1, k: number): PackedTrellisTensor {
153
+ const f32 = folded.astype(Dtype.float32, S);
154
+ let X = f32;
155
+ if (axis === 0) {
156
+ const t = ops.transposeAxes(f32, [1, 0], S);
157
+ X = ops.contiguous(t, S);
158
+ t.dispose(); f32.dispose();
159
+ }
160
+ ops.evalAll([X]);
161
+ const { rec, codes, scales } = this.codec(k).fakeQuantRowsPacked(X, this.#batch);
162
+ rec.dispose();
163
+ X.dispose();
164
+ clearCache();
165
+ return { codes, scales };
166
+ }
167
+
168
+ /** The per-coded-row fp16 scale, computed on the ORIGINAL folded weight so
169
+ * the weighted and unweighted arms share it exactly. In the LDLQ orientation:
170
+ * axis 1 -> [m,1] (per output row); axis 0 -> [n,1] (per input column).
171
+ * `Wt` is [n, m]. */
172
+ #rowScale(Wt: MlxArray, axis: 0 | 1, k: number): MlxArray {
173
+ const src = axis === 1 ? tContig(Wt) : Wt;
174
+ const sq = ops.square(src, S);
175
+ const ms = ops.meanAxis(sq, 1, true, S);
176
+ const rms = ops.sqrt(ms, S);
177
+ const inv = ops.mulScalar(rms, 1 / this.codec(k).lutRms, S);
178
+ const zero = Arr.fromFloat32(new Float32Array([0]), []);
179
+ const one = Arr.fromFloat32(new Float32Array([1]), []);
180
+ const isz = ops.equal(inv, zero, S);
181
+ const guarded = ops.where(isz, one, inv, S);
182
+ const s16 = guarded.astype(Dtype.float16, S);
183
+ const out = s16.astype(Dtype.float32, S);
184
+ for (const a of [sq, ms, rms, inv, zero, one, isz, guarded, s16]) a.dispose();
185
+ if (axis === 1) src.dispose();
186
+ ops.evalAll([out]);
187
+ return out;
188
+ }
189
+
190
+ /** BlockLDLQ encoding of a folded weight against Hessian factor `Lf`
191
+ * ([n, n], n = the LDLQ dim). Falls back to the unweighted encoding, with
192
+ * `tripped` set, when the feedback grows past the guard ceiling. */
193
+ encodeLdlq(folded: MlxArray, axis: 0 | 1, Lf: MlxArray, k: number): LdlqEncoding {
194
+ const T = this.T;
195
+ const f32 = folded.astype(Dtype.float32, S);
196
+ const Wt = tContig(f32); // [n, m]
197
+ f32.dispose();
198
+ ops.evalAll([Wt]);
199
+ const [n, m] = Wt.shape as [number, number];
200
+ if (Lf.shape[0] !== n) throw new Error(`L dim ${Lf.shape[0]} != LDLQ dim ${n}`);
201
+ const K = n / T;
202
+ const scale = this.#rowScale(Wt, axis, k);
203
+
204
+ // Guard ceiling from the UNWEIGHTED normalized weight.
205
+ let ceiling: number;
206
+ {
207
+ const s0 = blockScale(scale, axis, 0, m, T);
208
+ const w0 = rows(Wt, 0, T, m);
209
+ const b0 = tileToBatch(w0, axis, m, T);
210
+ const nrm = ops.div(b0, s0, S);
211
+ ceiling = LDLQ_CEILING * maxAbs(nrm);
212
+ w0.dispose(); b0.dispose(); nrm.dispose();
213
+ if (axis === 0) s0.dispose();
214
+ }
215
+
216
+ let prod = ops.zeros([n, m], Dtype.float32, S);
217
+ let What = ops.zeros([n, m], Dtype.float32, S);
218
+ ops.evalAll([prod, What]);
219
+ let tripped = false;
220
+ // Packed geometry: gate/up (axis 1) rows = m output rows, one block per LDLQ
221
+ // block; down (axis 0) rows = n input columns, m/T blocks each (batch row i
222
+ // of block b = input col b·T + i div (m/T), out-block i mod (m/T)).
223
+ const wpb = wordsPerBlock(T, k);
224
+ const codeRows = axis === 1 ? m : n;
225
+ const blocksPerRow = axis === 1 ? K : m / T;
226
+ const packed = new Uint32Array(codeRows * blocksPerRow * wpb);
227
+ const codec = this.codec(k);
228
+
229
+ for (let b = K - 1; b >= 0; b--) {
230
+ const w = rows(Wt, b * T, (b + 1) * T, m);
231
+ const p = rows(prod, b * T, (b + 1) * T, m);
232
+ const x = ops.add(w, p, S);
233
+ p.dispose();
234
+ const s = blockScale(scale, axis, b, m, T);
235
+ const batch = tileToBatch(x, axis, m, T);
236
+ const xn = ops.div(batch, s, S);
237
+ ops.evalAll([xn]);
238
+ batch.dispose(); x.dispose();
239
+
240
+ const peak = maxAbs(xn);
241
+ if (!Number.isFinite(peak) || peak > ceiling) {
242
+ tripped = true;
243
+ for (const y of [w, xn]) y.dispose();
244
+ if (axis === 0) s.dispose();
245
+ break;
246
+ }
247
+
248
+ // Chunked encode that keeps the STATES: pack them, then decode for the
249
+ // error feedback (the same map encodeDecode applies).
250
+ const parts: MlxArray[] = [];
251
+ for (let r0 = 0; r0 < m; r0 += this.#batch) {
252
+ const r1 = Math.min(m, r0 + this.#batch);
253
+ const sl = xn.slice([r0, 0], [r1, T], S);
254
+ const chunk = ops.contiguous(sl, S);
255
+ sl.dispose();
256
+ ops.evalAll([chunk]);
257
+ const idx = codec.encodeStates(chunk);
258
+ chunk.dispose();
259
+ const u32 = idx.astype(Dtype.uint32, S);
260
+ const raw = u32.rawBytes();
261
+ u32.dispose();
262
+ const st = new Int32Array(raw.buffer, raw.byteOffset, raw.byteLength / 4);
263
+ for (let i = 0; i < r1 - r0; i++) {
264
+ const bi = r0 + i;
265
+ const row = axis === 1 ? bi : b * T + Math.floor(bi / (m / T));
266
+ const blk = axis === 1 ? b : bi % (m / T);
267
+ packOne(st, i, T, k, packed, (row * blocksPerRow + blk) * wpb);
268
+ }
269
+ parts.push(codec.decodeStates(idx));
270
+ idx.dispose();
271
+ }
272
+ const rec = parts.length === 1 ? parts[0]! : ops.concatAxis(parts, 0, S);
273
+ if (parts.length > 1) for (const part of parts) part.dispose();
274
+ ops.evalAll([rec]);
275
+ xn.dispose();
276
+ const hatB = ops.mul(rec, s, S);
277
+ rec.dispose();
278
+ if (axis === 0) s.dispose();
279
+ const hat = batchToTile(hatB, axis, m, T);
280
+ hatB.dispose();
281
+
282
+ // The error fed forward is (W − Ŵ) against the ORIGINAL weight, per ldlq.py.
283
+ const err = ops.sub(w, hat, S);
284
+ w.dispose();
285
+ const nw = ops.sliceUpdate(What, hat, [b * T, 0], [(b + 1) * T, m], S);
286
+ What.dispose(); hat.dispose();
287
+ What = nw;
288
+
289
+ // prod += L[block, :]ᵀ @ err ([n,T] @ [T,m])
290
+ const Lb = rows(Lf, b * T, (b + 1) * T, n);
291
+ const Lt = tContig(Lb);
292
+ const contrib = ops.matmul(Lt, err, S);
293
+ const next = ops.add(prod, contrib, S);
294
+ prod.dispose();
295
+ prod = next;
296
+ ops.evalAll([prod, What]);
297
+ for (const y of [err, Lb, Lt, contrib]) y.dispose();
298
+ clearCache();
299
+ if (activeMemory() > this.#memoryAbort)
300
+ throw new Error(
301
+ `LDLQ leak guard: mlx active ${(activeMemory() / 2 ** 30).toFixed(1)} GiB > ` +
302
+ `${(this.#memoryAbort / 2 ** 30).toFixed(0)} GiB at block ${b} — aborting instead of OOM-killing`,
303
+ );
304
+ }
305
+
306
+ prod.dispose();
307
+ if (tripped) {
308
+ scale.dispose();
309
+ Wt.dispose(); What.dispose();
310
+ clearCache();
311
+ return { ...this.encode(folded, axis, k), tripped: true };
312
+ }
313
+ Wt.dispose(); What.dispose();
314
+ // The fp16-rounded per-coded-row scale in LDLQ orientation is exactly the
315
+ // packed rows' order.
316
+ const s16 = scale.astype(Dtype.float16, S);
317
+ scale.dispose();
318
+ const scales = ops.reshape(s16, [codeRows], S);
319
+ s16.dispose();
320
+ ops.evalAll([scales]);
321
+ const codes = Arr.fromBytesCopy(
322
+ new Uint8Array(packed.buffer, packed.byteOffset, packed.byteLength),
323
+ [codeRows, blocksPerRow * wpb], Dtype.uint32,
324
+ );
325
+ clearCache();
326
+ return { codes, scales, tripped: false };
327
+ }
328
+ }