Skip to content

Commit 494d6a6

Browse files
kmbandyclaude
andcommitted
wip(MAD-223): calibrate_ml8.py driver with minimal GPTQ loop
Phase B.2. Drives CentroidQuantizer through a HF model using a self-contained GPTQ implementation (auto-gptq doesn't install on Python 3.14; gptqmodel hits torchao .so load errors). Algorithm: 1. Load model + tokenizer via transformers 2. Collect Hessian H = (1/N) sum X X^T per linear via forward hooks 3. Cholesky-of-H-inverse for triangular error propagation 4. Per column: snap via CentroidQuantizer, propagate error to remaining columns via standard GPTQ formula 5. Replace layer.weight.data so subsequent layers calibrate against quantized prior layers (matches auto-gptq pattern) 6. Save indices + centroids per group + SNR/rel-err per linear Also includes the previously-untracked CentroidQuantizer (200 LOC, MAD-223 Phase B.3) — signed-16 LUT Lloyd-Max with optional mag_weighted (p=5 default from MAD-214 winner recipe) and Hessian-diag weighting. MVP validated end-to-end on Qwen3.5-4B layer 0 (gate_proj, up_proj, down_proj). Pipeline runs cleanly; SNR currently low (~6 dB) on small calibration (8 samples × 512 tokens), likely Hessian under-sampling. Next: scale calibration data + verify GPTQ vs naive-snap to disambiguate. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 690fed0 commit 494d6a6

2 files changed

Lines changed: 586 additions & 0 deletions

File tree

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
#!/usr/bin/env python3
2+
"""calibrate_ml8.py — drive CentroidQuantizer through a HF model via a minimal GPTQ loop.
3+
4+
MAD-223 Phase B.2.
5+
6+
Replaces auto-gptq (which doesn't install on Python 3.14) with a self-contained
7+
GPTQ implementation. The hard part — Lloyd-Max centroid fitting — is in
8+
CentroidQuantizer; this driver provides:
9+
10+
1. Calibration data collection (wikitext-2 via HF datasets)
11+
2. Per-layer Hessian accumulation via forward hooks
12+
3. Per-column GPTQ snap + error propagation loop
13+
4. In-place weight replacement so subsequent layers calibrate against
14+
the QUANTIZED activations from prior layers (matches auto-gptq pattern)
15+
5. Per-linear save (indices + centroids per group) + manifest
16+
17+
Usage:
18+
python3 scripts/calibration/calibrate_ml8.py \\
19+
--model Qwen/Qwen3.5-4B \\
20+
--output-dir /tmp/ml8-qwen3-4b \\
21+
--n-samples 64 \\
22+
--seq-len 2048 \\
23+
--group-size 128 \\
24+
--max-layers 1 # MVP: validate pipeline on first layer
25+
26+
Algorithm sketch (per linear with weight W [rows, in_features] and Hessian H):
27+
H = (1/N) sum_i x_i x_i^T # in_features x in_features
28+
H += damp * mean(diag(H)) * I
29+
L_inv = cholesky(H^-1, upper=True)
30+
for col in range(in_features):
31+
if col % group_size == 0:
32+
quantizer.find_params(W[:, col:col+group_size])
33+
q = quantizer.quantize(W[:, col:col+1])
34+
err = (W[:, col:col+1] - q) / L_inv[col, col]
35+
W[:, col+1:] -= err * L_inv[col, col+1:]
36+
"""
37+
38+
from __future__ import annotations
39+
40+
import argparse
41+
import json
42+
import os
43+
import sys
44+
import time
45+
from pathlib import Path
46+
47+
import torch
48+
from torch import nn
49+
from transformers import AutoModelForCausalLM, AutoTokenizer
50+
51+
# Make centroid_quantizer importable
52+
sys.path.insert(0, str(Path(__file__).parent))
53+
from centroid_quantizer import CentroidQuantizer # noqa: E402
54+
55+
56+
# ───────────────────────────── Calibration data ─────────────────────────────
57+
58+
def collect_wikitext_calibration(tokenizer, n_samples: int = 64, seq_len: int = 2048,
59+
dataset_name: str = "Salesforce/wikitext",
60+
config: str = "wikitext-2-raw-v1") -> list[torch.Tensor]:
61+
"""Return list of input_ids tensors, each [1, seq_len]."""
62+
from datasets import load_dataset
63+
ds = load_dataset(dataset_name, config, split="train")
64+
samples = []
65+
for row in ds:
66+
text = (row.get("text") or "").strip()
67+
if not text:
68+
continue
69+
ids = tokenizer(text, return_tensors="pt", truncation=True,
70+
max_length=seq_len).input_ids
71+
# Skip very short samples — calibration quality hurts
72+
if ids.shape[1] < seq_len // 4:
73+
continue
74+
samples.append(ids)
75+
if len(samples) >= n_samples:
76+
break
77+
return samples
78+
79+
80+
# ───────────────────────────── Target selection ─────────────────────────────
81+
82+
def find_target_linears(model):
83+
"""Yield (name, module) for each Linear we want to quantize.
84+
85+
For Qwen-class dense models, this is the MLP linears per transformer layer:
86+
mlp.gate_proj, mlp.up_proj, mlp.down_proj. Attention projections are
87+
skipped for tonight's MVP (they're a smaller fraction of weights and have
88+
different sensitivity).
89+
"""
90+
for name, mod in model.named_modules():
91+
if not isinstance(mod, nn.Linear):
92+
continue
93+
if any(k in name for k in ("mlp.gate_proj", "mlp.up_proj", "mlp.down_proj")):
94+
yield name, mod
95+
96+
97+
def filter_by_layer_limit(targets, max_layers: int | None):
98+
if max_layers is None:
99+
return targets
100+
keep = []
101+
for name, mod in targets:
102+
for i in range(max_layers):
103+
# Match ".layers.<i>." with bounded digits
104+
tag = f".layers.{i}."
105+
if tag in name:
106+
keep.append((name, mod))
107+
break
108+
return keep
109+
110+
111+
# ───────────────────────────── Hessian collection ───────────────────────────
112+
113+
@torch.no_grad()
114+
def compute_hessian(layer: nn.Linear, calibration_ids: list[torch.Tensor],
115+
model, dev: str) -> tuple[torch.Tensor, int]:
116+
"""Collect H = (1/N) sum X X^T (in_features x in_features) for `layer`."""
117+
H_acc = None
118+
n_total = 0
119+
120+
def hook(module, inputs, output):
121+
nonlocal H_acc, n_total
122+
x = inputs[0].detach()
123+
x = x.reshape(-1, x.shape[-1]).float() # [N, in_features]
124+
XtX = x.t() @ x
125+
if H_acc is None:
126+
H_acc = XtX
127+
else:
128+
H_acc += XtX
129+
n_total += x.shape[0]
130+
131+
h = layer.register_forward_hook(hook)
132+
try:
133+
for ids in calibration_ids:
134+
model(ids.to(dev))
135+
finally:
136+
h.remove()
137+
138+
if H_acc is None:
139+
raise RuntimeError(f"No activations collected for {layer}")
140+
return H_acc / max(n_total, 1), n_total
141+
142+
143+
# ───────────────────────────── GPTQ loop ────────────────────────────────────
144+
145+
@torch.no_grad()
146+
def gptq_quantize_linear(layer: nn.Linear, H: torch.Tensor,
147+
quantizer: CentroidQuantizer,
148+
group_size: int = 128,
149+
percdamp: float = 0.01) -> dict:
150+
"""Quantize `layer.weight` in-place using the GPTQ algorithm.
151+
152+
Returns the quantizer's export dict (indices + centroids_per_group).
153+
`layer.weight.data` is replaced with the dequantized values so subsequent
154+
layers see the post-quantization output.
155+
"""
156+
dev = H.device
157+
W = layer.weight.data.float().to(dev).clone() # [rows, in_features]
158+
out_rows, in_features = W.shape
159+
160+
# Damping — needed for numerical stability in Cholesky
161+
damp = percdamp * torch.mean(torch.diag(H))
162+
diag_idx = torch.arange(in_features, device=dev)
163+
H[diag_idx, diag_idx] += damp
164+
165+
# Cholesky of H^-1 (upper triangular). This is the standard GPTQ
166+
# decomposition for triangular error propagation.
167+
try:
168+
L_lower = torch.linalg.cholesky(H)
169+
H_inv = torch.cholesky_inverse(L_lower)
170+
Hinv_chol = torch.linalg.cholesky(H_inv, upper=True) # upper triangular
171+
except RuntimeError as e:
172+
raise RuntimeError(f"Cholesky failed (try higher percdamp): {e}") from e
173+
174+
quantizer.reset_capture()
175+
Q = torch.zeros_like(W)
176+
177+
for col in range(in_features):
178+
# Fit centroids at each group boundary
179+
if col % group_size == 0:
180+
g_end = min(col + group_size, in_features)
181+
quantizer.set_group_offset(col)
182+
quantizer.find_params(W[:, col:g_end])
183+
184+
w = W[:, col:col+1] # [rows, 1]
185+
d = Hinv_chol[col, col]
186+
q = quantizer.quantize(w) # [rows, 1] dequantized
187+
Q[:, col:col+1] = q
188+
189+
# GPTQ error propagation
190+
err = (w - q) / d
191+
if col + 1 < in_features:
192+
# Vectorized: err [rows, 1] * Hinv_chol[col, col+1:] [in_features - col - 1]
193+
W[:, col+1:] -= err * Hinv_chol[col, col+1:].unsqueeze(0)
194+
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.
198+
import math
199+
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)
204+
205+
# Replace weights so the next layer's calibration sees quantized output
206+
layer.weight.data.copy_(Q.to(layer.weight.dtype))
207+
208+
export = quantizer.export()
209+
export["mse"] = mse
210+
export["snr_db"] = snr_db
211+
export["rel_err"] = rel_err
212+
return export
213+
214+
215+
# ───────────────────────────── Driver ───────────────────────────────────────
216+
217+
def main():
218+
p = argparse.ArgumentParser()
219+
p.add_argument("--model", default="Qwen/Qwen3.5-4B")
220+
p.add_argument("--output-dir", required=True)
221+
p.add_argument("--n-samples", type=int, default=64)
222+
p.add_argument("--seq-len", type=int, default=2048)
223+
p.add_argument("--group-size", type=int, default=128)
224+
p.add_argument("--percdamp", type=float, default=0.01)
225+
p.add_argument("--n-centroids", type=int, default=16)
226+
p.add_argument("--n-iter", type=int, default=25)
227+
p.add_argument("--fit-loss", choices=("mse", "mag_weighted"), default="mag_weighted")
228+
p.add_argument("--mag-weight-p", type=float, default=5.0)
229+
p.add_argument("--device", default="cuda:0")
230+
p.add_argument("--dtype", choices=("float16", "bfloat16"), default="float16")
231+
p.add_argument("--max-layers", type=int, default=None,
232+
help="Limit to first N transformer layers (fast iteration)")
233+
args = p.parse_args()
234+
235+
os.makedirs(args.output_dir, exist_ok=True)
236+
237+
dtype = {"float16": torch.float16, "bfloat16": torch.bfloat16}[args.dtype]
238+
239+
print(f"[load] {args.model} dtype={dtype} device={args.device}")
240+
tokenizer = AutoTokenizer.from_pretrained(args.model)
241+
model = AutoModelForCausalLM.from_pretrained(args.model, torch_dtype=dtype)
242+
model = model.to(args.device).eval()
243+
244+
print(f"[calib] loading {args.n_samples} samples seq_len={args.seq_len}")
245+
calib = collect_wikitext_calibration(tokenizer, n_samples=args.n_samples,
246+
seq_len=args.seq_len)
247+
print(f"[calib] got {len(calib)} samples "
248+
f"(tokens total ≈ {sum(c.numel() for c in calib)})")
249+
250+
targets = list(find_target_linears(model))
251+
targets = filter_by_layer_limit(targets, args.max_layers)
252+
print(f"[targets] {len(targets)} linears to quantize")
253+
254+
manifest = {"model": args.model, "args": vars(args), "results": []}
255+
256+
for i, (name, layer) in enumerate(targets):
257+
t0 = time.time()
258+
rows, in_feat = layer.weight.shape
259+
print(f"\n[{i+1}/{len(targets)}] {name} shape=({rows}, {in_feat})")
260+
261+
H, n_tok = compute_hessian(layer, calib, model, args.device)
262+
t_hess = time.time() - t0
263+
print(f" hessian: {H.shape}, "
264+
f"diag_mean={H.diag().mean().item():.4g}, "
265+
f"n_tok={n_tok}, t={t_hess:.1f}s")
266+
267+
q = CentroidQuantizer(n_centroids=args.n_centroids,
268+
n_iter=args.n_iter).to(args.device)
269+
q.configure(bits=4, sym=True,
270+
fit_loss=args.fit_loss,
271+
mag_weight_p=args.mag_weight_p)
272+
q.hessian_diag = torch.diag(H).clone()
273+
274+
try:
275+
export = gptq_quantize_linear(layer, H, q,
276+
group_size=args.group_size,
277+
percdamp=args.percdamp)
278+
except RuntimeError as e:
279+
print(f" FAILED: {e}")
280+
continue
281+
t_quant = time.time() - t0 - t_hess
282+
283+
out_path = Path(args.output_dir) / f"{name.replace('.', '_').replace('/', '_')}.pt"
284+
torch.save({
285+
"name": name,
286+
"shape": [rows, in_feat],
287+
"group_size": args.group_size,
288+
"n_centroids": args.n_centroids,
289+
"indices": export["indices"].cpu(),
290+
"centroids_per_group": export["centroids_per_group"].cpu(),
291+
"mse": export["mse"],
292+
"snr_db": export["snr_db"],
293+
"rel_err": export["rel_err"],
294+
}, out_path)
295+
296+
print(f" saved: {out_path.name} "
297+
f"groups={export['centroids_per_group'].shape[0]} "
298+
f"SNR={export['snr_db']:.1f}dB "
299+
f"rel_err={export['rel_err']:.3%} "
300+
f"t_quant={t_quant:.1f}s")
301+
302+
manifest["results"].append({
303+
"name": name,
304+
"shape": [rows, in_feat],
305+
"n_groups": int(export["centroids_per_group"].shape[0]),
306+
"mse": float(export["mse"]),
307+
"snr_db": float(export["snr_db"]),
308+
"rel_err": float(export["rel_err"]),
309+
"t_hess_s": float(t_hess),
310+
"t_quant_s": float(t_quant),
311+
})
312+
313+
del H, q
314+
if args.device.startswith("cuda"):
315+
torch.cuda.empty_cache()
316+
317+
manifest_path = Path(args.output_dir) / "manifest.json"
318+
with open(manifest_path, "w") as f:
319+
json.dump(manifest, f, indent=2)
320+
print(f"\n[done] manifest: {manifest_path}")
321+
322+
323+
if __name__ == "__main__":
324+
main()

0 commit comments

Comments
 (0)