File size: 8,519 Bytes
6e668dc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 | """Downstream evidence: INT4/INT3 + per-channel-K (KIVI) vs vLLM's NVFP4 KV baseline.
Across several prompts on Laguna-XS.2, reports for each scheme:
* K-RMSE / V-RMSE — reconstruction error (cheap proxy; tracks KL)
* top-1 agreement — teacher-forced vs BF16 (identical context, no drift)
* mean KL(bf16||scheme) in nats — output-distribution distortion
Protocol is production-faithful: each 16-token page is quantized once when it
fills (frozen thereafter), and the partial hot page stays BF16. Teacher forcing
replays BF16's own tokens so every scheme sees identical context.
Schemes:
nvfp4-baseline K,V = nvfp4 / headdim / absmax (what vLLM ships, 4-bit)
int4-kivi K = int4 / per-channel / mse, V = int4 / per-token / mse
int3-kivi K = int3 / per-channel / mse, V = int3 / per-token / mse
int3-naive K,V = int3 / headdim / absmax (3-bit done the vLLM way)
Usage:
python -m scripts.quant_ab [--max-new 384] [--n-prompts 3]
"""
from __future__ import annotations
import argparse
import sys
import time
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, DynamicCache
sys.path.insert(0, "/home/alex/poolside-hackathon-kv-quant")
from kv_quant import BLOCK, PAGE, roundtrip, rmse_cell
MODEL = "poolside/Laguna-XS.2"
PROMPTS = [
"Solve step by step. A train leaves city A at 60 km/h. Two hours later a second "
"train leaves the same station on the same track at 90 km/h. How many hours after "
"the second train departs will it catch up to the first train? Show your reasoning.",
"Explain step by step how the quicksort algorithm works, including how partitioning "
"works and its time complexity in the best, average, and worst cases. Give a small "
"worked example.",
"Write a Python function that merges two sorted linked lists into one sorted list, "
"then explain step by step how it works and analyze its time and space complexity.",
]
# scheme -> per-K / per-V (format, layout, calib) cells + data bit-width.
SCHEMES = {
"nvfp4-baseline": {"k": ("nvfp4", "headdim", "absmax"), "v": ("nvfp4", "headdim", "absmax"), "bits": 4},
"int4-kivi": {"k": ("int4", "channel", "mse"), "v": ("int4", "headdim", "mse"), "bits": 4},
"int3-kivi": {"k": ("int3", "channel", "mse"), "v": ("int3", "headdim", "mse"), "bits": 3},
"int3-naive": {"k": ("int3", "headdim", "absmax"), "v": ("int3", "headdim", "absmax"), "bits": 3},
}
BASELINE = "nvfp4-baseline"
def mem_ratio(bits: int) -> float:
"""vs BF16: data bits/8 + one 1-byte scale per 16-elem block."""
return 2.0 / (bits / 8.0 + 1.0 / BLOCK)
class PageSim:
"""Freeze-at-fill quantization on a live DynamicCache: completed pages are
quantized once and kept; the partial hot page stays BF16."""
def __init__(self, scheme):
self.scheme = scheme
self.n_frozen = 0
def update(self, cache):
if self.scheme is None:
return
n_pages = cache.layers[0].keys.shape[2] // PAGE
if n_pages <= self.n_frozen:
return
lo, hi = self.n_frozen * PAGE, n_pages * PAGE
for layer in cache.layers:
k, v = layer.keys[0], layer.values[0]
qk = roundtrip(k[:, lo:hi], *self.scheme["k"])
qv = roundtrip(v[:, lo:hi], *self.scheme["v"])
layer.keys = torch.cat([k[:, :lo], qk, k[:, hi:]], dim=1).unsqueeze(0)
layer.values = torch.cat([v[:, :lo], qv, v[:, hi:]], dim=1).unsqueeze(0)
self.n_frozen = n_pages
def _eos_set(model):
eos = getattr(model.config, "eos_token_id", None)
if isinstance(eos, (list, tuple)):
return set(eos)
return {eos} if eos is not None else set()
def _prefill(model, input_ids, cache, device):
pos = torch.arange(input_ids.shape[1], device=device)
return model(input_ids=input_ids, past_key_values=cache, use_cache=True,
cache_position=pos, position_ids=pos.unsqueeze(0))
def _step(model, tok_id, cache, abs_pos, device):
cp = torch.tensor([abs_pos], device=device)
return model(input_ids=torch.tensor([[tok_id]], device=device), past_key_values=cache,
use_cache=True, cache_position=cp, position_ids=cp.unsqueeze(0))
def gen_bf16(model, input_ids, max_new, device, eos):
"""BF16 greedy; returns (gold_tokens, ref_logits [N,V] cpu, bf16 cache)."""
cache = DynamicCache()
logits, toks = [], []
with torch.no_grad():
out = _prefill(model, input_ids, cache, device)
logits.append(out.logits[0, -1].float().cpu())
toks.append(out.logits[0, -1].argmax().item())
abs_pos = input_ids.shape[1]
for _ in range(max_new - 1):
out = _step(model, toks[-1], cache, abs_pos, device)
logits.append(out.logits[0, -1].float().cpu())
toks.append(out.logits[0, -1].argmax().item())
abs_pos += 1
if toks[-1] in eos:
break
return toks, torch.stack(logits), cache
def teacher_forced(model, input_ids, gold, scheme, device):
"""Replay gold through a frozen-page scheme cache; logits [len(gold), V] cpu."""
cache = DynamicCache()
sim = PageSim(scheme)
logits = []
with torch.no_grad():
out = _prefill(model, input_ids, cache, device)
sim.update(cache)
logits.append(out.logits[0, -1].float().cpu())
abs_pos = input_ids.shape[1]
for t in gold[:-1]:
out = _step(model, t, cache, abs_pos, device)
sim.update(cache)
logits.append(out.logits[0, -1].float().cpu())
abs_pos += 1
return torch.stack(logits)
def fidelity(ref, scheme_logits):
top1 = (scheme_logits.argmax(-1) == ref.argmax(-1)).float().mean().item()
logp = torch.log_softmax(ref, dim=-1)
logq = torch.log_softmax(scheme_logits, dim=-1)
kl = (logp.exp() * (logp - logq)).sum(-1).mean().item()
return top1, kl
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--max-new", type=int, default=384)
ap.add_argument("--n-prompts", type=int, default=3)
args = ap.parse_args()
print(f"[load] {MODEL} ...", flush=True)
tok = AutoTokenizer.from_pretrained(MODEL)
model = AutoModelForCausalLM.from_pretrained(
MODEL, torch_dtype=torch.bfloat16, device_map="auto")
model.eval()
device = next(model.parameters()).device
eos = _eos_set(model)
agg = {n: {"top1": [], "kl": [], "krmse": [], "vrmse": []} for n in SCHEMES}
for pi, prompt in enumerate(PROMPTS[:args.n_prompts]):
input_ids = tok.apply_chat_template(
[{"role": "user", "content": prompt}],
add_generation_prompt=True, return_tensors="pt", return_dict=False).to(device)
t0 = time.time()
gold, ref_logits, cache = gen_bf16(model, input_ids, args.max_new, device, eos)
ctx = input_ids.shape[1] + len(gold)
for layer in cache.layers: # RMSE on the BF16 cache
S = layer.keys.shape[2]
nf = (S // BLOCK) * BLOCK
K, V = layer.keys[0, :, :nf], layer.values[0, :, :nf]
for n, s in SCHEMES.items():
agg[n]["krmse"].append(rmse_cell(K, *s["k"]))
agg[n]["vrmse"].append(rmse_cell(V, *s["v"]))
for n, s in SCHEMES.items(): # teacher-forced KL
top1, kl = fidelity(ref_logits, teacher_forced(model, input_ids, gold, s, device))
agg[n]["top1"].append(top1)
agg[n]["kl"].append(kl)
print(f"[prompt {pi}] ctx={ctx} tokens, {time.time()-t0:.0f}s", flush=True)
avg = lambda xs: sum(xs) / max(len(xs), 1)
base_kl = avg(agg[BASELINE]["kl"])
print("\n" + "=" * 78)
print(f"AGGREGATE over {args.n_prompts} prompts (production-faithful frozen-page protocol)")
print(f" {'scheme':<15} {'bits':>4} {'mem×':>5} {'K-RMSE':>8} {'V-RMSE':>8} "
f"{'top-1':>7} {'KL':>8} {'KL vs base':>11}")
print(f" {'-'*15} {'-'*4} {'-'*5} {'-'*8} {'-'*8} {'-'*7} {'-'*8} {'-'*11}")
for n, s in SCHEMES.items():
kl = avg(agg[n]["kl"])
kld = "baseline" if n == BASELINE else f"{100*(base_kl-kl)/max(base_kl,1e-12):+.0f}%"
print(f" {n:<15} {s['bits']:>4} {mem_ratio(s['bits']):>4.2f}x "
f"{avg(agg[n]['krmse']):>8.5f} {avg(agg[n]['vrmse']):>8.5f} "
f"{100*avg(agg[n]['top1']):>6.1f}% {kl:>8.5f} {kld:>11}")
if __name__ == "__main__":
main()
|