Skip to content

Commit c6ad89a

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 c6ad89a

File tree

2 files changed

+393
-0
lines changed

2 files changed

+393
-0
lines changed

perf/perf_stat.py

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