|
| 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