lean4-htt/tests/measure.py
Garmelon 492fda3bca
chore: speed up test suite slightly (#12969)
This PR speeds up some benchmarks when run as tests by lowering their
workload. It also stops testing some of the more expensive benchmarks
that can't be easily made smaller.
2026-03-20 12:24:32 +00:00

258 lines
6.7 KiB
Python
Executable file

#!/usr/bin/env python3
import argparse
import json
import os
import resource
import subprocess
import sys
import tempfile
from argparse import Namespace
from dataclasses import dataclass
from pathlib import Path
from typing import Tuple
@dataclass
class PerfMetric:
event: str
factor: float = 1
unit: str | None = None
@dataclass
class RusageMetric:
name: str
factor: float = 1
unit: str | None = None
@dataclass
class Result:
category: str
value: float
unit: str | None
def fmt(self, topic: str) -> str:
data = {"metric": f"{topic}//{self.category}", "value": self.value}
if self.unit is not None:
data["unit"] = self.unit
return json.dumps(data)
PERF_METRICS = {
"task-clock": PerfMetric("task-clock", factor=1e-9, unit="s"),
"wall-clock": PerfMetric("duration_time", factor=1e-9, unit="s"),
"instructions": PerfMetric("instructions"),
"cycles": PerfMetric("cycles"),
}
PERF_UNITS = {
"msec": 1e-3,
"ns": 1e-9,
}
RUSAGE_METRICS = {
"maxrss": RusageMetric("ru_maxrss", factor=1000, unit="B"), # KiB on linux
}
ALL_METRICS = {**PERF_METRICS, **RUSAGE_METRICS}
DEFAULT_METRICS = set(ALL_METRICS.keys())
def resolve_metrics(metrics: set[str]) -> Tuple[set[str], set[str]]:
perf = set()
rusage = set()
unknown = set()
for metric in metrics:
if metric in PERF_METRICS:
perf.add(metric)
elif metric in RUSAGE_METRICS:
rusage.add(metric)
else:
unknown.add(metric)
if unknown:
raise SystemExit(f"unknown metrics: {', '.join(unknown)}")
return perf, rusage
@dataclass
class PerfResult:
value: float
unit: str
type PerfResults = dict[str, PerfResult]
@dataclass
class MeasureResult:
perf: PerfResults
stdout: str
stderr: str
def measure_perf(cmd: list[str], events: set[str], capture: bool) -> MeasureResult:
with tempfile.NamedTemporaryFile() as tmp:
env = os.environ.copy()
env["LC_ALL"] = "C" # or perf may output syntactically invalid JSON
# On NixOS, perf effectively prepends /usr/bin to the PATH, but in this
# test suite, we often use the PATH to specify the binaries under test.
# Hence, we reset the PATH inside of perf using env.
cmd = [
*("perf", "stat", "-j", "-o", tmp.name),
*(arg for event in sorted(events) for arg in ["-e", event]),
"--",
*("env", f"PATH={env['PATH']}"),
*cmd,
]
# Execute command
result = subprocess.run(cmd, env=env, capture_output=capture, encoding="utf-8")
if result.returncode != 0:
if capture:
print(result.stdout, end="", file=sys.stdout)
print(result.stderr, end="", file=sys.stderr)
raise SystemExit(result.returncode)
# Collect results
perf: PerfResults = {}
for line in tmp:
data = json.loads(line)
if "event" in data and "counter-value" in data:
perf[data["event"]] = PerfResult(
value=float(data["counter-value"]),
unit=data["unit"],
)
return MeasureResult(
perf=perf,
stdout=result.stdout or "",
stderr=result.stderr or "",
)
def get_perf_result(perf: PerfResults, metric: str) -> Result:
info = PERF_METRICS[metric]
if info.event in perf:
result = perf[info.event]
else:
# Without the corresponding permissions,
# we only get access to the userspace versions of the counters.
result = perf[f"{info.event}:u"]
value = result.value * PERF_UNITS.get(result.unit, info.factor)
return Result(category=metric, value=value, unit=info.unit)
def get_rusage_result(rusage: resource.struct_rusage, metric: str) -> Result:
info = RUSAGE_METRICS[metric]
value = getattr(rusage, info.name) * info.factor
return Result(category=metric, value=value, unit=info.unit)
def main(
cmd: list[str],
output: Path,
topics: list[str],
metrics: set[str],
append: bool = True,
capture: bool = False,
) -> tuple[str, str]:
perf_metrics, rusage_metrics = resolve_metrics(metrics)
perf_events = {PERF_METRICS[metric].event for metric in perf_metrics}
measured = measure_perf(cmd, perf_events, capture=capture)
perf = measured.perf
rusage = resource.getrusage(resource.RUSAGE_CHILDREN)
results = []
for metric in perf_metrics:
results.append(get_perf_result(perf, metric))
for metric in rusage_metrics:
results.append(get_rusage_result(rusage, metric))
with open(output, "a" if append else "w") as f:
for result in results:
for topic in topics:
f.write(f"{result.fmt(topic)}\n")
return measured.stdout, measured.stderr
@dataclass
class Args(Namespace):
topic: list[str]
metric: list[str]
default_metrics: bool
output: Path
append: bool
cmd: str
args: list[str]
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Measure resource usage of a command using perf and rusage.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"--topic",
"-t",
action="append",
default=[],
help="topic prefix for the metrics",
)
parser.add_argument(
"--metric",
"-m",
action="append",
default=[],
help=f"metrics to measure. Can be specified multiple times. Available metrics: {', '.join(sorted(ALL_METRICS))}",
)
parser.add_argument(
"--default-metrics",
"-d",
action="store_true",
help=f"measure a default set of metrics: {', '.join(sorted(DEFAULT_METRICS))}",
)
parser.add_argument(
"--output",
"-o",
type=Path,
default=Path() / "measurements.jsonl",
help="output file to write measurements to, in the JSON Lines format",
)
parser.add_argument(
"--append",
"-a",
action="store_true",
help="append to the output file instead of overwriting it",
)
parser.add_argument(
"cmd",
help="command to measure the resource usage of",
)
parser.add_argument(
"args",
nargs="*",
default=[],
help="arguments to pass to the command",
)
args = parser.parse_args(namespace=Args)
metrics = set(args.metric)
if args.default_metrics:
metrics |= DEFAULT_METRICS
main(
cmd=[args.cmd] + args.args,
output=args.output,
topics=args.topic,
metrics=metrics,
append=args.append,
)