minimind/scripts/measure_sqnr.py

293 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""
Measure SQNR (Signal-to-Quantization-Noise Ratio) of MiniMind checkpoints.
三个维度:
1. Weight SQNR: 对每层 Linear.weight 应用 W8/W4 fake-quant,算 SQNR
- Pretrain vs QAT-trained: QAT 训练应把 W SQNR 推高(权重朝量化友好区漂)
2. Activation SQNR: 用 forward hook 抓每层输入 activation, 用 running_max 量化后算 SQNR
- 需要真实数据(否则激活分布不代表推理时)
3. Bit-width sweep: 对 pretrain 权重扫 W2/W4/W8/W16 看 SQNR ~ bits 曲线(应该 +6dB/bit)
Usage:
cd scripts && python measure_sqnr.py # 默认:W SQNR (pretrain vs qat) + bit sweep
python measure_sqnr.py --act 1 # 加上 activation SQNR (需要 GPU + 数据)
python measure_sqnr.py --act 1 --n_samples 32 # activation 用 N samples 校准
"""
import os
import sys
__package__ = "scripts"
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
import argparse
import warnings
import json
import torch
from transformers import AutoTokenizer
from model.model_minimind import MiniMindConfig, MiniMindForCausalLM
from model.quant import WeightFakeQuant, prepare_qat, DEFAULT_SKIP_PATTERNS
warnings.filterwarnings('ignore')
def sqnr_db(x: torch.Tensor, x_q: torch.Tensor) -> float:
"""SQNR = 10 log10(||x||² / ||x - x_q||²),用 fp64 算防止 fp16 数值下溢。"""
x, x_q = x.double(), x_q.double()
signal_pow = (x ** 2).mean()
noise_pow = ((x - x_q) ** 2).mean().clamp_min(1e-24)
return (10.0 * torch.log10(signal_pow / noise_pow)).item()
def is_quantizable_linear_key(k: str, skip_patterns=DEFAULT_SKIP_PATTERNS) -> bool:
"""判断 state_dict 的 key 是否对应我们会量化的 Linear.weight。"""
if not k.endswith('.weight'):
return False
# 排除 embedding / norm / lm_head / MoE 路由(和 prepare_qat 的 skip 一致)
if 'embed_tokens' in k or 'lm_head' in k or 'norm' in k:
return False
parent = k[:-len('.weight')]
for pat in skip_patterns:
if parent == pat or parent.endswith('.' + pat):
return False
return True
def measure_weight_sqnr(pretrain_sd, qat_sd, bits=8):
"""对每层 Linear.weight 算 W SQNR,返回 dict {layer_name: (sqnr_pretrain, sqnr_qat, delta)}。"""
wfq = WeightFakeQuant(bits=bits)
results = {}
for k in pretrain_sd.keys():
if not is_quantizable_linear_key(k):
continue
w_pt = pretrain_sd[k].float()
w_pt_q = wfq(w_pt)
sqnr_pt = sqnr_db(w_pt, w_pt_q)
rec = {'pretrain': sqnr_pt}
if k in qat_sd:
w_q = qat_sd[k].float()
w_q_q = wfq(w_q)
sqnr_q = sqnr_db(w_q, w_q_q)
rec['qat'] = sqnr_q
rec['delta'] = sqnr_q - sqnr_pt
results[k] = rec
return results
def measure_bit_sweep(pretrain_sd, bit_list=(2, 3, 4, 6, 8, 12)):
"""对每层权重扫多个 bit,验证 ~6 dB/bit 规律。返回 dict {bits: mean_sqnr_across_layers}。"""
out = {}
for bits in bit_list:
wfq = WeightFakeQuant(bits=bits)
sqnrs = []
for k in pretrain_sd.keys():
if not is_quantizable_linear_key(k):
continue
w = pretrain_sd[k].float()
sqnrs.append(sqnr_db(w, wfq(w)))
out[bits] = sum(sqnrs) / len(sqnrs)
return out
@torch.no_grad()
def measure_activation_sqnr(cfg, qat_ckpt_path, tokenizer_path, data_path, n_samples, device='cuda'):
"""
跑真实数据,用 forward hook 抓每个 QATLinear 的输入激活,
对比 fp 激活 vs act_fq(fp激活) 的 SQNR。使用 QAT ckpt 里训好的 running_max。
"""
model = MiniMindForCausalLM(cfg)
prepare_qat(model, w_bits=8, a_bits=8)
sd = torch.load(qat_ckpt_path, map_location='cpu', weights_only=True)
model.load_state_dict(sd, strict=False)
model = model.half().eval().to(device)
tokenizer = AutoTokenizer.from_pretrained(tokenizer_path)
bos_id, eos_id = tokenizer.bos_token_id, tokenizer.eos_token_id
# 收集每个 QATLinear 的激活 SQNR(累积 sum + count 便于跨样本求平均)
from model.quant import QATLinear
stats = {} # name -> {sum_sig_pow, sum_noise_pow, count}
hooks = []
def make_hook(name, act_fq):
def hook(mod, inp, out):
x = inp[0].detach()
x_q = act_fq(x.clone()) # 现用 QAT 训好的 running_max 量化
xd, xqd = x.double(), x_q.double()
s = (xd ** 2).sum().item()
n = ((xd - xqd) ** 2).sum().item()
if name not in stats:
stats[name] = {'sig': 0.0, 'noise': 0.0, 'nel': 0}
stats[name]['sig'] += s
stats[name]['noise'] += n
stats[name]['nel'] += x.numel()
return hook
for name, m in model.named_modules():
if isinstance(m, QATLinear):
hooks.append(m.register_forward_hook(make_hook(name, m.act_fq)))
# 走 N 条 pretrain 数据
fed = 0
with open(data_path) as f:
for line in f:
d = json.loads(line)
text = d.get('text') or d.get('content') or ''
if not text or len(text) < 10:
continue
body = tokenizer(text, max_length=510, truncation=True,
add_special_tokens=False).input_ids
tokens = [bos_id] + body + [eos_id]
ids = torch.tensor([tokens], device=device)
model(input_ids=ids)
fed += 1
if fed >= n_samples:
break
for h in hooks:
h.remove()
# 转成 SQNR
result = {}
for name, s in stats.items():
sqnr = 10.0 * torch.log10(torch.tensor(s['sig'] / max(s['noise'], 1e-24))).item()
result[name] = sqnr
return result, fed
def _fmt_layer_stats(results, val_key='pretrain'):
"""紧凑打印每层 SQNR(截 layer name 到 self_attn.q_proj 之类)。"""
values = [(k.replace('model.layers.', 'L').replace('.weight', ''), v)
for k, v in results.items()]
return values
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--hidden_size', type=int, default=768)
parser.add_argument('--num_hidden_layers', type=int, default=8)
parser.add_argument('--use_moe', type=int, default=0)
parser.add_argument('--save_dir', type=str, default='../out')
parser.add_argument('--pretrain', type=str, default='pretrain')
parser.add_argument('--qat', type=str, default='qat')
parser.add_argument('--tokenizer_path', type=str, default='../model')
parser.add_argument('--data_path', type=str, default='../dataset/pretrain_t2t.jsonl')
parser.add_argument('--n_samples', type=int, default=32)
parser.add_argument('--act', type=int, default=1, choices=[0, 1],
help='是否算激活 SQNR(需要 GPU + 数据)')
parser.add_argument('--device', type=str, default='cuda' if torch.cuda.is_available() else 'cpu')
args = parser.parse_args()
moe_suffix = '_moe' if args.use_moe else ''
pt_path = f'{args.save_dir}/{args.pretrain}_{args.hidden_size}{moe_suffix}.pth'
qat_path = f'{args.save_dir}/{args.qat}_{args.hidden_size}{moe_suffix}.pth'
print(f'[load] pretrain: {pt_path}')
print(f'[load] qat: {qat_path}')
pretrain_sd = torch.load(pt_path, map_location='cpu', weights_only=True)
qat_sd = torch.load(qat_path, map_location='cpu', weights_only=True)
# =========================================================
# 1. Weight SQNR: pretrain vs QAT-trained, W8
# =========================================================
print(f'\n{"=" * 70}')
print(f' Weight SQNR (W8 per-out-channel symmetric)')
print(f' higher = weights sit closer to int8 grid points')
print(f'{"=" * 70}')
w8_results = measure_weight_sqnr(pretrain_sd, qat_sd, bits=8)
# 分组:attn (q/k/v/o) vs ffn (gate/up/down) 分别汇总
def group(k):
if 'self_attn' in k: return 'attn'
if 'mlp' in k: return 'ffn'
return 'other'
groups = {'attn': [], 'ffn': [], 'other': []}
for k, r in w8_results.items():
groups[group(k)].append(r)
print(f"{'Group':10s} {'#layers':>8s} {'PT SQNR mean':>14s} {'QAT SQNR mean':>14s} {'Δ mean':>10s}")
for g, rs in groups.items():
if not rs: continue
pt_mean = sum(r['pretrain'] for r in rs) / len(rs)
qat_mean = sum(r.get('qat', 0) for r in rs) / len(rs)
delta_mean = sum(r.get('delta', 0) for r in rs) / len(rs)
print(f"{g:10s} {len(rs):>8d} {pt_mean:>13.2f}dB {qat_mean:>13.2f}dB {delta_mean:>+9.2f}dB")
print(f'\n Per-layer detail (first 8 layers, all types):')
print(f' {"layer":50s} {"PT dB":>8s} {"QAT dB":>8s} {"Δ":>8s}')
shown = 0
for k, r in w8_results.items():
if 'layers.0.' not in k: continue # 只看第 0 层
short = k.replace('model.layers.0.', '').replace('.weight', '')
print(f' {short:50s} {r["pretrain"]:>7.2f} {r.get("qat", 0):>7.2f} {r.get("delta", 0):>+7.2f}')
shown += 1
# 排序找最难 / 最容易量化的 3 层
ranked_pt = sorted(w8_results.items(), key=lambda kv: kv[1]['pretrain'])
print(f'\n Hardest to quantize (lowest PT SQNR):')
for k, r in ranked_pt[:3]:
print(f' {k:60s} {r["pretrain"]:.2f} dB')
print(f' Easiest to quantize (highest PT SQNR):')
for k, r in ranked_pt[-3:]:
print(f' {k:60s} {r["pretrain"]:.2f} dB')
# =========================================================
# 2. Bit-width sweep(验证 ~6 dB/bit)
# =========================================================
print(f'\n{"=" * 70}')
print(f' Bit-width sweep on pretrain weights (avg across {len(w8_results)} Linears)')
print(f' theory: SQNR ≈ 6.02 × bits + 1.76 dB (uniform quantizer, full-scale signal)')
print(f'{"=" * 70}')
sweep = measure_bit_sweep(pretrain_sd, bit_list=(2, 3, 4, 6, 8, 12))
prev = None
print(f" {'bits':>5s} {'measured':>12s} {'theory (6b+1.76)':>18s} {'Δ vs prev':>12s}")
for bits, s in sweep.items():
theory = 6.02 * bits + 1.76
delta = f"{s - prev:+.2f} dB" if prev is not None else "—"
print(f" {bits:>5d} {s:>10.2f}dB {theory:>15.2f}dB {delta:>12s}")
prev = s
# =========================================================
# 3. Activation SQNR(可选,需要 GPU + 数据)
# =========================================================
if args.act:
print(f'\n{"=" * 70}')
print(f' Activation SQNR (per-tensor symmetric int8, running_max from QAT training)')
print(f' 用 {args.n_samples} 条 pretrain 样本走前向,hook 到每个 QATLinear 输入')
print(f'{"=" * 70}')
cfg = MiniMindConfig(hidden_size=args.hidden_size,
num_hidden_layers=args.num_hidden_layers,
use_moe=bool(args.use_moe))
act_sqnr, n_run = measure_activation_sqnr(
cfg, qat_path, args.tokenizer_path,
args.data_path, args.n_samples, args.device,
)
print(f' (evaluated on {n_run} samples)')
# 按 attn/ffn 分组汇总
act_groups = {'attn': [], 'ffn': []}
for name, v in act_sqnr.items():
if 'self_attn' in name: act_groups['attn'].append(v)
elif 'mlp' in name: act_groups['ffn'].append(v)
print(f" {'Group':10s} {'#Linear':>10s} {'A8 SQNR mean':>15s} {'min':>10s} {'max':>10s}")
for g, vs in act_groups.items():
if not vs: continue
print(f" {g:10s} {len(vs):>10d} {sum(vs)/len(vs):>13.2f}dB {min(vs):>8.2f}dB {max(vs):>8.2f}dB")
# 层 0 明细
print(f'\n Layer 0 per-Linear activation SQNR:')
for name, v in act_sqnr.items():
if 'layers.0.' not in name: continue
short = name.replace('model.layers.0.', '')
print(f' {short:40s} {v:.2f} dB')
# 全局最差 3 个
ranked_act = sorted(act_sqnr.items(), key=lambda kv: kv[1])
print(f'\n Worst 3 activation SQNR (量化最难扛的层):')
for k, v in ranked_act[:3]:
print(f' {k:60s} {v:.2f} dB')
print(f'\n{"=" * 70}')
print(f' 参照:W8 通常 40-50 dB / W4 15-25 dB / <20 dB 开始明显掉 PPL')
print(f'{"=" * 70}')
if __name__ == "__main__":
main()