Skip to content

Commit cdf851b

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 <Tejas.Manhas1@ibm.com>
1 parent f18ce52 commit cdf851b

File tree

2 files changed

+361
-0
lines changed

2 files changed

+361
-0
lines changed

perf/perf_stat.py

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

perf/perf_stat.py.data/stat.yaml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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+
-M: "cycles"
15+
-p: "{{ pid }}"
16+
--pid: "{{ pid }}"
17+
--control: "fd:0"
18+
--control=fifo: "fifo:dummy"
19+
--input: "events_dir/events.txt"
20+
-j: "1"
21+
--delay: "1"
22+
--post: "/tmp/perf_post.sh"
23+
--pre: "/tmp/perf_pre.sh"
24+
--timeout: "100"
25+
--time: "100"
26+
--interval-clear: "-I 1000 -a"
27+
--table: "-r 2"
28+
--no-aggr: "-a"

0 commit comments

Comments
 (0)