[PATCH v1 16/49] perf python: Port stat-cpi to perf module
From: Ian Rogers
Date: Sun Sep 20 2026 - 01:24:09 EST
Port stat-cpi.py from the legacy embedded scripting framework to a
standalone Python script in tools/perf/python/ to calculate Cycles Per
Instruction (CPI) per interval per CPU or thread.
Improvements compared to the legacy script:
- Support both perf.data file mode (via perf.session stat callbacks)
and live counter collection mode (using perf.parse_events,
evlist.open, and evsel.read across intervals), with automatic fallback
to user-space (:u) and self-process monitoring when perf_event_paranoid
restricts system-wide events (EACCES).
- Compute per-interval counter deltas (val, ena, run) keyed by raw event
name so cumulative PERF_RECORD_STAT snapshots and hybrid PMU events
(e.g. cpu_core/cycles/, cpu_atom/cycles/) are accumulated accurately,
and scale counts by time_enabled / time_running when multiplexed.
- Replace hard-coded CPU ([0, 1]) and thread ([0]) arrays with dynamic
CPU and thread discovery so arbitrary system topologies work
automatically.
- Add CLI option handling (-i, -I, -p) via argparse and type annotations
passing mypy and pylint.
Add a shell test (test_stat_cpi_python.sh) to verify the standalone
script.
Assisted-by: Antigravity:gemini-3.1-pro
Signed-off-by: Ian Rogers <irogers@xxxxxxxxxx>
---
tools/perf/python/stat-cpi.py | 208 ++++++++++++++++++
.../perf/tests/shell/test_stat_cpi_python.sh | 106 +++++++++
2 files changed, 314 insertions(+)
create mode 100755 tools/perf/python/stat-cpi.py
create mode 100755 tools/perf/tests/shell/test_stat_cpi_python.sh
diff --git a/tools/perf/python/stat-cpi.py b/tools/perf/python/stat-cpi.py
new file mode 100755
index 000000000000..87df5ad279a0
--- /dev/null
+++ b/tools/perf/python/stat-cpi.py
@@ -0,0 +1,208 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0
+"""Calculate CPI from perf stat data or live."""
+from __future__ import annotations
+
+import argparse
+import os
+import signal
+import sys
+import time
+from typing import Any, Optional
+import perf
+
+class StatCpiAnalyzer:
+ """Accumulates cycles and instructions and calculates CPI."""
+
+ def __init__(self, args: argparse.Namespace) -> None:
+ self.args = args
+ self.data: dict[str, tuple[int, int, int]] = {}
+ self.prev_data: dict[str, tuple[int, int, int]] = {}
+ self.recorded_pairs: set[tuple[int, int]] = set()
+
+ def get_key(self, event: str, cpu: int, thread: int) -> str:
+ """Get key for data dictionary."""
+ return f"{event}-{cpu}-{thread}"
+
+ def store_key(self, cpu: int, thread: int) -> None:
+ """Store CPU and thread IDs."""
+ self.recorded_pairs.add((cpu, thread))
+
+ def store(self, event: str, cpu: int, thread: int,
+ counts: tuple[int, int, int], is_delta: bool = False,
+ raw_name: Optional[str] = None) -> None:
+ """Store counter values, computing difference from previous
+ absolute values if not already deltas."""
+ self.store_key(cpu, thread)
+ key = self.get_key(event, cpu, thread)
+ prev_key = self.get_key(raw_name or event, cpu, thread)
+
+ val, ena, run = counts
+ if is_delta:
+ # counts are already deltas
+ cur_val = val
+ cur_ena = ena
+ cur_run = run
+ else:
+ if prev_key in self.prev_data:
+ prev_val, prev_ena, prev_run = self.prev_data[prev_key]
+ cur_val = val - prev_val
+ cur_ena = ena - prev_ena
+ cur_run = run - prev_run
+ else:
+ cur_val = val
+ cur_ena = ena
+ cur_run = run
+ self.prev_data[prev_key] = counts # Store absolute value for next time
+
+ if key in self.data:
+ old_val, old_ena, old_run = self.data[key]
+ self.data[key] = (old_val + cur_val, old_ena + cur_ena, old_run + cur_run)
+ else:
+ self.data[key] = (cur_val, cur_ena, cur_run)
+
+ def get(self, event: str, cpu: int, thread: int) -> float:
+ """Get scaled counter value."""
+ key = self.get_key(event, cpu, thread)
+ if key not in self.data:
+ return 0.0
+ val, ena, run = self.data[key]
+ if run > 0:
+ return val * (ena / float(run))
+ return float(val)
+
+ def process_stat_event(self, event: Any, name: Optional[str] = None) -> None:
+ """Process PERF_RECORD_STAT and PERF_RECORD_STAT_ROUND events."""
+ if event.type == perf.RECORD_STAT:
+ if name:
+ if "cycles" in name:
+ event_name = "cycles"
+ elif "instructions" in name:
+ event_name = "instructions"
+ else:
+ return
+ self.store(event_name, event.cpu, event.thread,
+ (event.val, event.ena, event.run), raw_name=name)
+ elif event.type == perf.RECORD_STAT_ROUND:
+ timestamp = getattr(event, "time", 0)
+ self.print_interval(timestamp)
+ self.data.clear()
+ self.recorded_pairs.clear()
+
+ def print_interval(self, timestamp: int) -> None:
+ """Print CPI for the current interval."""
+ for cpu, thread in sorted(self.recorded_pairs):
+ cyc = self.get("cycles", cpu, thread)
+ ins = self.get("instructions", cpu, thread)
+ cpi = 0.0
+ if ins != 0:
+ cpi = cyc / float(ins)
+ t_sec = timestamp / 1000000000.0
+ print(f"{t_sec:15f}: cpu {cpu}, thread {thread} -> cpi {cpi:f} ({cyc:.0f}/{ins:.0f})")
+
+ def read_counters(self, evlist: Any) -> None:
+ """Read counters live."""
+ for evsel in evlist:
+ name = str(evsel)
+ if "cycles" in name:
+ event_name = "cycles"
+ elif "instructions" in name:
+ event_name = "instructions"
+ else:
+ continue
+
+ for cpu in evsel.cpus():
+ for thread in evsel.threads():
+ try:
+ counts = evsel.read(cpu, thread)
+ self.store(event_name, cpu, thread,
+ (counts.val, counts.ena, counts.run),
+ is_delta=True, raw_name=name)
+ except OSError:
+ pass
+
+ def run_file(self) -> None:
+ """Process events from file."""
+ session = perf.session(perf.data(self.args.input), stat=self.process_stat_event)
+ session.process_events()
+
+ def _open_live_evlist(self) -> Any:
+ """Open evlist for live mode, falling back to user-space or process scope on EACCES."""
+ threads = perf.thread_map(self.args.pid) if self.args.pid else None
+ candidates = [
+ ("cycles,instructions", threads),
+ ("cycles:u,instructions:u", threads),
+ ]
+ if threads is None:
+ self_threads = perf.thread_map(os.getpid())
+ candidates.append(("cycles,instructions", self_threads))
+ candidates.append(("cycles:u,instructions:u", self_threads))
+
+ last_err: Optional[OSError] = None
+ for events, tmap in candidates:
+ try:
+ evlist = perf.parse_events(events, None, tmap)
+ for evsel in evlist:
+ evsel.read_format |= (
+ perf.FORMAT_TOTAL_TIME_ENABLED | perf.FORMAT_TOTAL_TIME_RUNNING
+ )
+ evlist.open()
+ evlist.enable()
+ return evlist
+ except PermissionError as e:
+ last_err = e
+ except OSError as e:
+ if e.errno == 13:
+ last_err = e
+ else:
+ raise
+ if last_err is not None:
+ raise last_err
+ raise RuntimeError("Failed to open events")
+
+ def run_live(self) -> None:
+ """Read counters live."""
+ try:
+ evlist = self._open_live_evlist()
+ except OSError as e:
+ print(f"Failed to open events: {e}", file=sys.stderr)
+ sys.exit(1)
+
+ def handle_signal(_signum: int, _frame: Any) -> None:
+ raise KeyboardInterrupt
+
+ signal.signal(signal.SIGINT, signal.default_int_handler)
+ signal.signal(signal.SIGTERM, handle_signal)
+
+ print("Live mode started. Press Ctrl+C to stop.")
+ try:
+ while True:
+ time.sleep(self.args.interval)
+ timestamp = time.time_ns()
+ self.read_counters(evlist)
+ self.print_interval(timestamp)
+ self.data.clear()
+ self.recorded_pairs.clear()
+ except KeyboardInterrupt:
+ print("\nStopped.")
+ finally:
+ evlist.close()
+
+def main() -> None:
+ """Main function."""
+ ap = argparse.ArgumentParser(description="Calculate CPI from perf stat data or live")
+ ap.add_argument("-i", "--input", help="Input file name (enables file mode)")
+ ap.add_argument("-I", "--interval", type=float, default=1.0,
+ help="Interval in seconds for live mode")
+ ap.add_argument("-p", "--pid", type=int,
+ help="Monitor specific process ID in live mode")
+ args = ap.parse_args()
+
+ analyzer = StatCpiAnalyzer(args)
+ if args.input:
+ analyzer.run_file()
+ else:
+ analyzer.run_live()
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/perf/tests/shell/test_stat_cpi_python.sh b/tools/perf/tests/shell/test_stat_cpi_python.sh
new file mode 100755
index 000000000000..8579f9f552a8
--- /dev/null
+++ b/tools/perf/tests/shell/test_stat_cpi_python.sh
@@ -0,0 +1,106 @@
+#!/bin/bash
+# SPDX-License-Identifier: GPL-2.0
+# stat-cpi python test
+
+set -e
+
+shelldir=$(dirname "$0")
+# shellcheck source=lib/setup_python.sh
+. "${shelldir}"/lib/setup_python.sh
+
+# If we don't have the perf python module, we can't test
+if ! "$PYTHON" -c 'import perf' > /dev/null 2>&1; then
+ echo "Skipping test, perf python module not found"
+ return 2 2>/dev/null || exit 2
+fi
+
+script_dir="$(dirname "$0")/../../python"
+script_path="${script_dir}/stat-cpi.py"
+
+if [ ! -f "$script_path" ]; then
+ echo "Skipping test, stat-cpi.py not found at $script_path"
+ return 2 2>/dev/null || exit 2
+fi
+
+err=0
+ran=0
+temp_data=""
+temp_out=""
+
+cleanup() {
+ [ -n "${pid}" ] && kill "$pid" 2>/dev/null || true
+ [ -n "${workload_pid}" ] && kill "$workload_pid" 2>/dev/null || true
+ rm -f "${temp_data}" "${temp_out}"
+ trap - exit term int
+}
+
+trap_cleanup() {
+ cleanup
+ exit 1
+}
+trap trap_cleanup exit term int
+
+temp_data=$(mktemp /tmp/perf.data.XXXXXX)
+temp_out=$(mktemp /tmp/perf.out.XXXXXX)
+
+test_live_mode() {
+ echo "Testing stat-cpi.py live mode..."
+ if ! perf stat -e cycles,instructions -- sleep 0.1 2>/dev/null; then
+ echo "perf stat failed (permissions?), skipping live mode test."
+ return 0
+ fi
+ ran=1
+
+ perf test -w noploop &
+ workload_pid=$!
+
+ # Run live mode for 1 interval in the background, give it a tiny sleep, then interrupt
+ "$PYTHON" "$script_path" -I 0.1 -p "$workload_pid" > "${temp_out}" &
+ pid=$!
+ sleep 0.5
+ kill -INT "$pid" 2>/dev/null || true
+ set +e
+ wait "$pid"
+ res=$?
+ set -e
+ pid=""
+ kill "$workload_pid" 2>/dev/null || true
+ workload_pid=""
+ if [ $res -ne 0 ] && [ $res -ne 130 ] && [ $res -ne 143 ]; then
+ echo "Live mode failed or crashed"
+ err=1
+ elif ! grep -q "cpi" "${temp_out}"; then
+ echo "Live mode produced no cpi output"
+ err=1
+ else
+ echo "Live mode test passed."
+ fi
+}
+
+test_file_mode() {
+ echo "Testing stat-cpi.py file mode..."
+ # Generate some stat events - perf stat -I represents interval reporting
+ if ! perf stat -e cycles,instructions -I 100 record -o "${temp_data}" \
+ -- sleep 0.5 2>/dev/null; then
+ echo "perf stat failed (permissions?), skipping file mode test."
+ return
+ fi
+ ran=1
+
+ out=$("$PYTHON" "$script_path" -i "${temp_data}")
+ if ! echo "$out" | grep -q "cpi"; then
+ echo "File mode test failed."
+ err=1
+ else
+ echo "File mode test passed."
+ fi
+}
+
+test_live_mode
+test_file_mode
+
+cleanup
+if [ $ran -eq 0 ]; then
+ exit 2
+fi
+exit $err
--
2.55.0.1082.g2b9226bbc0-goog