Skip to content

Commit e11e754

Browse files
kmbandyclaude
andcommitted
feat(MAD-223): reconstruct_model.py — load HF model + overlay ml8-4 weights
Phase B.2 follow-up. Bridges calibration-artifact-on-disk → loaded quantized model in memory. Workflow: 1. Load original f16/bf16 model via HF transformers 2. For each .pt blob in --calibration-dir, find the matching nn.Linear in the model, reconstruct fp32 weight via ml8_io.reconstruct_weight, overlay (in-place .copy_) onto layer.weight 3. (Optional) eval wikitext-2 PPL on the reconstructed model 4. (Optional) eval f16 baseline PPL first for Δ_PPL Verifies that the disk format round-trips correctly — same PPL as the in-memory eval that calibrate_ml8.py does immediately after quantization (modulo numerical noise from fp16 vs fp32 reconstruction precision). Reports size ratio vs fp16 baseline (typically ~0.26 for ml8-4 at n_centroids=16 group_size=128, since 4.125/16 ≈ 0.258). Note: requires the layer .pt blobs to include scale_per_group (added in a5cbeba). Calibration runs before that commit won't be reloadable — re-run calibrate_ml8.py to produce reusable artifacts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a5cbeba commit e11e754

1 file changed

Lines changed: 205 additions & 0 deletions

