Skip to content

Commit 63b9dba

Browse files
Tejas Manhasroot
authored andcommitted
perf_stat.py: Add testcase for present and unused options
This commit adds a new Avocado testcase to dynamically run all perf stat options that are currently present in the system help but missing from kernel source tests. Features include: - Automatic extraction of perf stat --help options. - Filtering out options already covered in kernel perf tests. - Minimal resource generation for options that require inputs. - Special handling for metric groups, topdown, transaction, PID, and TID. - Minimal workloads created dynamically to avoid errors. - Logging of unknown or failed options with exit codes. This testcase helps ensure coverage of all perf stat options and detects any discrepancies between help output and available kernel tests. Signed-off-by: Tejas Manhas <[email protected]>
1 parent f18ce52 commit 63b9dba

File tree

2 files changed

+380
-0
lines changed

2 files changed

+380
-0
lines changed

perf/perf_stat.py

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
#!/usr/bin/env python
2+
#
3+
# This program is free software; you can redistribute it and/or modify
4+
# it under the terms of the GNU General Public License as published by
5+
# the Free Software Foundation; either version 2 of the License, or
6+
# (at your option) any later version.
7+
#
8+
# This program is distributed in the hope that it will be useful,
9+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11+
#
12+
# See LICENSE for more details.
13+
#
14+
# Copyright: 2025 IBM
15+
# Author: Tejas Manhas <[email protected]>
16+
import re
17+
import os
18+
from avocado import Test
19+
from avocado.utils import process, distro, build
20+
from avocado.utils.software_manager.manager import SoftwareManager
21+
22+
23+
class PerfStatOptions(Test):
24+
"""
25+
Test perf stat options: compare --help options vs kernel source.
26+
Run only options present in help but missing from source.
27+
"""
28+
29+
def setUp(self):
30+
"""
31+
Checks for dependencies and packages and Compiles
32+
final stat options options that are not used to be run
33+
"""
34+
self.log.info("Setting up PerfStatOptions test...")
35+
36+
# Check dependencies for RHEL/SLES/upstream
37+
self.detected_distro = distro.detect()
38+
smg = SoftwareManager()
39+
packages = ["perf"]
40+
for pkg in packages:
41+
if not smg.check_installed(pkg):
42+
if not smg.install(pkg):
43+
self.cancel(f"{pkg} is required for this test")
44+
45+
if self.detected_distro.name in [
46+
'rhel', 'centos', 'fedora', 'rocky', 'almalinux']:
47+
src_name = 'kernel'
48+
if self.detected_distro.name == 'rhel' and int(
49+
self.detected_distro.version) >= 9:
50+
pass
51+
self.buldir = smg.get_source(
52+
src_name, self.workdir, build_option='-bp')
53+
self.buldir = os.path.join(self.buldir, os.listdir(self.buldir)[0])
54+
55+
elif 'SuSE' in self.detected_distro.name:
56+
if not smg.check_installed(
57+
"kernel-source") and not smg.install("kernel-source"):
58+
self.cancel("Failed to install kernel-source for this test.")
59+
if not os.path.exists("/usr/src/linux"):
60+
self.cancel("kernel source missing after install")
61+
self.buldir = "/usr/src/linux"
62+
63+
elif self.detected_distro.name in ['ubuntu', 'debian']:
64+
self.buldir = smg.get_source('linux', self.workdir)
65+
66+
else:
67+
self.cancel(
68+
"Distro %s not supported for kernel source install" %
69+
self.detected_distro.name)
70+
71+
self.sourcedir = os.path.join(self.buldir, 'tools/perf')
72+
73+
self.unknown_options = set()
74+
self.failed_options = {}
75+
76+
# Get help options
77+
self.perf_options = self.get_help_options()
78+
self.log.info(f"Perf --help options: {self.perf_options}")
79+
80+
# Get source options
81+
self.src_options = self.get_src_options()
82+
self.log.info(
83+
f"Source options from kernel perf tests: {self.src_options}")
84+
85+
# Final options to test
86+
self.final_to_test = self.perf_options - self.src_options
87+
self.log.info(f"Final options to test: {self.final_to_test}")
88+
89+
def get_help_options(self):
90+
"""
91+
Extract valid -/-- options from `perf stat --help`.
92+
Ignores separators and non-option lines.
93+
"""
94+
result = process.run("perf stat --help", ignore_status=True)
95+
out = result.stdout.decode()
96+
opts = set()
97+
for line in out.splitlines():
98+
stripped = line.lstrip()
99+
# Only parse actual option lines
100+
if not stripped.startswith('-'):
101+
continue
102+
103+
# Tokenize line
104+
tokens = stripped.split()
105+
for token in tokens:
106+
# Capture only real options (skip commas and arguments)
107+
if re.match(r"^-{1,2}[A-Za-z0-9][A-Za-z0-9\-]*$", token):
108+
clean_opt = self.sanitize_option(token)
109+
if clean_opt:
110+
opts.add(clean_opt)
111+
112+
return opts
113+
114+
def get_src_options(self):
115+
"""
116+
Grep perf kernel tests for 'perf stat' and extract options.
117+
"""
118+
if not os.path.exists(self.sourcedir):
119+
self.cancel(f"{self.sourcedir} not found, cannot build tools/perf")
120+
121+
self.log.info(f"Building tools/perf in {self.sourcedir}")
122+
if build.make(self.sourcedir):
123+
self.fail("tools/perf build failed, check logs")
124+
self.log.info(f"Using Linux source directory: {self.sourcedir}")
125+
126+
# Grep recursively for 'perf stat' in tools/perf/tests and tests/shell
127+
cmd = f"grep -r 'perf stat' {self.sourcedir}/tests || true"
128+
result = process.run(cmd, ignore_status=True, shell=True)
129+
out = result.stdout.decode()
130+
opts = set()
131+
for line in out.splitlines():
132+
matches = re.findall(r"-{1,2}[a-zA-Z0-9][\w-]*", line)
133+
for opt in matches:
134+
clean_opt = self.sanitize_option(opt)
135+
if clean_opt:
136+
opts.add(clean_opt)
137+
return opts
138+
139+
def run_and_check(self, opt):
140+
"""
141+
Run a perf stat command for the given option, automatically generating
142+
required resources and minimal valid workloads to avoid errors.
143+
"""
144+
145+
# --- Step 1: Sanitize option ---
146+
opt = self.sanitize_option(opt)
147+
if not opt:
148+
return
149+
150+
# --- Step 2: Skip unsupported infra (cgroup / bpf) ---
151+
if opt in [
152+
"--for-each-cgroup", "-b", "--bpf",
153+
"--bpf-attr-map", "--bpf-counters",
154+
"--bpf-prog", "--cgroup",
155+
"--smi-cost", "--interval-clear"
156+
]:
157+
self.log.info(f"Skipping unsupported option: {opt}")
158+
self.unknown_options.add(opt)
159+
return
160+
161+
# --- Step 3: Determine the resource / value for this option ---
162+
minimal = self.params.get(opt, default="")
163+
if minimal:
164+
self.log.info(
165+
f"For option {opt}, using minimal value from YAML: {minimal}")
166+
else:
167+
self.log.info(
168+
f"No YAML value found for option {opt}, skipping or using default")
169+
170+
if opt in ["--metric-groups"]:
171+
cmd1 = "perf list metricgroup 2>/dev/null | grep -v '^$' | grep -v 'Metric Groups' | head -1"
172+
result1 = process.run(cmd1, ignore_status=True, shell=True)
173+
metric_group = result1.stdout.strip().decode()
174+
minimal = metric_group
175+
if not metric_group:
176+
self.cancel("No metric groups available on this system")
177+
self.log.info(f"Using metric group: {metric_group}")
178+
179+
if opt in ["--topdown", "-T", "--transaction", "-t"]:
180+
grep_pat = "^TopdownL1" if opt in [
181+
"--topdown", "-T"] else "^transaction"
182+
group = process.run(
183+
f"perf list metricgroups 2>/dev/null | grep '{grep_pat}' | head -1",
184+
shell=True, ignore_status=True
185+
).stdout.strip().decode()
186+
if not group:
187+
self.log.info(
188+
f"{opt} metric groups not present on this system")
189+
self.unknown_options.add(opt)
190+
return
191+
else:
192+
minimal = group
193+
194+
# Special handling for TID
195+
if opt in ["-t", "--tid"] or "--tid=" in opt:
196+
task_dir = "/proc/self/task"
197+
try:
198+
tids = os.listdir(task_dir)
199+
minimal = tids[0] if tids else str(os.getpid())
200+
except Exception:
201+
minimal = str(os.getpid())
202+
if opt in ["-p", "--pid"] or "--pid=" in opt:
203+
minimal = str(os.getpid())
204+
205+
# --- Step 4: Generate required files / workloads ---
206+
# Input data for perf
207+
if opt in ["--input"]:
208+
process.run(
209+
f"mkdir -p events_dir && echo -e 'cycles,instructions' > {minimal}",
210+
shell=True)
211+
212+
# Minimal post/pre scripts
213+
if opt in ["--post", "--pre"] and not os.path.exists(minimal):
214+
with open(minimal, "w") as f:
215+
f.write("#!/bin/bash\nsleep 0.1\n")
216+
os.chmod(minimal, 0o755)
217+
218+
# --- Step 5: Construct command ---
219+
cmd_parts = ["perf", "stat"]
220+
221+
# Flags that require a dependent event
222+
flags_with_deps = [
223+
"-b", "-u",
224+
"-s", "--metric-only",
225+
"--topdown", "--transaction", "-T"]
226+
if opt in flags_with_deps:
227+
cmd_parts.extend(["-e", self.params.get("-e")])
228+
229+
# Options with "="
230+
if "=" in opt:
231+
base_opt = opt.split("=", 1)[0]
232+
cmd_parts.append(f"{base_opt}={minimal}")
233+
elif minimal:
234+
cmd_parts.extend([opt, minimal])
235+
else:
236+
cmd_parts.append(opt)
237+
238+
# Default minimal workload
239+
workload = "sleep 5"
240+
cmd_parts.append(workload)
241+
242+
cmd = " ".join(cmd_parts)
243+
244+
# --- Step 6: Run command ---
245+
result = process.run(cmd, shell=True, ignore_status=True)
246+
ret = result.exit_status
247+
out = result.stdout_text
248+
err = result.stderr_text
249+
250+
# --- Step 7: Handle results ---
251+
if ret != 0:
252+
if ret == 129 or "unknown option" in err.lower():
253+
self.log.info(f"Skipping option {opt}: unknown option")
254+
self.unknown_options.add(opt)
255+
else:
256+
self.failed_options[opt] = {
257+
"exit_code": ret,
258+
"stderr": err.strip(),
259+
}
260+
self.log.warning(f"Option {opt} failed with exit code {ret}")
261+
else:
262+
self.log.info(f"Option {opt} ran successfully")
263+
264+
return ret, out, err
265+
266+
def sanitize_option(self, opt):
267+
"""
268+
Remove trailing non-alphanumeric chars commonly found in perf help/source.
269+
Keep leading '-' or '--'.
270+
"""
271+
# opt = opt.strip()
272+
if not opt.startswith("-"):
273+
return None
274+
# remove trailing junk characters
275+
opt = re.sub(r"[),.:;/\[\]]+$", "", opt)
276+
# handle attached arguments: -G/cgroup -> -G, --foo=bar -> --foo,
277+
# -j64 -> -j
278+
opt = re.split(r"[=/]", opt, 1)[0]
279+
opt = re.sub(r"^(-[a-zA-Z])\d+$", r"\1", opt)
280+
# remove leading/trailing whitespace
281+
opt = opt.strip()
282+
if not opt:
283+
return None
284+
return opt
285+
286+
def test_perf_stat_options(self):
287+
"""
288+
Run all final options with minimal values where required.
289+
"""
290+
291+
prefix = self.params.get("--prefix", default="")
292+
293+
if not prefix:
294+
self.log.info(
295+
"No YAML file provided, running plain perf report and exiting"
296+
)
297+
result = process.run(
298+
"perf stat sleep 2 > /tmp/perf_report_options.txt 2>&1"
299+
shell=True,
300+
ignore_status=False
301+
)
302+
if result.exit_status != 0:
303+
self.fail(f"Plain perf report failed: {result.stderr_text}")
304+
return
305+
306+
for opt in sorted(self.final_to_test):
307+
self.log.info(f"Testing option: {opt}")
308+
self.run_and_check(opt)
309+
if self.unknown_options:
310+
self.log.warning(
311+
f"Unknown options skipped: {', '.join(self.unknown_options)}")
312+
if self.failed_options:
313+
self.log.error("Failed options and their exit codes:")
314+
for opt, code in self.failed_options.items():
315+
self.log.error(f" {opt} -> {code}")
316+
self.fail(
317+
f"{len(self.failed_options)} options failed, see logs above")
318+
319+
def tearDown(self):
320+
"""
321+
Removes temporary files and post pre files
322+
"""
323+
self.log.info("Tearing down PerfStatOptions test...")
324+
# Remove events directory if exists
325+
events_dir = "events_dir"
326+
if os.path.exists(events_dir):
327+
try:
328+
import shutil
329+
shutil.rmtree(events_dir)
330+
self.log.info(f"Removed temporary directory: {events_dir}")
331+
except Exception as e:
332+
self.log.warning(f"Failed to remove {events_dir}: {e}")
333+
334+
# Remove any post/pre scripts created dynamically
335+
for opt in ["--post", "--pre"]:
336+
minimal = self.params.get(opt, default="")
337+
if minimal and os.path.exists(minimal):
338+
try:
339+
os.remove(minimal)
340+
self.log.info(f"Removed temporary script: {minimal}")
341+
except Exception as e:
342+
self.log.warning(f"Failed to remove {minimal}: {e}")

