Skip to content

Commit e2252bf

Browse files
committed
Add some tests for samply profiling
1 parent 057388d commit e2252bf

File tree

1 file changed

+244
-0
lines changed

1 file changed

+244
-0
lines changed

Lib/test/test_samply_profiler.py

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import unittest
2+
import subprocess
3+
import sys
4+
import sysconfig
5+
import os
6+
import pathlib
7+
import gzip
8+
from test import support
9+
from test.support.script_helper import (
10+
make_script,
11+
)
12+
from test.support.os_helper import temp_dir
13+
14+
15+
if not support.has_subprocess_support:
16+
raise unittest.SkipTest("test module requires subprocess")
17+
18+
if support.check_sanitizer(address=True, memory=True, ub=True, function=True):
19+
# gh-109580: Skip the test because it does crash randomly if Python is
20+
# built with ASAN.
21+
raise unittest.SkipTest("test crash randomly on ASAN/MSAN/UBSAN build")
22+
23+
24+
def supports_trampoline_profiling():
25+
perf_trampoline = sysconfig.get_config_var("PY_HAVE_PERF_TRAMPOLINE")
26+
if not perf_trampoline:
27+
return False
28+
return int(perf_trampoline) == 1
29+
30+
31+
if not supports_trampoline_profiling():
32+
raise unittest.SkipTest("perf trampoline profiling not supported")
33+
34+
35+
def samply_command_works():
36+
try:
37+
cmd = ["samply", "--help"]
38+
except (subprocess.SubprocessError, OSError):
39+
return False
40+
41+
# Check that we can run a simple samply run
42+
with temp_dir() as script_dir:
43+
try:
44+
output_file = script_dir + "/profile.json.gz"
45+
cmd = (
46+
"samply",
47+
"record",
48+
"--save-only",
49+
"--output",
50+
output_file,
51+
sys.executable,
52+
"-c",
53+
'print("hello")',
54+
)
55+
env = {**os.environ, "PYTHON_JIT": "0"}
56+
stdout = subprocess.check_output(
57+
cmd, cwd=script_dir, text=True, stderr=subprocess.STDOUT, env=env
58+
)
59+
except (subprocess.SubprocessError, OSError):
60+
return False
61+
62+
if "hello" not in stdout:
63+
return False
64+
65+
return True
66+
67+
68+
def run_samply(cwd, *args, **env_vars):
69+
env = os.environ.copy()
70+
if env_vars:
71+
env.update(env_vars)
72+
env["PYTHON_JIT"] = "0"
73+
output_file = cwd + "/profile.json.gz"
74+
base_cmd = (
75+
"samply",
76+
"record",
77+
"--save-only",
78+
"-o", output_file,
79+
)
80+
proc = subprocess.run(
81+
base_cmd + args,
82+
stdout=subprocess.PIPE,
83+
stderr=subprocess.PIPE,
84+
env=env,
85+
)
86+
if proc.returncode:
87+
print(proc.stderr, file=sys.stderr)
88+
raise ValueError(f"Samply failed with return code {proc.returncode}")
89+
90+
with gzip.open(output_file, mode="rt", encoding="utf-8") as f:
91+
return f.read()
92+
93+
94+
@unittest.skipUnless(samply_command_works(), "samply command doesn't work")
95+
class TestSamplyProfilerMixin:
96+
def run_samply(self, script_dir, perf_mode, script):
97+
raise NotImplementedError()
98+
99+
def test_python_calls_appear_in_the_stack_if_perf_activated(self):
100+
with temp_dir() as script_dir:
101+
code = """if 1:
102+
def foo(n):
103+
x = 0
104+
for i in range(n):
105+
x += i
106+
107+
def bar(n):
108+
foo(n)
109+
110+
def baz(n):
111+
bar(n)
112+
113+
baz(10000000)
114+
"""
115+
script = make_script(script_dir, "perftest", code)
116+
output = self.run_samply(script_dir, script)
117+
118+
self.assertIn(f"py::foo:{script}", output)
119+
self.assertIn(f"py::bar:{script}", output)
120+
self.assertIn(f"py::baz:{script}", output)
121+
122+
def test_python_calls_do_not_appear_in_the_stack_if_perf_deactivated(self):
123+
with temp_dir() as script_dir:
124+
code = """if 1:
125+
def foo(n):
126+
x = 0
127+
for i in range(n):
128+
x += i
129+
130+
def bar(n):
131+
foo(n)
132+
133+
def baz(n):
134+
bar(n)
135+
136+
baz(10000000)
137+
"""
138+
script = make_script(script_dir, "perftest", code)
139+
output = self.run_samply(
140+
script_dir, script, activate_trampoline=False
141+
)
142+
143+
self.assertNotIn(f"py::foo:{script}", output)
144+
self.assertNotIn(f"py::bar:{script}", output)
145+
self.assertNotIn(f"py::baz:{script}", output)
146+
147+
148+
@unittest.skipUnless(samply_command_works(), "samply command doesn't work")
149+
class TestSamplyProfiler(unittest.TestCase, TestSamplyProfilerMixin):
150+
def run_samply(self, script_dir, script, activate_trampoline=True):
151+
if activate_trampoline:
152+
return run_samply(script_dir, sys.executable, "-Xperf", script)
153+
return run_samply(script_dir, sys.executable, script)
154+
155+
def setUp(self):
156+
super().setUp()
157+
self.perf_files = set(pathlib.Path("/tmp/").glob("perf-*.map"))
158+
159+
def tearDown(self) -> None:
160+
super().tearDown()
161+
files_to_delete = (
162+
set(pathlib.Path("/tmp/").glob("perf-*.map")) - self.perf_files
163+
)
164+
for file in files_to_delete:
165+
file.unlink()
166+
167+
def test_pre_fork_compile(self):
168+
code = """if 1:
169+
import sys
170+
import os
171+
import sysconfig
172+
from _testinternalcapi import (
173+
compile_perf_trampoline_entry,
174+
perf_trampoline_set_persist_after_fork,
175+
)
176+
177+
def foo_fork():
178+
pass
179+
180+
def bar_fork():
181+
foo_fork()
182+
183+
def foo():
184+
import time; time.sleep(1)
185+
186+
def bar():
187+
foo()
188+
189+
def compile_trampolines_for_all_functions():
190+
perf_trampoline_set_persist_after_fork(1)
191+
for _, obj in globals().items():
192+
if callable(obj) and hasattr(obj, '__code__'):
193+
compile_perf_trampoline_entry(obj.__code__)
194+
195+
if __name__ == "__main__":
196+
compile_trampolines_for_all_functions()
197+
pid = os.fork()
198+
if pid == 0:
199+
print(os.getpid())
200+
bar_fork()
201+
else:
202+
bar()
203+
"""
204+
205+
with temp_dir() as script_dir:
206+
script = make_script(script_dir, "perftest", code)
207+
env = {**os.environ, "PYTHON_JIT": "0"}
208+
with subprocess.Popen(
209+
[sys.executable, "-Xperf", script],
210+
universal_newlines=True,
211+
stderr=subprocess.PIPE,
212+
stdout=subprocess.PIPE,
213+
env=env,
214+
) as process:
215+
stdout, stderr = process.communicate()
216+
217+
self.assertEqual(process.returncode, 0)
218+
self.assertNotIn("Error:", stderr)
219+
child_pid = int(stdout.strip())
220+
perf_file = pathlib.Path(f"/tmp/perf-{process.pid}.map")
221+
perf_child_file = pathlib.Path(f"/tmp/perf-{child_pid}.map")
222+
self.assertTrue(perf_file.exists())
223+
self.assertTrue(perf_child_file.exists())
224+
225+
perf_file_contents = perf_file.read_text()
226+
self.assertIn(f"py::foo:{script}", perf_file_contents)
227+
self.assertIn(f"py::bar:{script}", perf_file_contents)
228+
self.assertIn(f"py::foo_fork:{script}", perf_file_contents)
229+
self.assertIn(f"py::bar_fork:{script}", perf_file_contents)
230+
231+
child_perf_file_contents = perf_child_file.read_text()
232+
self.assertIn(f"py::foo_fork:{script}", child_perf_file_contents)
233+
self.assertIn(f"py::bar_fork:{script}", child_perf_file_contents)
234+
235+
# Pre-compiled perf-map entries of a forked process must be
236+
# identical in both the parent and child perf-map files.
237+
perf_file_lines = perf_file_contents.split("\n")
238+
for line in perf_file_lines:
239+
if f"py::foo_fork:{script}" in line or f"py::bar_fork:{script}" in line:
240+
self.assertIn(line, child_perf_file_contents)
241+
242+
243+
if __name__ == "__main__":
244+
unittest.main()

0 commit comments

Comments
 (0)