Skip to content

Commit 06b1bac

Browse files
kmbandyclaude
andcommitted
fix(MAD-223): default fit_loss=mse + add Y_SNR metric for GPTQ evaluation
Two related fixes diagnosed via scripts/calibration/diagnose_calibration.py: 1. fit_loss="mag_weighted" p=5 (inherited from MAD-214) is wrong for WEIGHTS. That recipe was the winner for KV-CACHE ACTIVATIONS which have heavy outliers — pushing centroids to the tails protects the big values. WEIGHTS are roughly Gaussian centered at zero; mag_p5 starves the dense center where most of the data is. On Qwen3.5-4B layer 0 mlp.gate_proj with 32 calib samples: - naive snap + mse: Y_SNR 22.07 dB - naive snap + mag_p5: Y_SNR 11.44 dB (-10 dB!) 2. The previous element-wise SNR metric was the WRONG yardstick for GPTQ. GPTQ deliberately "smears" individual weight elements to minimize OUTPUT-SPACE reconstruction error (what matters for inference quality), not element-wise weight matching. Naive snap beats GPTQ on element-wise SNR by design, but GPTQ wins by 5+ dB on output-space SNR (trace((Q-W) H (Q-W)^T) / trace(W H W^T)): - naive snap + mse: Y_SNR 22.07 dB W_SNR 20.55 dB - GPTQ + mse: Y_SNR 27.67 dB W_SNR 17.38 dB GPTQ adds 5.6 dB to Y_SNR (= ~3x better output match) while "regressing" 3 dB on the misleading W_SNR. This is GPTQ working exactly as designed. Changes: - calibrate_ml8.py: default --fit-loss now "mse" (was "mag_weighted"). Per-layer log + manifest now reports BOTH w_snr_db and y_snr_db. Y_SNR is the meaningful one for inference quality. - diagnose_calibration.py: new script that runs 5 quantization variants (uniform INT4, naive ± mse/mag_p5, GPTQ ± mse/mag_p5) on the same matrix and reports both metrics — what we used to diagnose both bugs above. Verified: Qwen3.5-4B layer 0 with MSE+GPTQ defaults gives: - mlp.gate_proj: Y_SNR 27.7 dB - mlp.up_proj: Y_SNR 26.6 dB - mlp.down_proj: Y_SNR 29.6 dB Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 494d6a6 commit 06b1bac

2 files changed

Lines changed: 289 additions & 13 deletions

File tree

