Source code for torch_lattice_bench.harness

from __future__ import annotations

import statistics
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, fields, is_dataclass
from typing import Any, Literal

import torch

from torch_lattice import SparseTensor

type Mode = Literal["cold_op", "hot_op", "backward"]
type Params = Mapping[str, Any]
type WorkloadMetrics = dict[str, int | float]
type MetricFactory = Callable[[Params, Any, Any | None, Any | None], WorkloadMetrics]


[docs] class SkipCase(RuntimeError): pass
[docs] @dataclass(frozen=True, slots=True) class BenchmarkCase: name: str group: str params: tuple[Params, ...] setup: Callable[[Params], Any] prepare: Callable[[Any], Any] run: Callable[[Any], Any] backward: Callable[[Any], Any] | None = None metrics: MetricFactory | None = None units: tuple[str, ...] = () modes: tuple[Mode, ...] | None = None
[docs] def supports(self, mode: Mode) -> bool: if self.modes is not None and mode not in self.modes: return False return mode != "backward" or self.backward is not None
[docs] @dataclass(frozen=True, slots=True) class BenchmarkResult: case: str group: str mode: Mode device: str params: dict[str, Any] warmup: int repeats: int median_ms: float | None min_ms: float | None p90_ms: float | None p95_ms: float | None samples_ms: tuple[float, ...] workload: WorkloadMetrics units: dict[str, float] skipped: bool = False notes: str = ""
[docs] def to_json(self) -> dict[str, Any]: return { "case": self.case, "group": self.group, "mode": self.mode, "device": self.device, "params": _jsonable(self.params), "warmup": self.warmup, "repeats": self.repeats, "median_ms": self.median_ms, "min_ms": self.min_ms, "p90_ms": self.p90_ms, "p95_ms": self.p95_ms, "samples_ms": list(self.samples_ms), "workload": self.workload, "units": self.units, "skipped": self.skipped, "notes": self.notes, }
type ProgressStart = Callable[[BenchmarkCase, Params, Mode, str], None] type ProgressResult = Callable[ [BenchmarkResult, BenchmarkCase, Params, Mode, str], None ] type ProgressSkip = Callable[[BenchmarkCase, Params, Mode, str], None] type ProgressError = Callable[[BenchmarkCase, Params, Mode, str, BaseException], None] class CudaTimer: def __init__(self) -> None: self.start = torch.cuda.Event(enable_timing=True) self.end = torch.cuda.Event(enable_timing=True) def __call__( self, fn: Callable[[], Any], warmup: int, repeats: int ) -> tuple[tuple[float, ...], Any | None]: last_output = None for _ in range(warmup): last_output = fn() force(last_output) torch.cuda.synchronize() samples: list[float] = [] for _ in range(repeats): self.start.record() last_output = fn() force(last_output) self.end.record() torch.cuda.synchronize() samples.append(float(self.start.elapsed_time(self.end))) return tuple(samples), last_output
[docs] def run_case( case: BenchmarkCase, params: Params, *, mode: Mode, device: str, warmup: int, repeats: int, ) -> BenchmarkResult | None: if not case.supports(mode): return None fixture = case.setup(params) force(fixture) prepared = case.prepare(fixture) force(prepared) action = _action(case, fixture, prepared, mode) timer = CudaTimer() torch.cuda.reset_peak_memory_stats() samples, output = timer(action, warmup, repeats) memory_mb = torch.cuda.max_memory_allocated() / (1024 * 1024) workload = _derive_workload_metrics( params, fixture=fixture, prepared=prepared, output=output, extra=case.metrics ) workload["memory_mb"] = memory_mb return BenchmarkResult( case=case.name, group=case.group, mode=mode, device=device, params=dict(params), warmup=warmup, repeats=repeats, median_ms=statistics.median(samples), min_ms=min(samples), p90_ms=_percentile(samples, 90), p95_ms=_percentile(samples, 95), samples_ms=samples, workload=workload, units=_derive_units(samples, params, workload, case.units), )
[docs] def run_cases( cases: Iterable[BenchmarkCase], *, modes: Sequence[Mode], device: str, warmup: int, repeats: int, include: str | None = None, keep_going: bool = True, on_start: ProgressStart | None = None, on_result: ProgressResult | None = None, on_skip: ProgressSkip | None = None, on_error: ProgressError | None = None, ) -> list[BenchmarkResult]: results: list[BenchmarkResult] = [] for case in cases: if include is not None and include not in case.name: continue for params in case.params: for mode in modes: if not case.supports(mode): if on_skip is not None: on_skip(case, params, mode, device) continue if on_start is not None: on_start(case, params, mode, device) try: result = run_case( case, params, mode=mode, device=device, warmup=warmup, repeats=repeats, ) except Exception as error: if on_error is not None: on_error(case, params, mode, device, error) if not keep_going: raise result = _skip_result( case, params, mode, device, warmup, repeats, error ) if result is not None: if on_result is not None: on_result(result, case, params, mode, device) results.append(result) return results
def force(value: Any) -> None: tensors = tuple(_collect_tensors(value)) if tensors: torch.cuda.synchronize() def _action( case: BenchmarkCase, fixture: Any, prepared: Any, mode: Mode ) -> Callable[[], Any]: if mode == "cold_op": return lambda: case.run(case.prepare(fixture)) if mode == "hot_op": return lambda: case.run(prepared) if case.backward is None: raise ValueError(f"{case.name} does not support backward.") return lambda: case.backward(case.prepare(fixture)) def _skip_result( case: BenchmarkCase, params: Params, mode: Mode, device: str, warmup: int, repeats: int, error: BaseException, ) -> BenchmarkResult: return BenchmarkResult( case=case.name, group=case.group, mode=mode, device=device, params=dict(params), warmup=warmup, repeats=repeats, median_ms=None, min_ms=None, p90_ms=None, p95_ms=None, samples_ms=(), workload=_derive_workload_metrics( params, fixture=None, prepared=None, output=None ), units={}, skipped=True, notes=f"{type(error).__name__}: {error}", ) def _collect_tensors(value: Any) -> Iterable[torch.Tensor]: if isinstance(value, torch.Tensor): yield value return if isinstance(value, SparseTensor): yield value.feats yield value.coords return if isinstance(value, Mapping): for item in value.values(): yield from _collect_tensors(item) return if isinstance(value, tuple | list): for item in value: yield from _collect_tensors(item) return if is_dataclass(value) and not isinstance(value, type): for field in fields(value): yield from _collect_tensors(getattr(value, field.name)) def _derive_workload_metrics( params: Params, *, fixture: Any, prepared: Any | None, output: Any | None, extra: MetricFactory | None = None, ) -> WorkloadMetrics: metrics: WorkloadMetrics = {} for source in (params,): for key, target in ( ("N", "N"), ("points", "points"), ("channels", "channels_in"), ("kernel", "kernel_volume"), ): value = source.get(key) if isinstance(value, int | float): metrics.setdefault(target, value) for value in (prepared, fixture): if isinstance(value, SparseTensor): metrics.setdefault("n_in", int(value.feats.shape[0])) metrics.setdefault("channels_in", int(value.feats.shape[1])) break if isinstance(output, SparseTensor): metrics["n_out"] = int(output.feats.shape[0]) metrics["channels_out"] = int(output.feats.shape[1]) metrics["elements"] = int(output.feats.numel()) elif isinstance(output, torch.Tensor): metrics.setdefault("n_out", int(output.shape[0]) if output.ndim else 1) metrics["elements"] = int(output.numel()) elif isinstance(output, dict): coords = output.get("coords") if isinstance(coords, torch.Tensor): metrics["n_out"] = int(coords.shape[0]) if extra is not None: metrics.update(extra(params, fixture, prepared, output)) edges = metrics.get("edges") n_out = metrics.get("n_out") if isinstance(edges, int | float) and isinstance(n_out, int | float) and n_out > 0: metrics["avg_neighbors"] = edges / n_out return metrics def _derive_units( samples: Sequence[float], params: Params, workload: WorkloadMetrics, units: Sequence[str], ) -> dict[str, float]: median_seconds = statistics.median(samples) / 1000.0 if median_seconds <= 0.0: return {} out = {} for unit in units or _default_units(workload): raw = workload.get(unit, params.get(unit)) if isinstance(raw, int | float): out[f"{unit}_per_s"] = float(raw) / median_seconds return out def _default_units(workload: WorkloadMetrics) -> tuple[str, ...]: return tuple( unit for unit in ("edges", "elements", "points", "n_out", "n_in", "N") if unit in workload ) def _percentile(samples: Sequence[float], pct: int) -> float: ordered = sorted(samples) if len(ordered) == 1: return ordered[0] rank = (pct / 100) * (len(ordered) - 1) lower = int(rank) upper = min(lower + 1, len(ordered) - 1) weight = rank - lower return ordered[lower] * (1.0 - weight) + ordered[upper] * weight def _jsonable(value: Any) -> Any: if isinstance(value, Mapping): return {str(key): _jsonable(item) for key, item in value.items()} if isinstance(value, tuple | list): return [_jsonable(item) for item in value] if isinstance(value, str | int | float | bool) or value is None: return value return str(value) __all__ = [ "BenchmarkCase", "BenchmarkResult", "Mode", "SkipCase", "run_case", "run_cases", ]