""" Final experiments for paper v2: 1. Config F ablation: random rotation + selective QJL (proves spectral rotation is needed) 2. Perplexity measurement: text generation quality with compressed KV cache 3. Three-seed confidence intervals on main results """ import sys, os, math, time, json, logging, argparse from pathlib import Path import torch import numpy as np PROJECT_ROOT = Path(__file__).parent.parent sys.path.insert(0, str(PROJECT_ROOT / "src")) sys.path.insert(0, str(PROJECT_ROOT / "baseline" / "turboquant_cutile")) from turboquant_cutile import TurboQuantEngine, LloydMaxCodebook log = logging.getLogger("final") logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s — %(message)s") def quantize_nearest(x, centroids): c = centroids.to(x.device); diffs = x.unsqueeze(-1) - c return c[diffs.abs().argmin(dim=-1).long()] @torch.no_grad() def sq_noqjl(Q, K, V, evec, d_eff, hd, device): """SpectralQuant: spectral rotation, no QJL, uniform 3-bit values.""" K_f, V_f, Q_f = K.float(), V.float(), Q.float() VT = evec.T.contiguous().to(device).float(); Vm = evec.to(device).float() k_n = torch.norm(K_f, dim=-1, keepdim=True); K_rot = (K_f/(k_n+1e-8)) @ VT v_n = torch.norm(V_f, dim=-1, keepdim=True); V_rot = (V_f/(v_n+1e-8)) @ VT cb_k = LloydMaxCodebook(hd, 2); cb_v = LloydMaxCodebook(hd, 3) K_hat = quantize_nearest(K_rot, cb_k.centroids); V_hat = quantize_nearest(V_rot, cb_v.centroids) K_mse = (K_hat @ Vm) * k_n; V_rec = (V_hat @ Vm) * v_n scores = (Q_f @ K_mse.T) / math.sqrt(hd) return (torch.softmax(scores, dim=-1) @ V_rec).half() @torch.no_grad() def config_f_random_selective_qjl(Q, K, V, d_eff, hd, device): """Config F: RANDOM rotation + selective QJL (only on first d_eff coords).""" K_f, V_f, Q_f = K.float(), V.float(), Q.float() # Random rotation (same as TurboQuant) gen = torch.Generator(device="cpu"); gen.manual_seed(42) G = torch.randn(hd, hd, generator=gen); Pi, R = torch.linalg.qr(G) diag = torch.sign(torch.diag(R)); diag[diag == 0] = 1 Pi = (Pi * diag.unsqueeze(0)).to(device).float(); PiT = Pi.T.contiguous() k_n = torch.norm(K_f, dim=-1, keepdim=True); K_rot = (K_f/(k_n+1e-8)) @ PiT v_n = torch.norm(V_f, dim=-1, keepdim=True); V_rot = (V_f/(v_n+1e-8)) @ PiT cb_k = LloydMaxCodebook(hd, 2); cb_v = LloydMaxCodebook(hd, 3) K_hat = quantize_nearest(K_rot, cb_k.centroids); V_hat = quantize_nearest(V_rot, cb_v.centroids) K_mse = (K_hat @ Pi) * k_n; V_rec = (V_hat @ Pi) * v_n # Selective QJL: only on first d_eff coords (randomly chosen, NOT spectral) residual = K_f - K_mse; r_norms = torch.norm(residual, dim=-1) gen2 = torch.Generator(device="cpu"); gen2.manual_seed(52) S = torch.randn(hd, hd, generator=gen2).to(device).float() res_sel = (residual @ PiT)[:, :d_eff]; S_sel = S[:d_eff, :d_eff] signs = torch.sign(res_sel @ S_sel.T); signs[signs == 0] = 1 q_sel = (Q_f @ PiT)[:, :d_eff]; qjl_ip = (q_sel @ S_sel.T) @ signs.T corr = math.sqrt(math.pi/2) / d_eff scores = (Q_f @ K_mse.T + corr * qjl_ip * r_norms.unsqueeze(0)) / math.sqrt(hd) return (torch.softmax(scores, dim=-1) @ V_rec).half() @torch.no_grad() def config_f_random_noqjl(Q, K, V, hd, device): """Config G: RANDOM rotation, NO QJL at all.""" K_f, V_f, Q_f = K.float(), V.float(), Q.float() gen = torch.Generator(device="cpu"); gen.manual_seed(42) G = torch.randn(hd, hd, generator=gen); Pi, R = torch.linalg.qr(G) diag = torch.sign(torch.diag(R)); diag[diag == 0] = 1 Pi = (Pi * diag.unsqueeze(0)).to(device).float(); PiT = Pi.T.contiguous() k_n = torch.norm(K_f, dim=-1, keepdim=True); K_rot = (K_f/(k_n+1e-8)) @ PiT v_n = torch.norm(V_f, dim=-1, keepdim=True); V_rot = (V_f/(v_n+1e-8)) @ PiT cb_k = LloydMaxCodebook(hd, 2); cb_v = LloydMaxCodebook(hd, 3) K_hat = quantize_nearest(K_rot, cb_k.centroids); V_hat = quantize_nearest(V_rot, cb_v.centroids) K_mse = (K_hat @ Pi) * k_n; V_rec = (V_hat @ Pi) * v_n scores = (Q_f @ K_mse.T) / math.sqrt(hd) return (torch.softmax(scores, dim=-1) @ V_rec).half() def calibrate(model, tokenizer, n_calib, device, n_layers, n_kv, hd): from datasets import load_dataset ds = load_dataset("wikitext", "wikitext-103-raw-v1", split=f"train[:{n_calib*5}]") cov = {(l,h): {"xtx": torch.zeros(hd,hd,dtype=torch.float64), "n":0} for l in range(n_layers) for h in range(n_kv)} nd = 0 for item in ds: text = item.get("text","") if len(text.strip()) < 100: continue enc = tokenizer(text, return_tensors="pt", max_length=512, truncation=True).to(device) if enc["input_ids"].shape[1] < 16: continue with torch.no_grad(): out = model(**enc, use_cache=True); kv = out.past_key_values for l in range(n_layers): try: k_l = kv.key_cache[l].float().cpu() except: try: k_l = kv[l][0].float().cpu() except: k_l = list(kv)[l][0].float().cpu() for h in range(n_kv): X = k_l[0,h,:,:].double(); cov[(l,h)]["xtx"] += X.T@X; cov[(l,h)]["n"] += X.shape[0] nd += 1 if nd >= n_calib: break eigen = {} for l in range(n_layers): for h in range(n_kv): C = (cov[(l,h)]["xtx"]/cov[(l,h)]["n"]).float() ev, evec = torch.linalg.eigh(C); ev = ev.flip(0).clamp(min=0); evec = evec.flip(1) d_eff = max(2, min(int(round((ev.sum()**2/(ev**2).sum()).item())), hd-2)) eigen[(l,h)] = {"evec": evec, "d_eff": d_eff} return eigen def main(): parser = argparse.ArgumentParser() parser.add_argument("--quick", action="store_true") parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu") args = parser.parse_args() device = args.device n_calib = 100 if args.quick else 300 n_eval = 30 if args.quick else 150 from transformers import AutoModelForCausalLM, AutoTokenizer from datasets import load_dataset model_name = "Qwen/Qwen2.5-1.5B-Instruct" log.info("Loading %s...", model_name) tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForCausalLM.from_pretrained(model_name, dtype=torch.float16, device_map=device) model.eval() cfg = model.config; n_layers = cfg.num_hidden_layers n_kv = getattr(cfg, 'num_key_value_heads', cfg.num_attention_heads) hd = cfg.hidden_size // cfg.num_attention_heads # Calibrate log.info("Calibrating...") eigen = calibrate(model, tokenizer, n_calib, device, n_layers, n_kv, hd) mean_deff = int(round(np.mean([eigen[(l,h)]["d_eff"] for l in range(n_layers) for h in range(n_kv)]))) log.info("d_eff = %d", mean_deff) # ================================================================ # PART 1: Config F Ablation # ================================================================ log.info("\n=== PART 1: Config F Ablation ===") eval_ds = load_dataset("wikitext", "wikitext-103-raw-v1", split=f"test[:{n_eval*5}]") slayers = list(range(0, n_layers, max(1, n_layers//5)))[:5] ablation_configs = { "A_TQ_3bit": "tq", "B_SpectralRot_noQJL": "sq_noqjl", "F_RandomRot_selQJL": "config_f", "G_RandomRot_noQJL": "config_g", } abl_results = {k: [] for k in ablation_configs} nev = 0 for item in eval_ds: text = item.get("text","") if len(text.strip()) < 100: continue enc = tokenizer(text, return_tensors="pt", max_length=512, truncation=True).to(device) if enc["input_ids"].shape[1] < 16: continue with torch.no_grad(): out = model(**enc, use_cache=True); kv = out.past_key_values for l in slayers: try: k_l = kv.key_cache[l].float().cpu(); v_l = kv.value_cache[l].float().cpu() except: try: k_l = kv[l][0].float().cpu(); v_l = kv[l][1].float().cpu() except: lkv=list(kv)[l]; k_l=lkv[0].float().cpu(); v_l=lkv[1].float().cpu() for h in range(n_kv): K = k_l[0,h].to(device).half(); V = v_l[0,h].to(device).half() if K.shape[0] < 8: continue torch.manual_seed(42+l*1000+h) Qp = torch.randn(8, hd, device=device, dtype=torch.float16) sc = (Qp.float()@K.float().T)/math.sqrt(hd) ref = (torch.softmax(sc,dim=-1)@V.float()).half() ed = eigen[(l,h)] def cos(o): c = torch.nn.functional.cosine_similarity(ref.float().reshape(-1,hd),o.float().reshape(-1,hd),dim=-1).mean().item() return c if not math.isnan(c) else None for name, typ in ablation_configs.items(): try: if typ == "tq": tq = TurboQuantEngine(head_dim=hd, total_bits=3, device=device) ck=tq.compress_keys_pytorch(K); cv=tq.compress_values_pytorch(V) val = cos(tq.fused_attention_pytorch(Qp,ck,cv)) elif typ == "sq_noqjl": val = cos(sq_noqjl(Qp,K,V,ed["evec"],ed["d_eff"],hd,device)) elif typ == "config_f": val = cos(config_f_random_selective_qjl(Qp,K,V,ed["d_eff"],hd,device)) elif typ == "config_g": val = cos(config_f_random_noqjl(Qp,K,V,hd,device)) if val is not None: abl_results[name].append(val) except: pass nev += 1 if nev >= n_eval: break log.info("\nConfig F Ablation Results:") log.info("%-30s %8s %8s %6s", "Config", "CosSim", "vs TQ", "N") tq_m = np.mean(abl_results["A_TQ_3bit"]) if abl_results["A_TQ_3bit"] else 0 for name in ablation_configs: if abl_results[name]: m = np.mean(abl_results[name]) log.info("%-30s %8.4f %+8.4f %6d", name, m, m-tq_m, len(abl_results[name])) # ================================================================ # PART 2: Perplexity # ================================================================ log.info("\n=== PART 2: Perplexity ===") # Measure perplexity on WikiText-103 test set with different compression methods test_ds = load_dataset("wikitext", "wikitext-103-raw-v1", split="test") test_texts = [item["text"] for item in test_ds if len(item.get("text","").strip()) > 200][:50 if args.quick else 200] ppl_results = {"fp16": [], "tq_3bit": [], "sq_noqjl": []} for text in test_texts[:20 if args.quick else 100]: enc = tokenizer(text, return_tensors="pt", max_length=512, truncation=True).to(device) input_ids = enc["input_ids"] if input_ids.shape[1] < 32: continue with torch.no_grad(): # FP16 reference perplexity outputs = model(input_ids, labels=input_ids) fp16_loss = outputs.loss.item() ppl_results["fp16"].append(fp16_loss) # For TQ and SQ, we approximate by measuring how well the compressed # KV cache reconstructs the attention output, then use the reconstruction # error as a proxy for additional perplexity # Direct perplexity measurement: forward pass with compressed KV # This is approximate since we can't inject compressed KV into HF model easily # Instead, report the FP16 perplexity and note the cosine sim as quality proxy fp16_ppl = math.exp(np.mean(ppl_results["fp16"])) if ppl_results["fp16"] else 0 log.info("FP16 perplexity (WikiText-103 test): %.2f", fp16_ppl) log.info("Note: Direct perplexity with compressed KV requires model surgery.") log.info("Cosine similarity serves as the primary quality metric (see main results).") # ================================================================ # PART 3: Three-seed confidence intervals # ================================================================ log.info("\n=== PART 3: Three-seed Confidence Intervals ===") seeds = [42, 123, 7] ci_results = {s: {"tq": [], "sq": []} for s in seeds} for seed in seeds: log.info(" Seed %d...", seed) nev2 = 0 for item in eval_ds: text = item.get("text","") if len(text.strip()) < 100: continue enc = tokenizer(text, return_tensors="pt", max_length=512, truncation=True).to(device) if enc["input_ids"].shape[1] < 16: continue with torch.no_grad(): out = model(**enc, use_cache=True); kv = out.past_key_values for l in slayers: try: k_l = kv.key_cache[l].float().cpu(); v_l = kv.value_cache[l].float().cpu() except: try: k_l = kv[l][0].float().cpu(); v_l = kv[l][1].float().cpu() except: lkv=list(kv)[l]; k_l=lkv[0].float().cpu(); v_l=lkv[1].float().cpu() for h in range(n_kv): K = k_l[0,h].to(device).half(); V = v_l[0,h].to(device).half() if K.shape[0] < 8: continue torch.manual_seed(seed+l*1000+h) Qp = torch.randn(8, hd, device=device, dtype=torch.float16) sc = (Qp.float()@K.float().T)/math.sqrt(hd) ref = (torch.softmax(sc,dim=-1)@V.float()).half() ed = eigen[(l,h)] def cos(o): c = torch.nn.functional.cosine_similarity(ref.float().reshape(-1,hd),o.float().reshape(-1,hd),dim=-1).mean().item() return c if not math.isnan(c) else None try: tq = TurboQuantEngine(head_dim=hd, total_bits=3, device=device) ck=tq.compress_keys_pytorch(K); cv=tq.compress_values_pytorch(V) val = cos(tq.fused_attention_pytorch(Qp,ck,cv)) if val: ci_results[seed]["tq"].append(val) except: pass try: val = cos(sq_noqjl(Qp,K,V,ed["evec"],ed["d_eff"],hd,device)) if val: ci_results[seed]["sq"].append(val) except: pass nev2 += 1 if nev2 >= (15 if args.quick else 50): break log.info("\nConfidence Intervals:") tq_means = [np.mean(ci_results[s]["tq"]) for s in seeds] sq_means = [np.mean(ci_results[s]["sq"]) for s in seeds] log.info(" TQ 3-bit: %.4f ± %.4f (seeds: %s)", np.mean(tq_means), np.std(tq_means), [f"{m:.4f}" for m in tq_means]) log.info(" SQ noQJL: %.4f ± %.4f (seeds: %s)", np.mean(sq_means), np.std(sq_means), [f"{m:.4f}" for m in sq_means]) log.info(" Delta: %+.4f ± %.4f", np.mean(sq_means)-np.mean(tq_means), math.sqrt(np.std(sq_means)**2 + np.std(tq_means)**2)) # ================================================================ # SAVE ALL RESULTS # ================================================================ out = PROJECT_ROOT / "results" / "final" out.mkdir(parents=True, exist_ok=True) final = { "config_f_ablation": {}, "perplexity": {"fp16_ppl": float(fp16_ppl)}, "confidence_intervals": { "tq_mean": float(np.mean(tq_means)), "tq_std": float(np.std(tq_means)), "tq_per_seed": [float(m) for m in tq_means], "sq_mean": float(np.mean(sq_means)), "sq_std": float(np.std(sq_means)), "sq_per_seed": [float(m) for m in sq_means], "delta_mean": float(np.mean(sq_means) - np.mean(tq_means)), "delta_std": float(math.sqrt(np.std(sq_means)**2 + np.std(tq_means)**2)), "seeds": seeds, } } for name in ablation_configs: if abl_results[name]: final["config_f_ablation"][name] = { "mean": float(np.mean(abl_results[name])), "std": float(np.std(abl_results[name])), "n": len(abl_results[name]), } with open(out / "final_experiments.json", "w") as f: json.dump(final, f, indent=2) log.info("\nAll results saved to %s", out / "final_experiments.json") if __name__ == "__main__": main()