blob: d3691c38149ae08658a2b3d349a850f7620fb6d8 [file] [edit]
#!/usr/bin/env python3
import argparse
import json
import os
import resource
import subprocess
import sys
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
@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"),
"instructions": PerfMetric("instructions:u"),
"cycles": PerfMetric("cycles:u"),
}
PERF_UNITS = {
"msec": 1e-3,
"ns": 1e-9,
}
RUSAGE_METRICS = {
"maxrss": RusageMetric("ru_maxrss", factor=1024, unit="B"), # KiB on linux
}
# Measured around the command instead of via perf's `duration_time` event, which `perf stat -j`
# reports as 0 in some perf versions (e.g. 7.2.6).
TIME_METRICS = {"wall-clock"}
ALL_METRICS = {**PERF_METRICS, **RUSAGE_METRICS, **{m: None for m in TIME_METRICS}}
DEFAULT_METRICS = set(ALL_METRICS.keys())
def resolve_metrics(metrics: set[str]) -> tuple[set[str], set[str], set[str]]:
perf = set()
rusage = set()
timed = set()
unknown = set()
for metric in metrics:
if metric in PERF_METRICS:
perf.add(metric)
elif metric in RUSAGE_METRICS:
rusage.add(metric)
elif metric in TIME_METRICS:
timed.add(metric)
else:
unknown.add(metric)
if unknown:
raise SystemExit(f"unknown metrics: {', '.join(unknown)}")
return perf, rusage, timed
@dataclass
class PerfResult:
value: float
unit: str
type PerfResults = dict[str, PerfResult]
@dataclass
class MeasureResult:
perf: PerfResults
wall_clock: float
stdout: str
stderr: str
def measure_perf(
cmd: list[str], events: set[str], capture: bool, allow_failure: bool = False
) -> 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
start = time.perf_counter()
result = subprocess.run(
cmd, env=env, capture_output=capture, encoding="utf-8", check=False
)
wall_clock = time.perf_counter() - start
if result.returncode != 0 and not allow_failure:
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,
wall_clock=wall_clock,
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,
allow_failure: bool = False,
) -> tuple[str, str]:
perf_metrics, rusage_metrics, timed_metrics = resolve_metrics(metrics)
perf_events = {PERF_METRICS[metric].event for metric in perf_metrics}
measured = measure_perf(
cmd, perf_events, capture=capture, allow_failure=allow_failure
)
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))
if "wall-clock" in timed_metrics:
results.append(Result(category="wall-clock", value=measured.wall_clock, unit="s"))
with open(output, "a" if append else "w") as f:
for result in results:
f.writelines(f"{result.fmt(topic)}\n" for topic in topics)
return measured.stdout, measured.stderr
class Args:
topic: list[str]
metric: list[str]
default_metrics: bool
output: Path
append: bool
allow_failure: 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(
"--allow-failure",
"-F",
action="store_true",
help="record measurements even if the measured command exits with a non-zero status",
)
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,
allow_failure=args.allow_failure,
)