spec-b300 / source /scripts /benchmark.py
khazic's picture
Archive three-epoch run: logs and provenance part 2
932bc69 verified
Raw
History Blame Contribute Delete
24.9 kB
#!/usr/bin/env python3
"""Training benchmark harness for speculators.
Measures training loop throughput, timing breakdown, and GPU memory usage
with full provenance tracking. Results are stored as JSON for comparison
across code changes.
Uses the real Trainer class so measurements stay in sync with the actual
training code path.
Subcommands:
run Run a training benchmark
compare Compare two benchmark result files
Examples:
# Synthetic benchmark (no dataset / vLLM needed)
python scripts/benchmark.py run --synthetic \\
-- --verifier-name-or-path Qwen/Qwen3-8B --total-seq-len 4096
# Real data benchmark
python scripts/benchmark.py run \\
-- --verifier-name-or-path Qwen/Qwen3-8B --data-path ./output \\
--on-missing skip
# Multi-GPU
torchrun --standalone --nproc_per_node 2 scripts/benchmark.py run \\
--synthetic -- --verifier-name-or-path Qwen/Qwen3-8B
# Compare two runs
python scripts/benchmark.py compare baseline.json candidate.json
"""
from __future__ import annotations
import argparse
import importlib.metadata
import json
import logging
import socket
import statistics
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
import torch
import torch.distributed as dist
from hs_connectors import HiddenStatesBackend
from speculators.model import SpeculatorModel
from speculators.models.eagle3.data import shift_batch
from speculators.models.mtp.data import shift_batch_mtp
from speculators.train.cli import (
build_draft_model,
parse_vocab_mappings,
set_seed,
)
from speculators.train.config import TrainConfig
from speculators.train.dataloader import create_train_val_loaders
from speculators.train.distributed import (
get_rank,
maybe_destroy_distributed,
maybe_setup_distributed,
)
from speculators.train.logger import setup_root_logger
from speculators.train.trainer import Trainer, TrainerConfig
BENCHMARK_VERSION = "1.0"
TIMING_KEYS = (
"step_ms",
"fwd_ms",
"bwd_ms",
"opt_ms",
"fetch_ms",
"tokens_per_s",
)
# ---------------------------------------------------------------------------
# Metric capture
# ---------------------------------------------------------------------------
class _MetricCapture(logging.Handler):
"""Captures profile dicts emitted by the Trainer via metric_logger."""
def __init__(self):
super().__init__()
self.profiles: list[dict] = []
def emit(self, record):
msg = record.msg
if isinstance(msg, dict) and "profile" in msg and msg["profile"] is not None:
self.profiles.append(msg["profile"])
# ---------------------------------------------------------------------------
# Synthetic data loader
# ---------------------------------------------------------------------------
class _SyntheticLoader:
"""DataLoader-like wrapper that yields the same batch repeatedly.
Satisfies the Trainer's expectations: ``__len__``, ``__iter__``, and a
``batch_sampler`` with ``set_epoch``.
"""
class _BatchSampler:
def set_epoch(self, _epoch):
pass
def __init__(self, batch: dict[str, torch.Tensor], num_steps: int):
self._batch = batch
self._num_steps = num_steps
self.batch_sampler = self._BatchSampler()
def __len__(self):
return self._num_steps
def __iter__(self):
for _ in range(self._num_steps):
yield self._batch
# ---------------------------------------------------------------------------
# Provenance
# ---------------------------------------------------------------------------
def collect_provenance() -> dict:
"""Gather system and version metadata for reproducibility."""
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"], # noqa: S607
capture_output=True,
text=True,
timeout=5,
check=False,
)
git_sha = result.stdout.strip() if result.returncode == 0 else "unknown"
except OSError:
git_sha = "unknown"
gpu_info = []
if torch.cuda.is_available():
for i in range(torch.cuda.device_count()):
props = torch.cuda.get_device_properties(i)
gpu_info.append(
{
"name": props.name,
"total_memory_gb": round(props.total_memory / 2**30, 2),
"compute_capability": [props.major, props.minor],
}
)
def _version(pkg: str) -> str:
try:
return importlib.metadata.version(pkg)
except importlib.metadata.PackageNotFoundError:
return "unknown"
return {
"git_sha": git_sha,
"timestamp": datetime.now(timezone.utc).isoformat(),
"hostname": socket.gethostname(),
"python_version": sys.version.split()[0],
"pytorch_version": torch.__version__,
"cuda_version": torch.version.cuda or "none",
"speculators_version": _version("speculators"),
"transformers_version": _version("transformers"),
"gpu_info": gpu_info,
"num_gpus": (torch.cuda.device_count() if torch.cuda.is_available() else 0),
}
# ---------------------------------------------------------------------------
# Synthetic data
# ---------------------------------------------------------------------------
def create_synthetic_batch(
total_seq_len: int,
hidden_size: int,
num_target_layers: int,
vocab_size: int = 32000,
dtype: torch.dtype = torch.bfloat16,
device: torch.device | int = 0,
) -> dict[str, torch.Tensor]:
"""Create a random batch matching the post-collation training shape."""
hs_dim = num_target_layers * hidden_size
return {
"hidden_states": torch.randn(
1, total_seq_len, hs_dim, dtype=dtype, device=device
),
"input_ids": torch.randint(0, vocab_size, (1, total_seq_len), device=device),
"verifier_last_hidden_states": torch.randn(
1, total_seq_len, hidden_size, dtype=dtype, device=device
),
"loss_mask": torch.ones(1, total_seq_len, dtype=torch.bool, device=device),
"position_ids": torch.arange(
1, total_seq_len + 1, device=device, dtype=torch.long
).unsqueeze(0),
"document_ids": torch.zeros(1, total_seq_len, dtype=torch.long, device=device),
}
# ---------------------------------------------------------------------------
# Statistics
# ---------------------------------------------------------------------------
def compute_statistics(values: list[float]) -> dict[str, float]:
"""Compute summary statistics for a list of measurements."""
return {
"mean": statistics.mean(values),
"std": statistics.stdev(values) if len(values) > 1 else 0.0,
"min": min(values),
"max": max(values),
"median": statistics.median(values),
"count": len(values),
}
def select_measured_profiles(
all_profiles: list[dict], warmup_steps: int, measured_steps: int
) -> list[dict]:
"""Validate the sample count and discard warmup profiles."""
total_steps = warmup_steps + measured_steps
if len(all_profiles) < total_steps:
raise RuntimeError(
"Benchmark dataset exhausted before the requested number of steps: "
f"got {len(all_profiles)}, requested {total_steps} "
f"({warmup_steps} warmup + {measured_steps} measured). Use more "
"samples or request fewer benchmark steps."
)
return all_profiles[warmup_steps:total_steps]
def compute_aggregate_throughput(profiles: list[dict]) -> dict[str, float]:
"""Compute time-weighted throughput for the measured window.
``mean(tokens_per_s)`` overweights short, fast steps. Reconstructing each
profile's token count and dividing by total elapsed time gives the effective
throughput observed by rank 0 over the complete measurement window.
"""
elapsed_s = sum(profile["step_ms"] for profile in profiles) / 1000
rank0_tokens = sum(
profile["tokens_per_s"] * profile["step_ms"] / 1000 for profile in profiles
)
return {
"measured_time_s": elapsed_s,
"rank0_tokens": rank0_tokens,
"effective_rank0_tokens_per_s": rank0_tokens / elapsed_s,
}
def shutdown_dataloader_workers(loader) -> None:
"""Stop persistent workers before Mooncake/distributed teardown."""
iterator = getattr(loader, "_iterator", None)
shutdown = getattr(iterator, "_shutdown_workers", None)
if callable(shutdown):
shutdown()
loader._iterator = None
# ---------------------------------------------------------------------------
# Benchmark runner
# ---------------------------------------------------------------------------
def _build_train_loader(
bench_args,
train_args,
hidden_size,
num_target_layers,
vocab_size,
hidden_states_dtype,
total_steps,
):
"""Build either a synthetic or real data loader for benchmarking."""
if bench_args.synthetic:
synth_batch = create_synthetic_batch(
total_seq_len=train_args.total_seq_len,
hidden_size=hidden_size,
num_target_layers=num_target_layers,
vocab_size=vocab_size,
dtype=hidden_states_dtype,
device="cpu",
)
return _SyntheticLoader(synth_batch, total_steps), True
preprocess_fns = {
"eagle3": shift_batch,
"peagle": shift_batch,
"mtp": shift_batch_mtp,
}
preprocess = preprocess_fns.get(train_args.speculator_type)
backend_registry = HiddenStatesBackend.registry
backend_cls = backend_registry[train_args.hidden_states_backend]
transfer = backend_cls.from_train_args(train_args, train_args.data_path)
train_loader, _ = create_train_val_loaders(
data_path=train_args.data_path,
total_seq_len=train_args.total_seq_len,
hidden_states_dtype=hidden_states_dtype,
noise_std=train_args.noise_std,
transfer=transfer,
vllm_endpoint=train_args.vllm_endpoint,
on_missing=train_args.on_missing,
on_generate=train_args.on_generate,
verifier_name_or_path=train_args.verifier_name_or_path,
request_timeout=train_args.request_timeout,
max_retries=train_args.max_retries,
generation_validation_retries=train_args.generation_validation_retries,
# A benchmark must not publish timings from degraded batches. Trip the
# landed recovery circuit breaker on the first sample that exhausts its
# complete generate/load/validate retry budget.
max_consecutive_generation_failures=1,
hidden_size=hidden_size,
num_target_layers=num_target_layers,
num_workers=train_args.num_workers,
prefetch_factor=train_args.prefetch_factor,
preprocess=preprocess,
train_data_ratio=train_args.train_data_ratio,
max_train_batches=total_steps,
)
return train_loader, False
def run_benchmark(bench_args, train_args) -> dict:
"""Execute the benchmark using the real Trainer and return results."""
set_seed(
train_args.seed,
getattr(train_args, "deterministic_cuda", False),
)
setup_root_logger()
maybe_setup_distributed()
rank = get_rank()
total_steps = bench_args.warmup_steps + bench_args.measured_steps
hidden_states_dtype = getattr(torch, train_args.hidden_states_dtype)
# --- Build model ---
if train_args.speculator_type == "mtp":
d2t, t2d, draft_vocab_size = None, None, None
train_args.mask_token_id = None
else:
d2t, t2d, draft_vocab_size = parse_vocab_mappings(train_args)
model_class = SpeculatorModel.registry[train_args.speculator_type]
draft_model = build_draft_model(train_args, model_class, t2d, d2t, draft_vocab_size)
num_target_layers = len(draft_model.target_layer_ids)
hidden_size = draft_model.config.transformer_layer_config.hidden_size
vocab_size = draft_model.config.transformer_layer_config.vocab_size
# --- Build data loader ---
train_loader, is_synthetic = _build_train_loader(
bench_args,
train_args,
hidden_size,
num_target_layers,
vocab_size,
hidden_states_dtype,
total_steps,
)
# --- Get forward kwargs ---
train_call_kwargs, _ = model_class.get_trainer_kwargs(**vars(train_args))
# --- Build TrainerConfig ---
trainer_config = TrainerConfig(
lr=train_args.lr,
num_epochs=1,
save_path="benchmark_unused",
resume_from_checkpoint=False,
train_call_kwargs=train_call_kwargs,
optimizer=train_args.optimizer,
weight_decay=train_args.weight_decay,
muon_lr=train_args.muon_lr,
muon_momentum=train_args.muon_momentum,
muon_weight_decay=train_args.muon_weight_decay,
muon_ns_steps=train_args.muon_ns_steps,
muon_adjust_lr_fn=train_args.muon_adjust_lr_fn,
scheduler_type="none",
hidden_states_dtype=hidden_states_dtype,
log_freq=1,
fsdp_shard=train_args.fsdp_shard,
max_steps=total_steps,
)
# --- Construct Trainer (handles GPU placement, DDP, optimizer) ---
trainer = Trainer(draft_model, trainer_config, train_loader)
if rank == 0:
print(
f"Benchmarking: {bench_args.warmup_steps} warmup + "
f"{bench_args.measured_steps} measured steps"
)
# --- Attach metric capture ---
metric_logger = logging.getLogger("speculators.metrics")
capture = _MetricCapture()
metric_logger.addHandler(capture)
# --- Reset memory tracking before the run ---
local_rank = trainer.local_rank
torch.cuda.reset_peak_memory_stats(local_rank)
# --- Run the real training loop ---
trainer.train_epoch(0)
# --- Remove capture handler ---
metric_logger.removeHandler(capture)
# --- Collect memory ---
peak_allocated_mb = torch.cuda.max_memory_allocated(local_rank) / (1024**2)
peak_reserved_mb = torch.cuda.max_memory_reserved(local_rank) / (1024**2)
# --- Split warmup / measured profiles ---
all_profiles = capture.profiles
measured_profiles = select_measured_profiles(
all_profiles,
bench_args.warmup_steps,
bench_args.measured_steps,
)
# --- Aggregate ---
timing_agg = {}
for key in TIMING_KEYS:
values = [s[key] for s in measured_profiles]
timing_agg[key] = compute_statistics(values)
aggregate = compute_aggregate_throughput(measured_profiles)
num_gpus_used = dist.get_world_size() if dist.is_initialized() else 1
results = {
"benchmark_version": BENCHMARK_VERSION,
"provenance": collect_provenance(),
"config": {
"speculator_type": train_args.speculator_type,
"verifier_name_or_path": train_args.verifier_name_or_path,
"total_seq_len": train_args.total_seq_len,
"hidden_size": hidden_size,
"num_target_layers": num_target_layers,
"optimizer": train_args.optimizer,
"lr": train_args.lr,
"hidden_states_dtype": train_args.hidden_states_dtype,
"synthetic_data": is_synthetic,
"data_path": train_args.data_path,
"hidden_states_backend": train_args.hidden_states_backend,
"num_workers": train_args.num_workers,
"prefetch_factor": train_args.prefetch_factor,
"fsdp_shard": train_args.fsdp_shard,
"num_gpus_used": num_gpus_used,
"warmup_steps": bench_args.warmup_steps,
"measured_steps": bench_args.measured_steps,
"seed": train_args.seed,
},
"memory": {
"peak_allocated_mb": round(peak_allocated_mb, 2),
"peak_reserved_mb": round(peak_reserved_mb, 2),
},
"timing": timing_agg,
"aggregate": aggregate,
}
if not bench_args.no_per_step:
results["per_step"] = [
{"step": i, **p} for i, p in enumerate(measured_profiles)
]
# --- Write results (rank 0 only) ---
if rank == 0:
output_path = Path(bench_args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w") as f:
json.dump(results, f, indent=2)
print(f"\nResults written to {output_path}")
_print_summary(results)
# --- Cleanup ---
shutdown_dataloader_workers(train_loader)
del trainer, train_loader, draft_model
if torch.cuda.is_available():
torch.cuda.empty_cache()
maybe_destroy_distributed()
return results
def _print_summary(results: dict) -> None:
"""Print a compact summary of benchmark results to stdout."""
timing = results["timing"]
memory = results["memory"]
print(f"\n{'Metric':<16} {'Mean':>10} {'Std':>10} {'Min':>10} {'Max':>10}")
print("-" * 58)
for key in TIMING_KEYS:
stats = timing[key]
print(
f"{key:<16} {stats['mean']:>10.2f} "
f"{stats['std']:>10.2f} "
f"{stats['min']:>10.2f} {stats['max']:>10.2f}"
)
aggregate = results["aggregate"]
print(
"\nEffective rank-0 throughput: "
f"{aggregate['effective_rank0_tokens_per_s']:.2f} tokens/s "
f"over {aggregate['measured_time_s']:.2f} s"
)
print(
f"\nPeak memory: {memory['peak_allocated_mb']:.1f} MB "
f"allocated, {memory['peak_reserved_mb']:.1f} MB reserved"
)
# ---------------------------------------------------------------------------
# Compare
# ---------------------------------------------------------------------------
def _nested_get(d: dict, key_path: str):
"""Traverse a dotted key path into a nested dict."""
for part in key_path.split("."):
if not isinstance(d, dict):
return None
d = d.get(part) # type: ignore[assignment]
return d
def _get_gpu_name(result: dict) -> str:
"""Extract the first GPU name from a result dict."""
info = result.get("provenance", {}).get("gpu_info")
if info:
return info[0].get("name", "unknown")
return "unknown"
def _get_effective_throughput(result: dict) -> float | None:
aggregate = result.get("aggregate", {})
value = aggregate.get("effective_rank0_tokens_per_s")
if value is not None:
return float(value)
profiles = result.get("per_step")
if profiles:
return compute_aggregate_throughput(profiles)["effective_rank0_tokens_per_s"]
return None
def compare_benchmarks(baseline_path: str, candidate_path: str) -> None:
"""Load two result files and print a comparison table."""
with open(baseline_path) as f:
baseline = json.load(f)
with open(candidate_path) as f:
candidate = json.load(f)
# --- Comparability warnings ---
comparability_checks = [
("config.speculator_type", "Speculator type"),
("config.hidden_size", "Hidden size"),
("config.total_seq_len", "Sequence length"),
("config.num_gpus_used", "GPU count"),
("config.fsdp_shard", "FSDP shard"),
("config.optimizer", "Optimizer"),
("config.hidden_states_dtype", "Dtype"),
]
warnings_found = False
for key_path, label in comparability_checks:
val_a = _nested_get(baseline, key_path)
val_b = _nested_get(candidate, key_path)
if val_a != val_b:
if not warnings_found:
print("COMPARABILITY WARNINGS:")
warnings_found = True
print(f" {label}: {val_a} vs {val_b}")
gpu_a = _get_gpu_name(baseline)
gpu_b = _get_gpu_name(candidate)
if gpu_a != gpu_b:
if not warnings_found:
print("COMPARABILITY WARNINGS:")
print(f" GPU: {gpu_a} vs {gpu_b}")
# --- Header ---
sha_a = baseline.get("provenance", {}).get("git_sha", "unknown")[:12]
sha_b = candidate.get("provenance", {}).get("git_sha", "unknown")[:12]
print(f"\nBaseline: {baseline_path}")
print(f" Git SHA: {sha_a}")
print(f"Candidate: {candidate_path}")
print(f" Git SHA: {sha_b}")
# --- Timing comparison ---
col_w = 26
print(
f"\n{'Metric':<16} "
f"{'Baseline (mean +/- std)':<{col_w}} "
f"{'Candidate (mean +/- std)':<{col_w}} "
f"{'Delta':>10} {'Delta %':>10}"
)
print("-" * (16 + col_w * 2 + 22))
for key in TIMING_KEYS:
ba = baseline.get("timing", {}).get(key, {})
ca = candidate.get("timing", {}).get(key, {})
ba_mean = ba.get("mean", 0)
ba_std = ba.get("std", 0)
ca_mean = ca.get("mean", 0)
ca_std = ca.get("std", 0)
delta = ca_mean - ba_mean
pct = (delta / ba_mean * 100) if ba_mean != 0 else 0
ba_str = f"{ba_mean:>8.2f} +/- {ba_std:<6.2f}"
ca_str = f"{ca_mean:>8.2f} +/- {ca_std:<6.2f}"
print(
f"{key:<16} {ba_str:<{col_w}} {ca_str:<{col_w}} "
f"{delta:>+10.2f} {pct:>+9.1f}%"
)
effective_a = _get_effective_throughput(baseline)
effective_b = _get_effective_throughput(candidate)
if effective_a is not None and effective_b is not None:
delta = effective_b - effective_a
pct = (delta / effective_a * 100) if effective_a != 0 else 0
print(
"\nEffective rank-0 throughput: "
f"{effective_a:.2f} -> {effective_b:.2f} tokens/s "
f"({delta:+.2f}, {pct:+.1f}%)"
)
# --- Memory comparison ---
print(f"\n{'Memory':<24} {'Baseline':>12} {'Candidate':>12} {'Delta':>12}")
print("-" * 62)
for key in ("peak_allocated_mb", "peak_reserved_mb"):
ba_val = baseline.get("memory", {}).get(key, 0)
ca_val = candidate.get("memory", {}).get(key, 0)
delta = ca_val - ba_val
print(f"{key:<24} {ba_val:>9.1f} MB {ca_val:>9.1f} MB {delta:>+9.1f} MB")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def build_parser():
"""Build the top-level argument parser."""
parser = argparse.ArgumentParser(
description="Training benchmark harness for speculators.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Pass train.py flags after '--'. Example:\n"
" python scripts/benchmark.py run --synthetic "
"-- --verifier-name-or-path Qwen/Qwen3-8B"
),
)
subparsers = parser.add_subparsers(dest="command", required=True)
# --- run ---
run_parser = subparsers.add_parser("run", help="Run a training benchmark")
run_parser.add_argument(
"--synthetic",
action="store_true",
help="Use synthetic random data instead of a real dataset.",
)
run_parser.add_argument(
"--warmup-steps",
type=int,
default=10,
help="Warmup steps (not measured). Default: 10.",
)
run_parser.add_argument(
"--measured-steps",
type=int,
default=50,
help="Number of measured steps. Default: 50.",
)
run_parser.add_argument(
"--output",
type=str,
default=None,
help="Output JSON path. Default: benchmark_<ts>.json.",
)
run_parser.add_argument(
"--no-per-step",
action="store_true",
help="Omit per-step timing data from the output JSON.",
)
# --- compare ---
cmp_parser = subparsers.add_parser(
"compare", help="Compare two benchmark result files"
)
cmp_parser.add_argument("baseline", help="Path to baseline result JSON.")
cmp_parser.add_argument("candidate", help="Path to candidate result JSON.")
return parser
def main():
parser = build_parser()
# Split argv on '--' to separate benchmark / train.py args.
argv = sys.argv[1:]
if "--" in argv:
sep_idx = argv.index("--")
bench_argv = argv[:sep_idx]
train_argv = argv[sep_idx + 1 :]
else:
bench_argv = argv
train_argv = []
bench_args = parser.parse_args(bench_argv)
if bench_args.command == "compare":
compare_benchmarks(bench_args.baseline, bench_args.candidate)
return
# --- run command ---
if bench_args.output is None:
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
bench_args.output = f"benchmark_{ts}.json"
# Resolve train.py args through the shared TrainConfig, flattened to the
# same argparse.Namespace the model layer consumes in train.main().
train_args = argparse.Namespace(**TrainConfig.resolve(train_argv).flatten())
run_benchmark(bench_args, train_args)
if __name__ == "__main__":
main()