scripts/calibration/calibrate_ml8.py

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,10 @@ def gptq_quantize_linear(layer: nn.Linear, H: torch.Tensor,
157157
W = layer.weight.data.float().to(dev).clone() # [rows, in_features]
158158
out_rows, in_features = W.shape
159159

160+
# Save undamped H for the post-quant Y_SNR metric (the damping is for
161+
# numerical stability; it's not part of the actual reconstruction loss).
162+
H_orig = H.clone()
163+
160164
# Damping — needed for numerical stability in Cholesky
161165
damp = percdamp * torch.mean(torch.diag(H))
162166
diag_idx = torch.arange(in_features, device=dev)
@@ -192,22 +196,34 @@ def gptq_quantize_linear(layer: nn.Linear, H: torch.Tensor,
192196
# Vectorized: err [rows, 1] * Hinv_chol[col, col+1:] [in_features - col - 1]
193197
W[:, col+1:] -= err * Hinv_chol[col, col+1:].unsqueeze(0)
194198

195-
# Reconstruction quality (before in-place replacement).
196-
# SNR_dB = 10 * log10(signal_power / noise_power). Higher is better.
197-
# 4-bit centroid quant typically targets >20 dB for good preservation.
199+
# Reconstruction quality. TWO metrics:
200+
# - W_SNR: element-wise weight reconstruction. What naive snap optimizes.
201+
# - Y_SNR: output-space reconstruction weighted by H. What GPTQ optimizes.
202+
# Y_SNR is the one that matters for inference quality (it measures how
203+
# close the layer's OUTPUT activations are to the original layer's).
204+
# Per the diagnose_calibration.py findings, MSE+GPTQ should give Y_SNR
205+
# >25 dB on Qwen-class MLP linears.
198206
import math
199207
orig = layer.weight.data.float().to(dev)
200-
mse = (orig - Q).pow(2).mean().item()
201-
signal_power = orig.pow(2).mean().clamp_min(1e-30).item()
202-
snr_db = 10.0 * math.log10(signal_power / max(mse, 1e-30))
203-
rel_err = (mse / signal_power) ** 0.5 # normalized RMSE (signal-rms-relative)
208+
diff = orig - Q
209+
# Element-wise
210+
mse = diff.pow(2).mean().item()
211+
sig_w = orig.pow(2).mean().clamp_min(1e-30).item()
212+
w_snr_db = 10.0 * math.log10(sig_w / max(mse, 1e-30))
213+
# Output-space (use H_orig — undamped — so we measure actual reconstruction
214+
# loss, not the regularized one we used during Cholesky).
215+
err_y = (diff @ H_orig @ diff.t()).diagonal().sum().item()
216+
sig_y = (orig @ H_orig @ orig.t()).diagonal().sum().clamp_min(1e-30).item()
217+
y_snr_db = 10.0 * math.log10(sig_y / max(err_y, 1e-30))
218+
rel_err = (mse / sig_w) ** 0.5
204219

205220
# Replace weights so the next layer's calibration sees quantized output
206221
layer.weight.data.copy_(Q.to(layer.weight.dtype))
207222

208223
export = quantizer.export()
209224
export["mse"] = mse
210-
export["snr_db"] = snr_db
225+
export["w_snr_db"] = w_snr_db
226+
export["y_snr_db"] = y_snr_db
211227
export["rel_err"] = rel_err
212228
return export
213229

@@ -224,7 +240,14 @@ def main():
224240
p.add_argument("--percdamp", type=float, default=0.01)
225241
p.add_argument("--n-centroids", type=int, default=16)
226242
p.add_argument("--n-iter", type=int, default=25)
227-
p.add_argument("--fit-loss", choices=("mse", "mag_weighted"), default="mag_weighted")
243+
# DEFAULT FIT_LOSS: MSE for weights (not mag_weighted).
244+
# The MAD-214 winner `mag_weighted p=5` was fit on KV-CACHE ACTIVATIONS,
245+
# which have heavy outliers. Weights are roughly Gaussian centered at zero
246+
# — mag_weighted p=5 pushes centroids to empty tails and starves the
247+
# dense center. Diagnosed via scripts/calibration/diagnose_calibration.py:
248+
# for Qwen3.5-4B layer 0 mlp.gate_proj, MSE gave 27.67 dB output-SNR vs
249+
# mag_p5's 17.01 dB. 10 dB difference.
250+
p.add_argument("--fit-loss", choices=("mse", "mag_weighted"), default="mse")
228251
p.add_argument("--mag-weight-p", type=float, default=5.0)
229252
p.add_argument("--device", default="cuda:0")
230253
p.add_argument("--dtype", choices=("float16", "bfloat16"), default="float16")
@@ -289,22 +312,24 @@ def main():
289312
"indices": export["indices"].cpu(),
290313
"centroids_per_group": export["centroids_per_group"].cpu(),
291314
"mse": export["mse"],
292-
"snr_db": export["snr_db"],
315+
"w_snr_db": export["w_snr_db"],
316+
"y_snr_db": export["y_snr_db"],
293317
"rel_err": export["rel_err"],
294318
}, out_path)
295319

296320
print(f" saved: {out_path.name} "
297321
f"groups={export['centroids_per_group'].shape[0]} "
298-
f"SNR={export['snr_db']:.1f}dB "
299-
f"rel_err={export['rel_err']:.3%} "
322+
f"Y_SNR={export['y_snr_db']:.1f}dB "
323+
f"W_SNR={export['w_snr_db']:.1f}dB "
300324
f"t_quant={t_quant:.1f}s")
301325

