yams/scripts/bench_turboquant_compare.py
Trevon 72647bbaf7
chore: stabilize CI, faiss, metadata, docker, and simeon (#25)
- CI warm: ccache dep, continue-on-error, 60min timeout, YAMS_DISABLE_FAISS
- Faiss: YAMS_HAS_FAISS guard, Apple Clang disable, OpenMP detection
- Metadata: retry constraint failures, deduplicate batch UPSERT entries
- Docker: wget for Zig, gfortran, YAMS_DISABLE_FAISS=1
- CI config: free-disk-space v1.3.1, libomp-18-dev, experimental trigger
- Weekly benchmarks: faiss disable, ccache, diagnostics capture
- Simeon: bump to 7763a97, EntropyRouter, concept_count
- CorpusStats: extraction readiness fields
- Remove committed artifacts (.wrangler, .artifacts, dispatcher_external)
- Fix .vscode/settings.json hardcoded path

Signed-off-by: trvon <git@trevon.dev>
2026-05-07 21:07:59 -04:00

306 lines
11 KiB
Python
Executable file

#!/usr/bin/env python3
"""
TurboQuant Benchmark Comparison Script
Compares baseline 8-bit linear quantization vs TurboQuant storage retrieval benchmarks.
Detects regressions in encode/decode latency, storage efficiency, and quality metrics.
Usage:
# First, capture baseline (before TurboQuant integration):
./builddir/tests/benchmarks/turboquant_bench --vectors=1000 > bench_old.json
# After TurboQuant integration:
./builddir/tests/benchmarks/turboquant_bench --vectors=1000 > bench_new.json
# Run comparison:
./scripts/bench_turboquant_compare.py --old bench_old.json --new bench_new.json
Output:
- Console summary of regressions
- benchmark_comparison.json with detailed results
- Exit code 0 if no regressions, 1 if regressions found
"""
import json
import sys
import argparse
from dataclasses import dataclass
from typing import Optional
@dataclass
class RegressionThresholds:
storage_increase_max: float = 1.05 # Allow 5% tolerance
encode_latency_increase_max: float = 1.50 # Allow 50% increase
decode_latency_increase_max: float = 1.50 # Allow 50% increase
mse_max: float = 0.01 # MSE must be < 1%
recall_at_1_min: float = 0.85 # Recall must be > 85%
@dataclass
class BenchmarkResult:
dimension: int
bitwidth: int
storage_bytes: float
encode_latency_us_p50: float
decode_latency_us_p50: float
baseline_encode_us_p50: float
baseline_decode_us_p50: float
mse: float
recall_at_1: float
speedup_encode: float
speedup_decode: float
class BenchmarkComparator:
def __init__(self, old_data: dict, new_data: dict, thresholds: RegressionThresholds):
self.old_data = old_data
self.new_data = new_data
self.thresholds = thresholds
self.regressions = []
self.warnings = []
def find_result(self, data: dict, dimension: int, bitwidth: int) -> Optional[BenchmarkResult]:
for r in data.get("results", []):
if r["dimension"] == dimension and r["bitwidth"] == bitwidth:
return BenchmarkResult(
dimension=r["dimension"],
bitwidth=r["bitwidth"],
storage_bytes=r.get("storage_bytes", 0),
encode_latency_us_p50=r.get("encode_latency_us_p50", 0),
decode_latency_us_p50=r.get("decode_latency_us_p50", 0),
baseline_encode_us_p50=r.get("baseline_encode_us_p50", r.get("encode_latency_us_p50", 0)),
baseline_decode_us_p50=r.get("baseline_decode_us_p50", r.get("decode_latency_us_p50", 0)),
mse=r.get("mse", 0),
recall_at_1=r.get("recall_at_1", 1.0),
speedup_encode=r.get("speedup_encode", 1.0),
speedup_decode=r.get("speedup_decode", 1.0),
)
return None
def compare(self) -> tuple[bool, list, list]:
old_results = self.old_data.get("results", [])
new_results = self.new_data.get("results", [])
# Index new results by dimension/bitwidth
new_indexed = {(r["dimension"], r["bitwidth"]): r for r in new_results}
for old_r in old_results:
dim = old_r["dimension"]
bits = old_r["bitwidth"]
if (dim, bits) not in new_indexed:
self.warnings.append(f"Missing new result for dim={dim}, bits={bits}")
continue
new_r = new_indexed[(dim, bits)]
# Compare storage (should decrease, not increase)
old_storage = old_r.get("storage_bytes", 0)
new_storage = new_r.get("storage_bytes", 0)
if old_storage > 0 and new_storage > old_storage * self.thresholds.storage_increase_max:
self.regressions.append(
f"STORAGE: dim={dim}, bits={bits}: "
f"{old_storage:.0f} bytes -> {new_storage:.0f} bytes "
f"({new_storage/old_storage:.2f}x)"
)
# Compare encode latency (should not increase too much)
old_encode = old_r.get("baseline_encode_us_p50", old_r.get("encode_latency_us_p50", 0))
new_encode = new_r.get("encode_latency_us_p50", 0)
if old_encode > 0 and new_encode > old_encode * self.thresholds.encode_latency_increase_max:
self.regressions.append(
f"ENCODE_LATENCY: dim={dim}, bits={bits}: "
f"{old_encode:.2f}μs -> {new_encode:.2f}μs "
f"({new_encode/old_encode:.2f}x increase)"
)
# Compare decode latency (should not increase too much)
old_decode = old_r.get("baseline_decode_us_p50", old_r.get("decode_latency_us_p50", 0))
new_decode = new_r.get("decode_latency_us_p50", 0)
if old_decode > 0 and new_decode > old_decode * self.thresholds.decode_latency_increase_max:
self.regressions.append(
f"DECODE_LATENCY: dim={dim}, bits={bits}: "
f"{old_decode:.2f}μs -> {new_decode:.2f}μs "
f"({new_decode/old_decode:.2f}x increase)"
)
# Check MSE quality
new_mse = new_r.get("mse", 0)
if new_mse > self.thresholds.mse_max:
self.regressions.append(
f"MSE_QUALITY: dim={dim}, bits={bits}: "
f"MSE={new_mse:.6f} exceeds threshold {self.thresholds.mse_max}"
)
# Check recall quality
new_recall = new_r.get("recall_at_1", 0)
if new_recall < self.thresholds.recall_at_1_min:
self.regressions.append(
f"RECALL_QUALITY: dim={dim}, bits={bits}: "
f"recall@1={new_recall:.4f} below threshold {self.thresholds.recall_at_1_min}"
)
has_regressions = len(self.regressions) > 0
return has_regressions, self.regressions, self.warnings
def print_summary(old_data: dict, new_data: dict, regressions: list, warnings: list):
print("=" * 70)
print("TURBOQUANT BENCHMARK COMPARISON")
print("=" * 70)
print()
# Print configuration
old_config = old_data.get("config", {})
new_config = new_data.get("config", {})
print(f"Baseline config: {old_config}")
print(f"TurboQuant config: {new_config}")
print()
# Print table header
print(f"{'Dim':>6} {'Bits':>5} {'Storage Ratio':>14} {'Encode Ratio':>13} {'Decode Ratio':>13} {'MSE':>10} {'Recall@1':>10}")
print("-" * 70)
old_results = old_data.get("results", [])
new_results = {r["dimension"]: r for r in new_data.get("results", [])}
for old_r in old_results:
dim = old_r["dimension"]
bits = old_r["bitwidth"]
if dim not in new_results:
continue
new_r = new_results[dim]
old_storage = old_r.get("storage_bytes", 0)
new_storage = new_r.get("storage_bytes", 0)
storage_ratio = new_storage / old_storage if old_storage > 0 else 0
old_encode = old_r.get("baseline_encode_us_p50", 0)
new_encode = new_r.get("encode_latency_us_p50", 0)
encode_ratio = new_encode / old_encode if old_encode > 0 else 0
old_decode = old_r.get("baseline_decode_us_p50", 0)
new_decode = new_r.get("decode_latency_us_p50", 0)
decode_ratio = new_decode / old_decode if old_decode > 0 else 0
mse = new_r.get("mse", 0)
recall = new_r.get("recall_at_1", 0)
print(f"{dim:>6} {bits:>5} {storage_ratio:>13.2f}x {encode_ratio:>12.2f}x {decode_ratio:>12.2f}x {mse:>10.6f} {recall:>10.4f}")
print()
# Print regressions
if regressions:
print("REGRESSIONS DETECTED:")
for r in regressions:
print(f" ✗ {r}")
print()
else:
print("✓ No regressions detected")
print()
# Print warnings
if warnings:
print("WARNINGS:")
for w in warnings:
print(f" ! {w}")
print()
print("=" * 70)
def main():
parser = argparse.ArgumentParser(
description="Compare TurboQuant vs baseline benchmark results",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Compare benchmarks
%(prog)s --old bench_old.json --new bench_new.json
# Custom thresholds
%(prog)s --old bench_old.json --new bench_new.json --encode-threshold 2.0
# JSON output
%(prog)s --old bench_old.json --new bench_new.json --output comparison.json
"""
)
parser.add_argument("--old", required=True, help="Baseline benchmark JSON file")
parser.add_argument("--new", required=True, help="TurboQuant benchmark JSON file")
parser.add_argument("--output", default="benchmark_comparison.json", help="Output JSON file")
parser.add_argument("--storage-threshold", type=float, default=1.05,
help="Max storage increase ratio (default: 1.05)")
parser.add_argument("--encode-threshold", type=float, default=1.50,
help="Max encode latency increase ratio (default: 1.50)")
parser.add_argument("--decode-threshold", type=float, default=1.50,
help="Max decode latency increase ratio (default: 1.50)")
parser.add_argument("--mse-max", type=float, default=0.01,
help="Max MSE threshold (default: 0.01)")
parser.add_argument("--recall-min", type=float, default=0.85,
help="Min recall@1 threshold (default: 0.85)")
args = parser.parse_args()
# Load benchmark files
try:
with open(args.old) as f:
old_data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"Error loading --old file: {e}", file=sys.stderr)
return 1
try:
with open(args.new) as f:
new_data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"Error loading --new file: {e}", file=sys.stderr)
return 1
# Create thresholds
thresholds = RegressionThresholds(
storage_increase_max=args.storage_threshold,
encode_latency_increase_max=args.encode_threshold,
decode_latency_increase_max=args.decode_threshold,
mse_max=args.mse_max,
recall_at_1_min=args.recall_min,
)
# Run comparison
comparator = BenchmarkComparator(old_data, new_data, thresholds)
has_regressions, regressions, warnings = comparator.compare()
# Print summary
print_summary(old_data, new_data, regressions, warnings)
# Write output JSON
result = {
"has_regressions": has_regressions,
"regression_count": len(regressions),
"regressions": regressions,
"warnings": warnings,
"thresholds": {
"storage_increase_max": thresholds.storage_increase_max,
"encode_latency_increase_max": thresholds.encode_latency_increase_max,
"decode_latency_increase_max": thresholds.decode_latency_increase_max,
"mse_max": thresholds.mse_max,
"recall_at_1_min": thresholds.recall_at_1_min,
},
"files": {
"baseline": args.old,
"turboquant": args.new,
"output": args.output,
}
}
with open(args.output, "w") as f:
json.dump(result, f, indent=2)
print(f"Results written to: {args.output}")
return 1 if has_regressions else 0
if __name__ == "__main__":
sys.exit(main())