perf/perf_stat.py.data/stat.yaml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
--td-level: "0"
2+
--field-separator: "."
3+
--log-fd: "1"
4+
--output: "/tmp/perf_out.txt"
5+
-o: "/tmp/perf_out.txt"
6+
-e: "cycles"
7+
--event: "cpu-cycles"
8+
-C: "0"
9+
--cpu: "0"
10+
-I: "1000"
11+
--interval-print: "1000"
12+
-r: "1"
13+
--repeat: "1"
14+
-p: "{{ pid }}"
15+
--pid: "{{ pid }}"
16+
--control: "fd:0"
17+
--control = fifo: "fifo:dummy"
18+
--input: "events_dir/events.txt"
19+
--delay: "1"
20+
--post: "/tmp/perf_post.sh"
21+
--pre: "/tmp/perf_pre.sh"
22+
--timeout: "100"
23+
--time: "100"
24+
--interval - clear: "-I 1000 -a"
25+
--table: "-r 2"
26+
--no-aggr: "-a"
27+
-M: "BRU_STALL_CPI"
28+
--metrics: "BRU_STALL_CPI"
29+
--no-merge: "-a"
30+
-A: "-a"
31+
--per-cache: "-a"
32+
--per-cluster: "-a"
33+
--per-core: "-a"
34+
--per-die: "-a"
35+
--per-node: "-a"
36+
--per-socket: "-a"
37+
--per-thread: "-a"
38+
-x: ","

0 commit comments

Comments
 (0)