302326
manifest["results"].append({
303327
"name": name,
304328
"shape": [rows, in_feat],
305329
"n_groups": int(export["centroids_per_group"].shape[0]),
306330
"mse": float(export["mse"]),
307-
"snr_db": float(export["snr_db"]),
331+
"w_snr_db": float(export["w_snr_db"]),
332+
"y_snr_db": float(export["y_snr_db"]),
308333
"rel_err": float(export["rel_err"]),
309334
"t_hess_s": float(t_hess),
310335
"t_quant_s": float(t_quant),
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
#!/usr/bin/env python3
2+
"""diagnose_calibration.py — disambiguate where the SNR loss is coming from.
3+
4+
Runs THREE quantizations on the SAME weight matrix from Qwen3.5-4B layer 0:
5+
6+
(1) UNIFORM INT4 — 16 evenly-spaced levels per row, no Lloyd-Max,
7+
no error propagation. Simplest possible 4-bit quant baseline.
8+
9+
(2) CENTROID NAIVE — CentroidQuantizer LUT + per-row scale, but NO
10+
GPTQ error propagation. Just snap each column independently.
11+
12+
(3) CENTROID GPTQ — full CentroidQuantizer + GPTQ error propagation
13+
(= what calibrate_ml8.py does today).
14+
15+
Each variant runs with multiple fit_loss settings to also test the
16+
hypothesis that mag_weighted p=5 (the MAD-214 KV winner) is wrong for
17+
weights (which are Gaussian-near-zero, not heavy-tailed like activations).
18+
19+
Decision matrix:
20+
- (1) better than (2) → CentroidQuantizer / Lloyd-Max is broken
21+
- (2) better than (3) → GPTQ error propagation is broken or wrong-sign
22+
- mse better than mag_p5 → confirms mag_weighted is wrong loss for weights
23+
- all of (1)(2)(3) at low SNR → fundamentally wrong scale or shape assumption
24+
25+
Usage:
26+
python3 scripts/calibration/diagnose_calibration.py \\
27+
--model Qwen/Qwen3.5-4B --n-samples 32 --seq-len 1024
28+
"""
29+
30+
from __future__ import annotations
31+
32+
import argparse
33+
import math
34+
import sys
35+
from pathlib import Path
36+
37+
import torch
38+
from torch import nn
39+
from transformers import AutoModelForCausalLM, AutoTokenizer
40+
41+
sys.path.insert(0, str(Path(__file__).parent))
42+
from centroid_quantizer import CentroidQuantizer # noqa: E402
43+
44+
45+
def snr_db(orig: torch.Tensor, recon: torch.Tensor) -> tuple[float, float, float]:
46+
"""Element-wise SNR_dB, MSE, rel_err. (What naive snap optimizes.)"""
47+
orig_f = orig.float()
48+
recon_f = recon.float()
49+
mse = (orig_f - recon_f).pow(2).mean().item()
50+
sig_pow = orig_f.pow(2).mean().clamp_min(1e-30).item()
51+
snr = 10.0 * math.log10(sig_pow / max(mse, 1e-30))
52+
rel = (mse / sig_pow) ** 0.5
53+
return snr, mse, rel
54+
55+
56+
def output_snr_db(orig: torch.Tensor, recon: torch.Tensor, H: torch.Tensor) -> tuple[float, float, float]:
57+
"""Output-space (activation-space) SNR_dB, weighted by Hessian.
58+
59+
This is what GPTQ actually optimizes:
60+
loss = trace((Q - W) H (Q - W)^T) / trace(W H W^T)
61+
62+
Equivalent to comparing layer OUTPUTS rather than weight matrix elements.
63+
"""
64+
diff = (orig - recon).float()
65+
W = orig.float()
66+
# numerator: sum over rows of diff_row^T H diff_row
67+
err_pow = (diff @ H @ diff.t()).diagonal().sum().item()
68+
sig_pow = (W @ H @ W.t()).diagonal().sum().clamp_min(1e-30).item()
69+
snr = 10.0 * math.log10(sig_pow / max(err_pow, 1e-30))
70+
rel = (err_pow / sig_pow) ** 0.5
71+
return snr, err_pow, rel
72+
73+
74+
# ───────────────────────────── Calibration ──────────────────────────────────
75+
76+
def collect_wikitext(tokenizer, n_samples: int, seq_len: int) -> list[torch.Tensor]:
77+
from datasets import load_dataset
78+
ds = load_dataset("Salesforce/wikitext", "wikitext-2-raw-v1", split="train")
79+
samples = []
80+
for row in ds:
81+
text = (row.get("text") or "").strip()
82+
if not text:
83+
continue
84+
ids = tokenizer(text, return_tensors="pt", truncation=True,
85+
max_length=seq_len).input_ids
86+
if ids.shape[1] < seq_len // 4:
87+
continue
88+
samples.append(ids)
89+
if len(samples) >= n_samples:
90+
break
91+
return samples
92+
93+
94+
@torch.no_grad()
95+
def compute_hessian(layer, calib, model, dev):
96+
H_acc = None
97+
n = 0
98+
def hook(_m, inputs, _o):
99+
nonlocal H_acc, n
100+
x = inputs[0].detach().reshape(-1, inputs[0].shape[-1]).float()
101+
XtX = x.t() @ x
102+
if H_acc is None:
103+
H_acc = XtX
104+
else:
105+
H_acc += XtX
106+
n += x.shape[0]
107+
h = layer.register_forward_hook(hook)
108+
try:
109+
for ids in calib:
110+
model(ids.to(dev))
111+
finally:
112+
h.remove()
113+
return H_acc / max(n, 1), n
114+
115+
116+
# ───────────────────────────── Quantizers ───────────────────────────────────
117+
118+
def quant_uniform_int4(W: torch.Tensor) -> torch.Tensor:
119+
"""16 evenly-spaced signed levels [-1, 1] per row. Symmetric per-row scale."""
120+
W = W.float()
121+
# Per-row max abs → scale
122+
scale = W.abs().max(dim=1, keepdim=True).values.clamp_min(1e-8)
123+
# 16 levels span [-1, 1]: centers at (-7.5, -6.5, ..., 6.5, 7.5) / 7.5
124+
levels = (torch.arange(16, device=W.device, dtype=torch.float32) - 7.5) / 7.5
125+
x_norm = W / scale # [rows, cols]
126+
# Distance to each level
127+
dist = (x_norm.unsqueeze(-1) - levels).abs()
128+
idx = dist.argmin(dim=-1)
129+
return levels[idx] * scale
130+
131+
132+
def quant_centroid_naive(W: torch.Tensor, group_size: int, fit_loss: str,
133+
mag_p: float = 5.0, dev=None) -> torch.Tensor:
134+
"""CentroidQuantizer per-group, no GPTQ propagation. Just snap."""
135+
if dev is None:
136+
dev = W.device
137+
rows, cols = W.shape
138+
Q = torch.zeros_like(W, device=dev)
139+
q = CentroidQuantizer(n_centroids=16, n_iter=25).to(dev)
140+
q.configure(bits=4, sym=True, fit_loss=fit_loss, mag_weight_p=mag_p)
141+
for g_start in range(0, cols, group_size):
142+
g_end = min(g_start + group_size, cols)
143+
q.set_group_offset(g_start)
144+
q.find_params(W[:, g_start:g_end])
145+
for c in range(g_start, g_end):
146+
Q[:, c:c+1] = q.quantize(W[:, c:c+1])
147+
return Q
148+
149+
150+
def quant_centroid_gptq(W: torch.Tensor, H: torch.Tensor, group_size: int,
151+
fit_loss: str, mag_p: float = 5.0,
152+
percdamp: float = 0.01) -> torch.Tensor:
153+
"""Full GPTQ loop with CentroidQuantizer."""
154+
dev = H.device
155+
W = W.float().to(dev).clone()
156+
rows, cols = W.shape
157+
H = H.clone()
158+
damp = percdamp * torch.mean(torch.diag(H))
159+
diag = torch.arange(cols, device=dev)
160+
H[diag, diag] += damp
161+
L = torch.linalg.cholesky(H)
162+
H_inv = torch.cholesky_inverse(L)
163+
Hinv_chol = torch.linalg.cholesky(H_inv, upper=True)
164+
q = CentroidQuantizer(n_centroids=16, n_iter=25).to(dev)
165+
q.configure(bits=4, sym=True, fit_loss=fit_loss, mag_weight_p=mag_p)
166+
Q = torch.zeros_like(W)
167+
for col in range(cols):
168+
if col % group_size == 0:
169+
g_end = min(col + group_size, cols)
170+
q.set_group_offset(col)
171+
q.find_params(W[:, col:g_end])
172+
w = W[:, col:col+1]
173+
d = Hinv_chol[col, col]
174+
qv = q.quantize(w)
175+
Q[:, col:col+1] = qv
176+
err = (w - qv) / d
177+
if col + 1 < cols:
178+
W[:, col+1:] -= err * Hinv_chol[col, col+1:].unsqueeze(0)
179+
return Q
180+
181+
182+
# ───────────────────────────── Driver ───────────────────────────────────────
183+
184+
def main():
185+
p = argparse.ArgumentParser()
186+
p.add_argument("--model", default="Qwen/Qwen3.5-4B")
187+
p.add_argument("--n-samples", type=int, default=32)
188+
p.add_argument("--seq-len", type=int, default=1024)
189+
p.add_argument("--group-size", type=int, default=128)
190+
p.add_argument("--device", default="cuda:0")
191+
p.add_argument("--target", default="model.layers.0.mlp.gate_proj")
192+
args = p.parse_args()
193+
194+
print(f"[load] {args.model}")
195+
tok = AutoTokenizer.from_pretrained(args.model)
196+
model = AutoModelForCausalLM.from_pretrained(args.model, torch_dtype=torch.float16)
197+
model = model.to(args.device).eval()
198+
199+
print(f"[calib] {args.n_samples} samples × {args.seq_len} tokens")
200+
calib = collect_wikitext(tok, args.n_samples, args.seq_len)
201+
202+
# Find target
203+
layer = None
204+
for name, mod in model.named_modules():
205+
if name == args.target:
206+
layer = mod
207+
break
208+
if layer is None:
209+
print(f"target not found: {args.target}", file=sys.stderr)
210+
return 1
211+
W_orig = layer.weight.data.float().clone().to(args.device)
212+
print(f"[target] {args.target} shape={tuple(W_orig.shape)} "
213+
f"|W|_mean={W_orig.abs().mean().item():.4g} "
214+
f"W²_mean={W_orig.pow(2).mean().item():.4g}")
215+
216+
print(f"[hessian] collecting from {len(calib)} samples...")
217+
H, n_tok = compute_hessian(layer, calib, model, args.device)
218+
print(f" H {tuple(H.shape)} diag_mean={H.diag().mean().item():.4g} n_tok={n_tok}")
219+
220+
# ── Run all variants on the SAME starting W ──
221+
print()
222+
print(f"{'variant':40s} {'W_SNR':>8s} {'Y_SNR':>8s} {'W_relerr':>9s} {'Y_relerr':>9s}")
223+
print("─" * 82)
224+
print("(W_SNR = element-wise weight SNR; Y_SNR = output-space SNR weighted by H)")
225+
print("(GPTQ optimizes Y_SNR. Naive snap optimizes W_SNR.)")
226+
print()
227+
228+
variants = [
229+
("1. Uniform INT4 (no LUT, no GPTQ)",
230+
lambda: quant_uniform_int4(W_orig)),
231+
("2a. Centroid naive, fit_loss=mse",
232+
lambda: quant_centroid_naive(W_orig, args.group_size, "mse")),
233+
("2b. Centroid naive, fit_loss=mag_p5",
234+
lambda: quant_centroid_naive(W_orig, args.group_size, "mag_weighted", 5.0)),
235+
("3a. Centroid + GPTQ, fit_loss=mse",
236+
lambda: quant_centroid_gptq(W_orig, H, args.group_size, "mse")),
237+
("3b. Centroid + GPTQ, fit_loss=mag_p5",
238+
lambda: quant_centroid_gptq(W_orig, H, args.group_size, "mag_weighted", 5.0)),
239+
]
240+
241+
for name, fn in variants:
242+
Q = fn()
243+
w_snr, _, w_rel = snr_db(W_orig, Q)
244+
y_snr, _, y_rel = output_snr_db(W_orig, Q, H)
245+
print(f"{name:40s} {w_snr:>7.2f} {y_snr:>7.2f} {w_rel:>8.2%} {y_rel:>8.2%}")
246+
247+
return 0
248+
249+
250+
if __name__ == "__main__":
251+
sys.exit(main())

0 commit comments

Comments
 (0)