Files
colibri/c/tools/quant_ablation.py

387 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
A/B any quantization scheme against fp16 — WITHOUT converting a 370 GB model first.
Why this exists
---------------
Measuring "what does int4 cost us?" by comparing colibri's score to a published model-card
number does not work: the harness scores 0-shot log-likelihood, published numbers are
few-shot/CoT, and that protocol gap can swamp the quantization effect (see #108).
This tool removes the confound by construction. It takes an fp16 model, pushes its weights
through colibri's OWN quantizer (quantize -> dequantize, in place), and scores both with the
SAME harness, on the SAME questions, on the SAME machine. The only variable is the quantizer,
so the delta IS the quantization cost.
It runs on a small model (OLMoE) in minutes, so a scheme can be ranked BEFORE committing to
a multi-hour GLM conversion. The quantizer math is replicated from tools/convert_fp8_to_int4.py
(symmetric absmax, per-row scales) and generalised with an optional group size.
Measured with this tool on OLMoE-1B-7B, n=200/task (issue #108):
scheme hellaswag arc_c mmlu mean delta
fp16 77.0% 47.0% 47.0% 57.0% --
int4 (shipped) 74.0% 41.0% 31.5% 48.8% -8.2pp
int4-nohead 73.5% 40.5% 37.5% 50.5% -6.5pp
int4-g128 78.5% 45.5% 38.0% 54.0% -3.0pp
int4-g128-nohead 78.5% 46.5% 38.0% 54.3% -2.7pp
-> the per-row int4 container costs ~8pp, concentrated on the HARD task (MMLU falls to
31.5% against a 25% random baseline, while easy HellaSwag barely moves): per-row scales
eat the small logit margins hard questions depend on.
-> group=128 recovers ~63% of that loss for ~+0.25 bits/weight.
-> leaving lm_head/embed in fp16 is NOT the fix (+1.7pp alone, +0.3pp on top of grouping).
Usage
-----
pip install torch transformers # dev-only; the engine stays dependency-free
python tools/fetch_benchmarks.py --out ./bench --tasks hellaswag,arc_challenge,mmlu --limit 200
python tools/quant_ablation.py --model allenai/OLMoE-1B-7B-0924 --data ./bench \
--tasks hellaswag,arc_challenge,mmlu --limit 200 \
--schemes fp16,int4,int4-g64,int4-g128
Scheme grammar: fp16 | int{2,4,8}[-g<N>][-nohead]
int4 per-row absmax int4 -- what the converter ships today
int4-g64 one scale per 64 input weights instead of per row
int4-nohead as int4, but lm_head/embed kept in fp16
"""
import argparse
import json
import random
import re
import sys
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
# --------------------------------------------------------------------------------------
# colibri's quantizer (tools/convert_fp8_to_int4.py:32-52), generalised with a group size.
#
# LAYOUT NOTE, and it is a trap: transformers fuses MoE experts into 3D tensors
# (mlp.experts.gate_up_proj = [n_experts, in, out]) even when the checkpoint stores one 2D
# matrix per expert. A `p.ndim == 2` filter therefore skips EVERY expert and silently leaves
# ~85% of an MoE in fp16 while appearing to work. Both layouts must be handled, and the
# coverage assert below exists to make that failure loud instead of plausible.
# --------------------------------------------------------------------------------------
def _quant_last_dim(x, bits, group):
"""Symmetric absmax quantize->dequantize along the last (input) dim."""
qmax = (1 << (bits - 1)) - 1 # int4 -> 7, int8 -> 127, int2 -> 1
qmin = -(qmax + 1) # int4 -> -8 (nibble [-8,7], as the converter does)
if group:
if x.shape[-1] % group:
raise SystemExit(f"group {group} does not divide input dim {x.shape[-1]}")
x = x.reshape(*x.shape[:-1], x.shape[-1] // group, group)
amax = x.abs().amax(dim=-1, keepdim=True)
s = torch.clamp(amax / qmax, min=1e-8)
q = torch.clamp(torch.round(x / s), qmin, qmax)
out = q * s
return out.reshape(*out.shape[:-2], -1) if group else out
# --------------------------------------------------------------------------------------
# Rotation preconditioning (QuaRot / QuIP# family, #81): multiply the input dimension by
# an orthogonal Q = diag(signs) @ H/sqrt(n) BEFORE quantizing, and by Q^T after — the
# round-trip Q4(W@Q)@Q.T measures exactly the weight error of a deployed scheme that
# stores W@Q quantized and rotates activations at runtime (W'@x' = W@x since Q@Q.T = I;
# the runtime cost is one O(D log D) transform per matmul INPUT, not per weight).
# Spreading outliers across the block is the point: absmax scales stop being hostage to
# one heavy coordinate, which is the failure mode #108 measured (margin erosion on MMLU).
# --------------------------------------------------------------------------------------
_ROT_CACHE = {}
def rotation(dim, device, seed=417):
key = (dim, str(device))
if key in _ROT_CACHE:
return _ROT_CACHE[key]
if dim & (dim - 1):
raise SystemExit(f"-rot needs power-of-2 input dims (got {dim}); OLMoE dims are 2048/1024")
h = torch.ones(1, 1, device=device, dtype=torch.float32)
while h.shape[0] < dim: # Sylvester recursion
h = torch.cat([torch.cat([h, h], 1), torch.cat([h, -h], 1)], 0)
h /= h.shape[0] ** 0.5 # orthonormal
g = torch.Generator().manual_seed(seed + dim)
signs = (torch.randint(0, 2, (dim,), generator=g).float() * 2 - 1).to(device)
q = signs[:, None] * h # Q = D @ H/sqrt(n), orthogonal
_ROT_CACHE[key] = q
return q
def quantize_param(w, bits, group, rot=False, e8=""):
if w.ndim == 3: # fused experts [E, in, out] -> move input last
x = w.transpose(1, 2).contiguous()
x = _rot_quant(x, bits, group, e8) if rot else _grid_or_e8(x, bits, group, e8)
return x.transpose(1, 2).contiguous()
if rot:
return _rot_quant(w, bits, group, e8)
return _grid_or_e8(w, bits, group, e8) # nn.Linear [out, in] -- input already last
def _grid_or_e8(x, bits, group, e8):
if e8:
return _quant_e8(x.float(), group, bits, ball=(e8 == "-e8"))
return _quant_last_dim(x, bits, group)
def _rot_quant(x, bits, group, e8=""):
"""W -> Qn(W@Q) @ Q^T along the last (input) dim — see rotation() above."""
q = rotation(x.shape[-1], x.device)
return (_grid_or_e8(x.float() @ q, bits, group, e8) @ q.T).contiguous()
# --------------------------------------------------------------------------------------
# E8 lattice quantization (#81 follow-up): the -rot schemes above are QuaRot (rotation +
# uniform grid). QuIP#'s 2-bit result needs the second ingredient — an E8 lattice codebook
# instead of the grid. E8 = D8 (D8 + 1/2), nearest point via ConwaySloane: round every
# coordinate, and if the sum is odd re-round the worst coordinate the other way; repeat on
# the half-shifted copy and keep the closer of the two. `-e8` clamps points to |p|^2 <= 10,
# the E8P ball QuIP# builds its 2^16 codebook from (2 bits/weight for 8-dim blocks);
# `-e8u` leaves the lattice unbounded — an ideal-codebook upper bound, not a deployable rate.
# Scale: per group, a small MSE search over multiples of the block RMS (absmax is the wrong
# statistic for a lattice — the ball wants energy matched, not the peak).
# --------------------------------------------------------------------------------------
def _d8_nearest(y):
f = torch.round(y)
d = y - f
odd = (f.sum(-1).long() & 1).bool()
idx = d.abs().argmax(-1, keepdim=True)
step = torch.where(d.gather(-1, idx) >= 0, 1.0, -1.0)
flipped = f.gather(-1, idx) + step
return f.scatter(-1, idx, torch.where(odd[..., None], flipped, f.gather(-1, idx)))
def _e8_nearest(y):
a = _d8_nearest(y)
b = _d8_nearest(y - 0.5) + 0.5
da = ((y - a) ** 2).sum(-1, keepdim=True)
db = ((y - b) ** 2).sum(-1, keepdim=True)
return torch.where(da <= db, a, b)
def _e8_ball(y, r2=10.0):
p = _e8_nearest(y)
for _ in range(8): # shrink-and-requantize until inside
n2 = (p ** 2).sum(-1, keepdim=True)
over = n2 > r2 + 1e-6
if not over.any():
break
y = torch.where(over, y * torch.sqrt(r2 / torch.clamp(n2, min=r2)) * 0.98, y)
p = torch.where(over, _e8_nearest(y), p)
return p
_E8_R2_REPORTED = set()
def _e8_radius(bits):
# E8 lattice: points within |p|^2<=r2 grow ~r2^4, so +1 bit (x256 codebook) needs r2 x4.
# Anchor: r2=10 is the ~2^16 E8P ball (2 bits over 8 dims). Scale from there.
return 10.0 * (4.0 ** (bits - 2))
def _quant_e8(x, group, bits, ball):
"""Blocks of 8 along the input dim; per-group scale by MSE search over RMS multiples.
ball=True clamps to the rate-scaled E8 ball for `bits`; ball=False is the unbounded ideal."""
if x.shape[-1] % 8:
raise SystemExit(f"-e8 needs input dim divisible by 8 (got {x.shape[-1]})")
g = group or x.shape[-1]
if g % 8:
raise SystemExit(f"-e8 group {g} must be a multiple of 8")
shp = x.shape
xg = x.reshape(-1, g) # [G, g]
rms = torch.clamp(xg.pow(2).mean(-1, keepdim=True).sqrt(), min=1e-8)
best_out, best_err = None, None
for k in (0.5, 0.7, 0.9, 1.1, 1.4, 1.8, 2.4):
s = rms * k
yb = (xg / s).reshape(-1, g // 8, 8)
p = _e8_ball(yb, _e8_radius(bits)) if ball else _e8_nearest(yb)
if ball and bits not in _E8_R2_REPORTED:
_E8_R2_REPORTED.add(bits)
import sys as _sys
_sys.stderr.write(f"[e8] bits={bits}: ball r2={_e8_radius(bits):.1f}\n")
out = (p.reshape(-1, g) * s)
err = (out - xg).pow(2).sum(-1, keepdim=True)
if best_err is None:
best_out, best_err = out, err
else:
take = err < best_err
best_out = torch.where(take, out, best_out)
best_err = torch.where(take, err, best_err)
return best_out.reshape(shp)
SCHEME_RE = re.compile(r"^int(2|3|4|8)(?:-g(\d+))?(-e8u?)?(-rot)?(-nohead)?$")
def parse_scheme(name):
"""'int4-g128-nohead' -> (bits, group, e8, skip_head...). 'fp16' -> None."""
if name == "fp16":
return None
m = SCHEME_RE.match(name)
if not m:
raise SystemExit(f"bad scheme '{name}' (expected fp16 | int{{2,3,4,8}}[-g<N>][-e8|-e8u][-rot][-nohead])")
return int(m.group(1)), int(m.group(2) or 0), m.group(3) or "", bool(m.group(4)), bool(m.group(5))
def is_router(name):
# The router (mlp.gate.weight) stays f32 in the converter -- convert_fp8_to_int4.py:14.
# Careful: expert weights are gate_proj/up_proj/down_proj and DO get quantized.
return name.endswith("mlp.gate.weight")
def is_head_or_embed(name):
return "embed_tokens" in name or "lm_head" in name
def apply_scheme(model, scheme):
"""Quantize the tensor classes the converter hits (attn/mlp/expert/embed/lm_head);
norms, router and biases stay float. Returns (n_tensors, quantized_params, total)."""
total = sum(p.numel() for p in model.parameters())
spec = parse_scheme(scheme)
if spec is None:
return 0, 0, total
bits, group, e8, rot, skip_head = spec
n = qp = 0
with torch.no_grad():
for name, p in model.named_parameters():
if p.ndim < 2 or is_router(name):
continue
if skip_head and is_head_or_embed(name):
continue
p.data.copy_(quantize_param(p.data.float(), bits, group, rot, e8).to(p.dtype))
n += 1
qp += p.numel()
return n, qp, total
# --------------------------------------------------------------------------------------
# Scoring — mirrors tools/eval_glm.py exactly:
# acc = argmax over options of sum(logprob of continuation tokens)
# acc_norm = argmax over options of sum(logprob) / len(continuation string in CHARACTERS)
#
# THE PREFIX IS NOT OPTIONAL (issue #108, credit @bokiko). GLM-5.2 sees "[gMASK]<sop>" at the
# start of every training sequence. Score it without that prefix and the model runs
# out-of-distribution: measured here, perplexity on plain English prose goes 9.4 -> 29.2, and
# on markdown/code 24.5 -> 131.0. It does not merely depress scores, it distorts SENSITIVITY:
# an int4-vs-exact kernel A/B measured without the prefix reported a penalty that halved and
# flipped sign on one corpus once the prefix was restored (#153). Any quantization delta
# measured OOD is therefore suspect.
#
# The GLM tokenizer does NOT add it for you — add_special_tokens=True is a no-op there — so it
# has to be prepended explicitly. Auto-detected from the vocab so it cannot be lost by
# omission; models with no such prefix (e.g. OLMoE, which has no BOS at all) are unaffected.
# --------------------------------------------------------------------------------------
def detect_prefix(tk):
"""'[gMASK]<sop>' for GLM snapshots, '' otherwise. --prefix overrides."""
vocab = tk.get_vocab()
if "[gMASK]" in vocab and "<sop>" in vocab:
return "[gMASK]<sop>"
return ""
def load_docs(task, data_dir, limit, seed, prefix=""):
path = f"{data_dir}/{task}.jsonl"
try:
docs = [json.loads(l) for l in open(path) if l.strip()]
except FileNotFoundError:
raise SystemExit(f"missing {path} — run: python tools/fetch_benchmarks.py --out {data_dir} --tasks {task}")
random.Random(seed).shuffle(docs) # same seed/shuffle convention as eval_glm.py
docs = docs[:limit] if limit else docs
if prefix: # condition every context in-distribution
docs = [dict(d, ctx=prefix + d["ctx"]) for d in docs]
return docs
@torch.no_grad()
def score(model, tk, docs, device):
acc = accn = 0
for d in docs:
ctx, choices, gold = d["ctx"], d["choices"], int(d["gold"])
ctx_ids = tk(ctx, add_special_tokens=False).input_ids
lps, norms = [], []
for cont in choices:
full = tk(ctx + cont, add_special_tokens=False).input_ids
cl = len(ctx_ids)
while cl > 0 and (cl > len(full) or full[:cl] != ctx_ids[:cl]):
cl -= 1
if not full[cl:]:
full = ctx_ids + tk(cont, add_special_tokens=False).input_ids
cl = len(ctx_ids)
cl = max(1, cl)
ids = torch.tensor([full], device=device)
logprobs = torch.log_softmax(model(ids).logits.float()[0, :-1], dim=-1)
tgt = ids[0, 1:]
lps.append(logprobs[torch.arange(cl - 1, len(full) - 1), tgt[cl - 1:]].sum().item())
norms.append(max(1, len(cont))) # CHARACTER length, like eval_glm.py
acc += max(range(len(lps)), key=lambda i: lps[i]) == gold
accn += max(range(len(lps)), key=lambda i: lps[i] / norms[i]) == gold
n = len(docs)
return 100 * acc / n, 100 * accn / n
def main():
ap = argparse.ArgumentParser(description="A/B a quantization scheme against fp16, engine-free")
ap.add_argument("--model", default="allenai/OLMoE-1B-7B-0924", help="HF repo id or local dir")
ap.add_argument("--data", default="./bench")
ap.add_argument("--tasks", default="hellaswag,arc_challenge,mmlu")
ap.add_argument("--limit", type=int, default=200)
ap.add_argument("--seed", type=int, default=1234)
ap.add_argument("--schemes", default="fp16,int4,int4-g128",
help="comma list: fp16 | int{2,4,8}[-g<N>][-nohead]")
ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
ap.add_argument("--min-coverage", type=float, default=95.0,
help="fail if a scheme quantized less than this %% of params (catches the "
"3D-fused-expert trap, where a ndim==2 filter skips every expert)")
ap.add_argument("--prefix", default=None,
help="context prefix (default: auto — '[gMASK]<sop>' for GLM, '' otherwise). "
"Scoring GLM without it runs the model out-of-distribution: see #108.")
a = ap.parse_args()
tasks = a.tasks.split(",")
schemes = a.schemes.split(",")
for s in schemes:
parse_scheme(s) # fail fast on a typo
tk = AutoTokenizer.from_pretrained(a.model, trust_remote_code=True)
prefix = detect_prefix(tk) if a.prefix is None else a.prefix
print(f"[prefix] {prefix!r}" + (" (auto-detected: GLM snapshot)" if prefix and a.prefix is None
else " (no prefix for this model)" if not prefix else " (--prefix)"),
flush=True)
docs = {t: load_docs(t, a.data, a.limit, a.seed, prefix) for t in tasks}
means, rows = {}, {}
for scheme in schemes:
model = AutoModelForCausalLM.from_pretrained(
a.model, dtype=torch.float16, low_cpu_mem_usage=True,
device_map={"": 0} if a.device == "cuda" else None, trust_remote_code=True)
model.eval()
if a.device != "cuda":
model.to(a.device)
n, qp, tp = apply_scheme(model, scheme)
cov = 100 * qp / tp if tp else 0.0
print(f"[{scheme}] {n} tensors · {qp/1e9:.2f}B/{tp/1e9:.2f}B params ({cov:.1f}% coverage)",
flush=True)
if scheme != "fp16" and cov < a.min_coverage:
raise SystemExit(
f"ERROR: {scheme} quantized only {cov:.1f}% of parameters (< {a.min_coverage}%).\n"
f" The experts are probably being skipped: transformers fuses MoE experts\n"
f" into 3D tensors, so a ndim==2 filter silently leaves them in fp16.")
rows[scheme] = {t: score(model, tk, docs[t], a.device) for t in tasks}
means[scheme] = sum(v[1] for v in rows[scheme].values()) / len(tasks)
for t in tasks:
print(f" {t:<16} n={len(docs[t]):<4} acc {rows[scheme][t][0]:5.1f}%"
f" acc_norm {rows[scheme][t][1]:5.1f}%", flush=True)
print(f" {'MEAN acc_norm':<16} {means[scheme]:5.1f}%\n", flush=True)
del model
torch.cuda.empty_cache()
base = means.get("fp16")
print(f"{'scheme':<20}{'mean acc_norm':>14}{'delta vs fp16':>16}")
for scheme in schemes:
d = f"{means[scheme]-base:+.1f}pp" if base is not None and scheme != "fp16" else "--"
print(f"{scheme:<20}{means[scheme]:>13.1f}%{d:>16}")
if base is None:
print("\n(no fp16 baseline in --schemes, so no deltas)", file=sys.stderr)
if __name__ == "__main__":
main()