File tree

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
#!/usr/bin/env python3
2+
"""reconstruct_model.py — load a HF model and overlay ml8-4 quantized weights.
3+
4+
Given a HF model name + a directory of per-layer .pt blobs (output of
5+
calibrate_ml8.py), this loads the original f16/bf16 model and replaces
6+
each quantized layer's weight with the dequantized version reconstructed
7+
from its blob. Result: a model in memory whose target linears now match
8+
exactly what a real ml8-4 deployment would produce at inference time.
9+
10+
Use cases:
11+
1. Re-evaluate PPL on a calibration artifact (vs the in-memory PPL
12+
eval that calibrate_ml8.py does immediately after quantization —
13+
this verifies the disk format actually round-trips).
14+
2. Run sanity prompts via .generate() on the quantized model.
15+
3. Inspect per-layer quality after the fact.
16+
17+
Usage:
18+
python3 scripts/calibration/reconstruct_model.py \\
19+
--model Qwen/Qwen3.5-4B \\
20+
--calibration-dir /tmp/ml8-qwen3-4b-full \\
21+
--eval-ppl --ppl-max-tokens 100000 \\
22+
--device cuda:0
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import argparse
28+
import json
29+
import math
30+
import sys
31+
from pathlib import Path
32+
33+
import torch
34+
from torch import nn
35+
from transformers import AutoModelForCausalLM, AutoTokenizer
36+
37+
sys.path.insert(0, str(Path(__file__).parent))
38+
from ml8_io import load_ml8_layer, reconstruct_weight, bits_per_value # noqa: E402
39+
40+
41+
def get_module(model, name: str):
42+
"""model.layers.0.mlp.up_proj → traverse attribute chain."""
43+
cur = model
44+
for part in name.split("."):
45+
if part.isdigit():
46+
cur = cur[int(part)]
47+
else:
48+
cur = getattr(cur, part)
49+
return cur
50+
51+
52+
@torch.no_grad()
53+
def overlay_ml8_weights(model, calibration_dir: Path, device: str,
54+
verbose: bool = True) -> dict:
55+
"""Load all per-layer .pt blobs and overwrite the corresponding linears.
56+
57+
Returns a summary dict: counts, total bits, original-fp bits, etc.
58+
"""
59+
blobs = sorted(calibration_dir.glob("*.pt"))
60+
if not blobs:
61+
raise RuntimeError(f"no .pt files found in {calibration_dir}")
62+
63+
n_loaded = 0
64+
n_skipped = 0
65+
total_quant_bits = 0
66+
total_orig_bits_fp16 = 0
67+
per_layer = []
68+
69+
for path in blobs:
70+
blob = load_ml8_layer(path)
71+
name = blob["name"]
72+
target = None
73+
try:
74+
target = get_module(model, name)
75+
except (AttributeError, IndexError):
76+
if verbose:
77+
print(f" [skip] {name}: not found in model")
78+
n_skipped += 1
79+
continue
80+
if not isinstance(target, nn.Linear):
81+
if verbose:
82+
print(f" [skip] {name}: not a Linear ({type(target).__name__})")
83+
n_skipped += 1
84+
continue
85+
86+
# Reconstruct on the target device for fewest transfers
87+
W_recon = reconstruct_weight(blob).to(device)
88+
if tuple(W_recon.shape) != tuple(target.weight.shape):
89+
print(f" [skip] {name}: shape mismatch "
90+
f"{tuple(W_recon.shape)} vs {tuple(target.weight.shape)}")
91+
n_skipped += 1
92+
continue
93+
94+
# Replace weight in-place. Preserve original dtype.
95+
target.weight.data.copy_(W_recon.to(target.weight.dtype))
96+
97+
bpv = bits_per_value(blob)
98+
numel = target.weight.numel()
99+
total_quant_bits += numel * bpv
100+
total_orig_bits_fp16 += numel * 16
101+
per_layer.append({"name": name, "numel": numel, "bpv": bpv})
102+
n_loaded += 1
103+
if verbose:
104+
print(f" [load] {name} shape={tuple(W_recon.shape)} bpv={bpv:.3f}")
105+
106+
summary = {
107+
"n_loaded": n_loaded,
108+
"n_skipped": n_skipped,
109+
"total_quant_bits": total_quant_bits,
110+
"total_orig_bits_fp16": total_orig_bits_fp16,
111+
"size_ratio_vs_fp16": total_quant_bits / max(total_orig_bits_fp16, 1),
112+
"per_layer": per_layer,
113+
}
114+
return summary
115+
116+
117+
@torch.no_grad()
118+
def eval_ppl_wikitext(model, tokenizer, dev: str, seq_len: int = 2048,
119+
stride: int = 1024, max_tokens: int | None = None) -> dict:
120+
"""Same eval as calibrate_ml8.py — duplicated to keep this module standalone."""
121+
from datasets import load_dataset
122+
ds = load_dataset("Salesforce/wikitext", "wikitext-2-raw-v1", split="test")
123+
text = "\n\n".join(r["text"] for r in ds if r["text"].strip())
124+
enc = tokenizer(text, return_tensors="pt")
125+
ids = enc.input_ids.to(dev)
126+
n_tokens = ids.shape[1]
127+
if max_tokens is not None:
128+
n_tokens = min(n_tokens, max_tokens)
129+
ids = ids[:, :n_tokens]
130+
print(f" [ppl] {n_tokens} tokens, window={seq_len} stride={stride}")
131+
132+
nll_sum = 0.0
133+
n_pred = 0
134+
prev_end = 0
135+
for begin in range(0, n_tokens, stride):
136+
end = min(begin + seq_len, n_tokens)
137+
target_len = end - prev_end
138+
input_ids = ids[:, begin:end]
139+
target_ids = input_ids.clone()
140+
target_ids[:, :-target_len] = -100
141+
outputs = model(input_ids, labels=target_ids)
142+
n_unmasked = (target_ids != -100).sum().item()
143+
nll_sum += outputs.loss.item() * n_unmasked
144+
n_pred += n_unmasked
145+
prev_end = end
146+
if end >= n_tokens:
147+
break
148+
149+
return {"ppl": math.exp(nll_sum / max(n_pred, 1)),
150+
"avg_nll": nll_sum / max(n_pred, 1),
151+
"n_tokens_scored": n_pred}
152+
153+
154+
def main():
155+
p = argparse.ArgumentParser()
156+
p.add_argument("--model", required=True)
157+
p.add_argument("--calibration-dir", required=True, type=Path)
158+
p.add_argument("--device", default="cuda:0")
159+
p.add_argument("--dtype", choices=("float16", "bfloat16"), default="float16")
160+
p.add_argument("--eval-ppl", action="store_true")
161+
p.add_argument("--ppl-max-tokens", type=int, default=None)
162+
p.add_argument("--also-eval-baseline", action="store_true",
163+
help="Eval PPL on the original f16 model FIRST, before overlay, "
164+
"for delta computation.")
165+
args = p.parse_args()
166+
167+
dtype = {"float16": torch.float16, "bfloat16": torch.bfloat16}[args.dtype]
168+
169+
print(f"[load] {args.model} dtype={dtype} device={args.device}")
170+
tok = AutoTokenizer.from_pretrained(args.model)
171+
model = AutoModelForCausalLM.from_pretrained(args.model, torch_dtype=dtype)
172+
model = model.to(args.device).eval()
173+
174+
baseline_ppl = None
175+
if args.eval_ppl and args.also_eval_baseline:
176+
print("\n[ppl-baseline] f16 baseline PPL...")
177+
baseline_ppl = eval_ppl_wikitext(model, tok, args.device,
178+
max_tokens=args.ppl_max_tokens)
179+
print(f" baseline PPL = {baseline_ppl['ppl']:.4f}")
180+
181+
print(f"\n[overlay] loading {args.calibration_dir}/*.pt ...")
182+
summary = overlay_ml8_weights(model, args.calibration_dir, args.device)
183+
print(f"\n[overlay summary] {summary['n_loaded']} layers loaded, "
184+
f"{summary['n_skipped']} skipped")
185+
print(f" size: {summary['total_quant_bits']/8/1e9:.2f} GB quantized vs "
186+
f"{summary['total_orig_bits_fp16']/8/1e9:.2f} GB fp16 "
187+
f"(ratio {summary['size_ratio_vs_fp16']:.3f})")
188+
189+
if args.eval_ppl:
190+
print("\n[ppl-quant] reconstructed model PPL...")
191+
quant_ppl = eval_ppl_wikitext(model, tok, args.device,
192+
max_tokens=args.ppl_max_tokens)
193+
print(f" reconstructed PPL = {quant_ppl['ppl']:.4f}")
194+
if baseline_ppl is not None:
195+
delta = quant_ppl["ppl"] - baseline_ppl["ppl"]
196+
print(f"\n Δ_PPL = {delta:+.4f} "
197+
f"({delta / baseline_ppl['ppl'] * 100:+.2f}%)")
198+
if delta < 0.08:
199+
print(f" ✓ Δ_PPL < 0.08 — MAD-223 gate PASSED")
200+
else:
201+
print(f" ⚠ Δ_PPL ≥ 0.08 — MAD-223 gate triggers AWQ B.5")
202+
203+
204+
if __name__ == "__main__":
205+
main()

0 commit comments

Comments
 (0)