426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
465 lines
17 KiB
Python
465 lines
17 KiB
Python
"""End-to-end validation of TurboQuant on a real Gemma model on the local 5080.
|
|
|
|
Honest about what TurboQuant is: a *runtime KV-cache* quantizer. It does
|
|
NOT shrink ``model.safetensors`` on disk -- the weights are unchanged.
|
|
The win is in the per-step KV cache memory at long context.
|
|
|
|
This script therefore measures the things TurboQuant actually changes:
|
|
|
|
* KV-cache *bytes per token* (analytic, from the quantizer geometry)
|
|
* Peak generation VRAM with a long context (empirical, with
|
|
``torch.cuda.reset_peak_memory_stats`` framing)
|
|
* Tokens / sec for both runs (wall clock)
|
|
* Output sanity (the quantized model still produces a non-empty native JSON-
|
|
looking response on each of 5 sampled prompts)
|
|
|
|
Default model is ``google/gemma-4-E2B``. This is a hybrid
|
|
linear-attention + Gated Attention model; the cache machinery applies to
|
|
the full-attention layers and bypasses linear-attention layers. The
|
|
assertions are correspondingly looser than old dense full-attention smoke runs.
|
|
|
|
Usage::
|
|
|
|
.venv/bin/python scripts/quantization/test_turboquant.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import gc
|
|
import json
|
|
import logging
|
|
import math
|
|
import os
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
torch = pytest.importorskip("torch")
|
|
transformers = pytest.importorskip("transformers")
|
|
AutoModelForCausalLM = transformers.AutoModelForCausalLM
|
|
AutoTokenizer = transformers.AutoTokenizer
|
|
DynamicCache = pytest.importorskip("transformers.cache_utils").DynamicCache
|
|
TurboQuantCache = pytest.importorskip("turboquant").TurboQuantCache
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
)
|
|
log = logging.getLogger("test_turboquant")
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
VAL_JSONL = ROOT / "data" / "final" / "val.jsonl"
|
|
|
|
|
|
def load_payload_message_handler_prompts(n: int = 5) -> list[dict]:
|
|
"""Pull n records whose expected response looks like a native JSON message_handler
|
|
document (starts with `thought:` and contains `text:` somewhere). These
|
|
are the canonical assistant-turn shape for the message_handler task.
|
|
"""
|
|
out: list[dict] = []
|
|
with VAL_JSONL.open("r", encoding="utf-8") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
rec = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
er = rec.get("expectedResponse") or ""
|
|
if er.lstrip().startswith("thought:") and "\ntext:" in er:
|
|
out.append(rec)
|
|
if len(out) >= n:
|
|
break
|
|
if len(out) < n:
|
|
# Fall back to thought-only docs (some message_handler outputs are
|
|
# action-routing docs without a `text:` field). Still native JSON.
|
|
with VAL_JSONL.open("r", encoding="utf-8") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
rec = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
er = rec.get("expectedResponse") or ""
|
|
if er.lstrip().startswith("thought:"):
|
|
if rec not in out:
|
|
out.append(rec)
|
|
if len(out) >= n:
|
|
break
|
|
if len(out) < n:
|
|
raise RuntimeError(f"Only found {len(out)} native JSON prompts in {VAL_JSONL}")
|
|
return out[:n]
|
|
|
|
|
|
def render_chat(tokenizer, record: dict) -> str:
|
|
"""Render a record into a chat prompt ending with the assistant turn open.
|
|
|
|
We intentionally avoid pulling in ``format_for_training.format_record``
|
|
here so this script has no dependency on the synth pipeline beyond the
|
|
public dataset shape.
|
|
"""
|
|
sys_prompt = (
|
|
"You are an autonomous elizaOS agent. Decide which action to take "
|
|
"from `availableActions` and respond with ONE native JSON document. "
|
|
"Always native JSON. No fences, no <think>, no prose before or after."
|
|
)
|
|
msgs = [{"role": "system", "content": sys_prompt}]
|
|
for m in record.get("memoryEntries") or []:
|
|
role = m.get("role") or "user"
|
|
if role not in ("user", "assistant"):
|
|
continue
|
|
content = m.get("content") or ""
|
|
if content:
|
|
msgs.append({"role": role, "content": content})
|
|
cm = record.get("currentMessage") or {}
|
|
msgs.append({"role": "user", "content": cm.get("content") or ""})
|
|
return tokenizer.apply_chat_template(
|
|
msgs, add_generation_prompt=True, tokenize=False
|
|
)
|
|
|
|
|
|
def kv_bytes_per_token_analytic(
|
|
config, *, nbits: int, skip_layers: set[int]
|
|
) -> tuple[int, int]:
|
|
"""Analytic per-token KV bytes for baseline (bf16 DynamicCache) vs quantized.
|
|
|
|
For each *full-attention* layer we have ``num_kv_heads * head_dim`` key
|
|
coords + same for value coords stored per token.
|
|
|
|
Baseline: 2 (bytes/bf16) per coord -> 2 * num_kv_heads * head_dim per K and V.
|
|
TurboQuant: nbits/8 per coord + a per-vector norm scalar in bf16.
|
|
Skipped layers stay at baseline.
|
|
"""
|
|
text_cfg = (
|
|
config.get_text_config(decoder=True)
|
|
if hasattr(config, "get_text_config")
|
|
else config
|
|
)
|
|
head_dim = getattr(text_cfg, "head_dim", None) or (
|
|
text_cfg.hidden_size // text_cfg.num_attention_heads
|
|
)
|
|
num_kv_heads = getattr(text_cfg, "num_key_value_heads", None) or text_cfg.num_attention_heads
|
|
|
|
# Number of *full attention* layers (where a KV cache materializes).
|
|
# Hybrid Gemma 4 specify layer_types; fall back to "all are full".
|
|
layer_types = getattr(text_cfg, "layer_types", None)
|
|
if layer_types:
|
|
full_idx = [i for i, t in enumerate(layer_types) if t == "full_attention"]
|
|
else:
|
|
full_idx = list(range(text_cfg.num_hidden_layers))
|
|
|
|
baseline_per_token = 0
|
|
quantized_per_token = 0
|
|
for i in full_idx:
|
|
# Per layer, per token, K + V vectors of length (num_kv_heads * head_dim)
|
|
coords_k = num_kv_heads * head_dim
|
|
coords_v = num_kv_heads * head_dim
|
|
baseline_per_token += 2 * (coords_k + coords_v) # bf16
|
|
if i in skip_layers:
|
|
quantized_per_token += 2 * (coords_k + coords_v)
|
|
else:
|
|
# nbits per coord, packed; plus 2 bytes (bf16) per (head, token) for the norm.
|
|
quantized_per_token += int(math.ceil(coords_k * nbits / 8))
|
|
quantized_per_token += int(math.ceil(coords_v * nbits / 8))
|
|
quantized_per_token += 2 * num_kv_heads * 2 # K-norm + V-norm scalars
|
|
return baseline_per_token, quantized_per_token
|
|
|
|
|
|
def measure_generation(
|
|
model,
|
|
tokenizer,
|
|
prompts: list[str],
|
|
*,
|
|
cache_factory,
|
|
max_new_tokens: int,
|
|
label: str,
|
|
long_context_prompt: str | None = None,
|
|
) -> dict:
|
|
"""Run generation on all `prompts` and (optionally) one long-context probe.
|
|
|
|
Returns a dict with peak memory, total elapsed, total new tokens, tok/s,
|
|
and the decoded texts. The long-context probe is what surfaces the KV
|
|
cache savings; short prompts are mostly weight-bound.
|
|
"""
|
|
torch.cuda.empty_cache()
|
|
gc.collect()
|
|
torch.cuda.reset_peak_memory_stats()
|
|
|
|
decoded: list[str] = []
|
|
total_new = 0
|
|
t0 = time.perf_counter()
|
|
for p in prompts:
|
|
cache = cache_factory()
|
|
ids = tokenizer(p, return_tensors="pt").to(model.device)
|
|
with torch.no_grad():
|
|
out = model.generate(
|
|
**ids,
|
|
past_key_values=cache,
|
|
max_new_tokens=max_new_tokens,
|
|
do_sample=False,
|
|
pad_token_id=tokenizer.eos_token_id,
|
|
)
|
|
new = out[0, ids.input_ids.shape[-1]:]
|
|
total_new += int(new.shape[-1])
|
|
decoded.append(tokenizer.decode(new, skip_special_tokens=True))
|
|
del cache, out, ids
|
|
torch.cuda.synchronize()
|
|
elapsed = time.perf_counter() - t0
|
|
|
|
long_peak = None
|
|
if long_context_prompt is not None:
|
|
torch.cuda.empty_cache()
|
|
gc.collect()
|
|
torch.cuda.reset_peak_memory_stats()
|
|
cache = cache_factory()
|
|
ids = tokenizer(long_context_prompt, return_tensors="pt", truncation=False).to(
|
|
model.device
|
|
)
|
|
with torch.no_grad():
|
|
_ = model.generate(
|
|
**ids,
|
|
past_key_values=cache,
|
|
max_new_tokens=64,
|
|
do_sample=False,
|
|
pad_token_id=tokenizer.eos_token_id,
|
|
)
|
|
long_peak = torch.cuda.max_memory_allocated()
|
|
del cache, ids
|
|
torch.cuda.synchronize()
|
|
|
|
short_peak = torch.cuda.max_memory_allocated()
|
|
return {
|
|
"label": label,
|
|
"elapsed_s": elapsed,
|
|
"tokens_new": total_new,
|
|
"toks_per_s": total_new / elapsed if elapsed > 0 else 0.0,
|
|
"peak_vram_short_bytes": int(short_peak),
|
|
"peak_vram_long_bytes": int(long_peak) if long_peak is not None else None,
|
|
"decoded": decoded,
|
|
}
|
|
|
|
|
|
def directory_size_bytes(path: Path) -> int:
|
|
total = 0
|
|
for root, _, files in os.walk(path):
|
|
for f in files:
|
|
fp = Path(root) / f
|
|
if fp.is_file():
|
|
total += fp.stat().st_size
|
|
return total
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
|
|
ap.add_argument("--model", default="google/gemma-4-E2B")
|
|
ap.add_argument("--num-prompts", type=int, default=5)
|
|
ap.add_argument("--max-new-tokens", type=int, default=128)
|
|
ap.add_argument("--calibration-samples", type=int, default=32)
|
|
ap.add_argument("--nbits", type=int, default=4, choices=(2, 4))
|
|
ap.add_argument("--long-context-tokens", type=int, default=4096)
|
|
ap.add_argument(
|
|
"--report",
|
|
default=str(ROOT / "scripts" / "quantization" / "turboquant_report.json"),
|
|
)
|
|
args = ap.parse_args()
|
|
|
|
if not torch.cuda.is_available():
|
|
raise RuntimeError("CUDA required")
|
|
|
|
log.info("loading %s", args.model)
|
|
tok = AutoTokenizer.from_pretrained(args.model, trust_remote_code=True)
|
|
model = AutoModelForCausalLM.from_pretrained(
|
|
args.model,
|
|
torch_dtype=torch.bfloat16,
|
|
device_map="cuda",
|
|
trust_remote_code=True,
|
|
)
|
|
model.eval()
|
|
|
|
# On-disk size of the model snapshot (bf16) -- reported for context.
|
|
# TurboQuant does not change this number; we record it explicitly so
|
|
# nobody mistakes the reduction we *do* report (KV cache) for a weight
|
|
# quantization win.
|
|
model_dir = Path(model.config._name_or_path)
|
|
on_disk_bytes = None
|
|
if model_dir.exists() and model_dir.is_dir():
|
|
on_disk_bytes = directory_size_bytes(model_dir)
|
|
else:
|
|
# Resolve through HF cache
|
|
from huggingface_hub import snapshot_download
|
|
|
|
snap = Path(snapshot_download(args.model, allow_patterns=["*.safetensors"]))
|
|
on_disk_bytes = directory_size_bytes(snap)
|
|
|
|
# Pull native JSON prompts and build long-context calibration.
|
|
records = load_payload_message_handler_prompts(args.num_prompts)
|
|
rendered = [render_chat(tok, r) for r in records]
|
|
log.info("loaded %d native JSON prompts", len(rendered))
|
|
# Long-context probe: tile a non-trivial corpus through the chat template
|
|
# so we hit a realistic generation regime where KV dominates.
|
|
long_prompt_text = (
|
|
"Summarize the following operational notes in native JSON.\n\n"
|
|
+ (records[0].get("currentMessage") or {}).get("content", "")
|
|
)
|
|
long_ids_full = tok(long_prompt_text, return_tensors="pt").input_ids[0]
|
|
if long_ids_full.shape[-1] < args.long_context_tokens:
|
|
long_prompt_text = (long_prompt_text + "\n\n") * (
|
|
args.long_context_tokens // max(long_ids_full.shape[-1], 1) + 1
|
|
)
|
|
long_ids_full = tok(long_prompt_text, return_tensors="pt").input_ids[0]
|
|
long_ids = long_ids_full[: args.long_context_tokens]
|
|
long_prompt = tok.decode(long_ids, skip_special_tokens=True)
|
|
log.info(
|
|
"long-context probe: %d tokens", tok(long_prompt, return_tensors="pt").input_ids.shape[-1]
|
|
)
|
|
|
|
# Calibrate skip layers using a handful of prompts.
|
|
log.info("calibrating skip_layers across %d prompts", args.calibration_samples)
|
|
cal_prompts: list[str] = []
|
|
with VAL_JSONL.open("r", encoding="utf-8") as f:
|
|
for line in f:
|
|
try:
|
|
rec = json.loads(line)
|
|
except Exception:
|
|
continue
|
|
cm = (rec.get("currentMessage") or {}).get("content") or ""
|
|
if cm:
|
|
cal_prompts.append(cm[:512])
|
|
if len(cal_prompts) >= args.calibration_samples:
|
|
break
|
|
skip: set[int] = set()
|
|
for p in cal_prompts:
|
|
s = TurboQuantCache.calibrate_skip_layers(model, tok, calibration_text=p)
|
|
skip |= s
|
|
log.info("skip_layers (union): %s", sorted(skip))
|
|
|
|
# Analytic KV bytes per token
|
|
base_bpt, quant_bpt = kv_bytes_per_token_analytic(
|
|
model.config, nbits=args.nbits, skip_layers=skip
|
|
)
|
|
log.info(
|
|
"analytic KV bytes/token: baseline=%d quantized=%d (%.2fx reduction)",
|
|
base_bpt,
|
|
quant_bpt,
|
|
base_bpt / max(quant_bpt, 1),
|
|
)
|
|
|
|
# Baseline (bf16 DynamicCache)
|
|
log.info("=== BASELINE: bf16 DynamicCache ===")
|
|
base_res = measure_generation(
|
|
model,
|
|
tok,
|
|
rendered,
|
|
cache_factory=lambda: DynamicCache(),
|
|
max_new_tokens=args.max_new_tokens,
|
|
label="baseline_bf16",
|
|
long_context_prompt=long_prompt,
|
|
)
|
|
|
|
# Quantized (TurboQuant)
|
|
log.info("=== TURBOQUANT: %d-bit ===", args.nbits)
|
|
quant_res = measure_generation(
|
|
model,
|
|
tok,
|
|
rendered,
|
|
cache_factory=lambda: TurboQuantCache(
|
|
model.config, nbits=args.nbits, base_seed=42, skip_layers=skip
|
|
),
|
|
max_new_tokens=args.max_new_tokens,
|
|
label=f"turboquant_{args.nbits}bit",
|
|
long_context_prompt=long_prompt,
|
|
)
|
|
|
|
# Reporting
|
|
print()
|
|
print("=" * 78)
|
|
print("TurboQuant validation report")
|
|
print("=" * 78)
|
|
print(f"model: {args.model}")
|
|
print(f"on-disk size (bf16): {on_disk_bytes / 1e6:.2f} MB (unchanged by TurboQuant)")
|
|
print(f"skip_layers: {sorted(skip)}")
|
|
print(f"nbits: {args.nbits}")
|
|
print()
|
|
print("KV cache bytes / token (analytic, full-attention layers only):")
|
|
print(f" baseline: {base_bpt:>10,} bytes")
|
|
print(f" turboquant: {quant_bpt:>10,} bytes")
|
|
print(f" reduction: {base_bpt / max(quant_bpt, 1):.2f}x ({100 * (1 - quant_bpt / max(base_bpt, 1)):.1f}% smaller)")
|
|
print()
|
|
print(f"Short-prompt batch ({args.num_prompts} prompts, {args.max_new_tokens} new tokens each):")
|
|
print(f" baseline tok/s: {base_res['toks_per_s']:.2f} ({base_res['tokens_new']} tok in {base_res['elapsed_s']:.2f}s)")
|
|
print(f" turboquant tok/s: {quant_res['toks_per_s']:.2f} ({quant_res['tokens_new']} tok in {quant_res['elapsed_s']:.2f}s)")
|
|
print(f" baseline peak VRAM: {base_res['peak_vram_short_bytes'] / 1e9:.3f} GB")
|
|
print(f" turboquant peak VRAM: {quant_res['peak_vram_short_bytes'] / 1e9:.3f} GB")
|
|
print()
|
|
if base_res["peak_vram_long_bytes"] is not None:
|
|
print(f"Long-context probe ({args.long_context_tokens} tokens prefill + 64 new):")
|
|
print(f" baseline peak VRAM: {base_res['peak_vram_long_bytes'] / 1e9:.3f} GB")
|
|
print(f" turboquant peak VRAM: {quant_res['peak_vram_long_bytes'] / 1e9:.3f} GB")
|
|
delta = (
|
|
base_res["peak_vram_long_bytes"] - quant_res["peak_vram_long_bytes"]
|
|
) / 1e6
|
|
print(f" delta: {delta:+.2f} MB saved")
|
|
print()
|
|
print("Sample TurboQuant outputs:")
|
|
for i, txt in enumerate(quant_res["decoded"]):
|
|
snippet = txt[:240].replace("\n", " ")
|
|
print(f" [{i + 1}] {snippet!r}")
|
|
print("=" * 78)
|
|
|
|
# Assertions
|
|
failures: list[str] = []
|
|
|
|
# KV cache reduction must be meaningful (> 30% on the cache itself).
|
|
if quant_bpt >= 0.7 * base_bpt:
|
|
failures.append(
|
|
f"KV cache reduction insufficient: {quant_bpt}/{base_bpt} bytes/token "
|
|
f"(>30% reduction required)"
|
|
)
|
|
|
|
# Quantized outputs must be non-empty / non-degenerate.
|
|
for i, txt in enumerate(quant_res["decoded"]):
|
|
stripped = (txt or "").strip()
|
|
if len(stripped) < 8:
|
|
failures.append(f"prompt {i}: quantized output too short: {stripped!r}")
|
|
elif stripped.count(stripped[:8]) > 6:
|
|
failures.append(
|
|
f"prompt {i}: quantized output looks degenerate (repeats): {stripped[:80]!r}"
|
|
)
|
|
|
|
report = {
|
|
"model": args.model,
|
|
"on_disk_bytes": on_disk_bytes,
|
|
"nbits": args.nbits,
|
|
"skip_layers": sorted(skip),
|
|
"kv_bytes_per_token_baseline": base_bpt,
|
|
"kv_bytes_per_token_quantized": quant_bpt,
|
|
"kv_reduction_factor": base_bpt / max(quant_bpt, 1),
|
|
"baseline": {
|
|
k: v for k, v in base_res.items() if k != "decoded"
|
|
},
|
|
"turboquant": {
|
|
k: v for k, v in quant_res.items() if k != "decoded"
|
|
},
|
|
"sample_outputs": quant_res["decoded"],
|
|
"assertions_failed": failures,
|
|
}
|
|
Path(args.report).write_text(json.dumps(report, indent=2), encoding="utf-8")
|
|
log.info("wrote report to %s", args.report)
|
|
|
|
if failures:
|
|
print("\nFAILED ASSERTIONS:")
|
|
for f in failures:
|
|
print(f" - {f}")
|
|
return 1
|
|
print("\nAll assertions passed.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|