mirror of
https://github.com/SakanaAI/doc-to-lora.git
synced 2026-07-23 17:01:04 +02:00
305 lines
11 KiB
Python
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)
|