doc-to-lora/tmp/test_distillation_sparse_vs_dense.py
2025-09-29 00:40:51 +09:00

305 lines
11 KiB
Python

#!/usr/bin/env python
"""
Compare the current dense KL (teacher top-k scatter into full vocab) vs a sparse gather-based
implementation used in distillation.
Metrics:
- Exact numerical equality (max |diff|) across many random seeds.
- Runtime (per iteration, averaged).
- Peak memory (GPU if available, else best-effort RSS delta on CPU).
By default we mimic the current code semantics (NO renormalization of the provided teacher
log-probs). That is, we treat absent vocab items as probability 0 and do:
loss_i = - sum_j exp(target_logp[i,j]) * log q(indices[i,j])
which matches the existing dense scatter approach that sets -inf everywhere else.
We also optionally show (flag --normalized) a variant where teacher log-probs are re-normalized
over the provided top-k indices (useful if you want a true cross-entropy on the partial dist).
Usage (from repo root):
python tmp/test_distillation_sparse_vs_dense.py --seeds 100 --n 2048 --vocab 32000 --k 8 --device cuda
Adjust sizes to fit your GPU memory. The dense method allocates an (N, V) float tensor twice
(outputs_logits + teacher_logp). For large (N,V) this can OOM.
"""
from __future__ import annotations
import argparse
import math
import os
import statistics
import time
import tracemalloc
from dataclasses import dataclass
import torch
try:
import psutil # optional for CPU RSS
except ImportError: # pragma: no cover
psutil = None
def dense_loss(
outputs_logits: torch.Tensor, indices: torch.Tensor, target_logp: torch.Tensor
) -> torch.Tensor:
"""Replicates current implementation: build full teacher_logp filled with -inf, scatter top-k logp.
outputs_logits: (N, V)
indices: (N, K) long
target_logp: (N, K) float (NOT assumed normalized)
Returns: (N,) per-position losses.
"""
N, V = outputs_logits.shape
# Create full teacher log-probs
teacher_logp = torch.full_like(outputs_logits, -torch.inf)
teacher_logp.scatter_(1, indices, target_logp)
p = teacher_logp.exp() # zeros for -inf
# logq = torch.log_softmax(outputs_logits, dim=-1)
logq_full_denom = torch.logsumexp(outputs_logits, dim=-1, keepdim=True) # (N,1)
logq = outputs_logits - logq_full_denom
loss = -(p * logq).sum(dim=-1) # (N,)
return loss
def sparse_loss(
outputs_logits: torch.Tensor,
indices: torch.Tensor,
target_logp: torch.Tensor,
*,
normalize=False,
) -> torch.Tensor:
"""Sparse equivalent to dense_loss without constructing a full (N,V) teacher matrix.
If normalize=True we renormalize teacher log-probs over provided indices.
Returns (N,) per-position losses with SAME semantics as dense_loss when normalize=False.
"""
# Gather student log-probs only at teacher indices.
logq_full_denom = torch.logsumexp(outputs_logits, dim=-1, keepdim=True) # (N,1)
selected_logits = outputs_logits.gather(1, indices) # (N,K)
logq_selected = selected_logits - logq_full_denom # log softmax at selected indices
if normalize:
# True cross-entropy over provided subset.
teacher_logp_norm = target_logp - torch.logsumexp(
target_logp, dim=-1, keepdim=True
)
teacher_p = teacher_logp_norm.exp()
else:
teacher_p = target_logp.exp()
# NOT EFFICIENT?
# logq = torch.nn.functional.log_softmax(outputs_logits, dim=-1)
# logq_selected = logq.gather(1, indices) # (N,K)
# teacher_p = target_logp.exp()
loss = -(teacher_p * logq_selected).sum(dim=-1)
return loss
@dataclass
class Metrics:
time_ms: float
peak_mem_mb: float | None
max_abs_diff: float | None = None
def measure(
method_fn, *args, repeat=1, device="cpu", measure_memory=True, **kwargs
) -> tuple[torch.Tensor, Metrics]:
"""Run method_fn (returns tensor), measure avg wall time and peak memory.
For GPU memory we use torch.cuda.reset_peak_memory_stats / max_memory_allocated.
For CPU fallback we sample RSS before/after (approx)."""
is_cuda = device.startswith("cuda") and torch.cuda.is_available()
if is_cuda:
torch.cuda.synchronize()
torch.cuda.reset_peak_memory_stats()
else:
if measure_memory and psutil is not None:
process = psutil.Process(os.getpid())
rss_before = process.memory_info().rss
elif measure_memory:
tracemalloc.start()
start = time.perf_counter()
out = None
for _ in range(repeat):
out = method_fn(*args, **kwargs)
if is_cuda:
torch.cuda.synchronize()
elapsed = (time.perf_counter() - start) / repeat * 1000.0
peak_mb = None
if is_cuda:
peak_mb = torch.cuda.max_memory_allocated(device=device) / (1024**2)
else:
if measure_memory and psutil is not None:
rss_after = process.memory_info().rss
peak_mb = (rss_after - rss_before) / (1024**2)
elif measure_memory:
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
peak_mb = peak / (1024**2)
return out, Metrics(time_ms=elapsed, peak_mem_mb=peak_mb)
def run_experiment(args):
device = args.device
torch.manual_seed(0)
# Pre-generate shapes once per seed to reduce variance from dynamic graph allocations.
dense_times = []
dense_mems = []
sparse_times = []
sparse_mems = []
diffs = []
allclose_fail_seeds = []
N = args.n
V = args.vocab
K = args.k
if K > V:
raise ValueError("k cannot exceed vocab size")
dtype = torch.float16 if args.fp16 else torch.float32
for seed in range(args.seeds):
torch.manual_seed(seed)
# Student logits (simulate model outputs for label positions)
outputs_logits = 1 + 10 * torch.randn(N, V, device=device, dtype=dtype)
# Ensure uniqueness per row: sample without replacement via randperm
indices = torch.stack([torch.randperm(V, device=device)[:K] for _ in range(N)])
# Teacher log-probs (could be any real numbers). Simulate by sampling logits then subtract logsumexp to get log-probs,
# then optionally add random temperature scaling so they are not normalized (matching current dense semantics).
teacher_logits = 1 + 10 * torch.randn(
N, K, device=device, dtype=torch.float32
) # keep higher precision for stability
target_logp = (
teacher_logits - torch.logsumexp(teacher_logits, dim=-1, keepdim=True)
).to(dtype)
# Measure dense
dense_out, dense_metrics = measure(
dense_loss, outputs_logits, indices, target_logp, device=device
)
# Measure sparse (semantics-matching version)
sparse_out, sparse_metrics = measure(
sparse_loss,
outputs_logits,
indices,
target_logp,
device=device,
normalize=args.normalize_sparse,
)
# Compare
# Move to float32 for diff to reduce underflow when fp16
diff = (dense_out.float() - sparse_out.float()).abs().max().item()
diffs.append(diff)
dense_times.append(dense_metrics.time_ms)
dense_mems.append(dense_metrics.peak_mem_mb or math.nan)
sparse_times.append(sparse_metrics.time_ms)
sparse_mems.append(sparse_metrics.peak_mem_mb or math.nan)
# Per-seed torch.allclose check when semantics should match (no normalization flags)
if not args.normalize_sparse:
if not torch.allclose(
dense_out, sparse_out, rtol=args.rtol, atol=args.atol
):
allclose_fail_seeds.append(seed)
print("=== Distillation Dense vs Sparse Comparison ===")
print(f"Device: {device} dtype: {dtype} seeds: {args.seeds}")
print(f"Shape: N={N} positions, V={V} vocab, K={K} top-k per position")
print(f"Sparse renormalize flag (normalize_sparse): {args.normalize_sparse}")
print("-- Timing (ms per iteration) --")
print(
f"Dense mean: {statistics.mean(dense_times):.3f} median: {statistics.median(dense_times):.3f}"
)
print(
f"Sparse mean: {statistics.mean(sparse_times):.3f} median: {statistics.median(sparse_times):.3f}"
)
speedup = statistics.mean(dense_times) / statistics.mean(sparse_times)
print(f"Speedup (dense / sparse): {speedup:.2f}x")
if all(not math.isnan(m) for m in dense_mems + sparse_mems):
print("-- Peak Memory (MB) --")
print(
f"Dense mean: {statistics.mean(dense_mems):.1f} median: {statistics.median(dense_mems):.1f}"
)
print(
f"Sparse mean: {statistics.mean(sparse_mems):.1f} median: {statistics.median(sparse_mems):.1f}"
)
mem_reduction = statistics.mean(dense_mems) / max(
1e-9, statistics.mean(sparse_mems)
)
print(f"Memory reduction (dense / sparse): {mem_reduction:.2f}x")
else:
print("(Memory stats not fully available on this platform / configuration)")
print("-- Numerical Differences --")
max_diff = max(diffs)
mean_diff = statistics.mean(diffs)
print(f"Max |dense - sparse| over seeds: {max_diff:.3e}")
print(f"Mean |dense - sparse|: {mean_diff:.3e}")
tol = 1e-5
if args.normalize_sparse:
print(
"NOTE: Normalization flags enabled; numerical results may intentionally differ from dense baseline."
)
else:
# Summary of allclose results
if not allclose_fail_seeds:
print(
f"SUCCESS: Sparse matches dense within tolerance (max diff {max_diff:.3e} < {tol}) and allclose passed for all seeds (rtol={args.rtol}, atol={args.atol})."
)
else:
if allclose_fail_seeds:
print(
f"WARNING: torch.allclose failed for {len(allclose_fail_seeds)} / {args.seeds} seeds. Failed seeds: {allclose_fail_seeds[:10]}{'...' if len(allclose_fail_seeds) > 10 else ''}"
)
# if max_diff >= tol:
# print(
# f"WARNING: Max diff {max_diff:.3e} exceeds heuristic dtype tolerance {tol}."
# )
def parse_args():
p = argparse.ArgumentParser()
p.add_argument(
"--seeds", type=int, default=100, help="Number of random seeds / trials"
)
p.add_argument("--n", type=int, default=1024, help="Number of label positions (N)")
p.add_argument("--vocab", type=int, default=32000, help="Vocabulary size (V)")
p.add_argument("--k", type=int, default=8, help="Top-k teacher tokens (K)")
p.add_argument(
"--device", type=str, default="cuda" if torch.cuda.is_available() else "cpu"
)
p.add_argument(
"--fp16", action="store_true", help="Use float16 for student logits/log-probs"
)
p.add_argument(
"--normalize-sparse",
dest="normalize_sparse",
action="store_true",
help="Renormalize teacher log-probs inside sparse path",
)
return p.parse_args()
if __name__ == "__main__":
torch.backends.cuda.matmul.allow_fp16_reduced_precision_reduction = False
torch.backends.cuda.matmul.allow_bf16_reduced_precision_reduction = False
torch.backends.cudnn.benchmark = False
torch.backends.cuda.matmul.allow_tf32 = False
torch.backends.cudnn.allow_tf32 = False
args = parse_args()
# Set default tolerances if not provided
args.rtol = 1.3e-6
args.atol = 1e-5
run_experiment(args)