-
-
Notifications
You must be signed in to change notification settings - Fork 32.4k
gh-136459: Add perf trampoline support for macOS #136461
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
canova
wants to merge
11
commits into
python:main
Choose a base branch
from
canova:perf-trampoline-macos
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+351
−27
Open
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
7547a29
Add perf trampoline support for macOS
canova 17df9c8
Make sure that test_perfmaps.py test is not skipped on macOS
canova 3dac7a9
Update the docs for perfmaps to mention that macOS is supported
canova d428ba4
Add myself to Misc/ACKS
canova 50e80bf
Add a Misc/NEWS.d entry
canova 3f9e24d
Define constants per-platform
canova dc54659
Do not mmap the jitdump file on macOS
canova 8a20a4b
Update the perf profiling doc to include samply
canova 1432cc8
Add some tests for samply profiling
canova a7b043d
Apply documentation suggestions from code review
canova 3ae5cb2
Change the version number in the docs and mention macOS restrictions
canova File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,244 @@ | ||
import unittest | ||
import subprocess | ||
import sys | ||
import sysconfig | ||
import os | ||
import pathlib | ||
from test import support | ||
from test.support.script_helper import ( | ||
make_script, | ||
) | ||
from test.support.os_helper import temp_dir | ||
|
||
|
||
if not support.has_subprocess_support: | ||
raise unittest.SkipTest("test module requires subprocess") | ||
|
||
if support.check_sanitizer(address=True, memory=True, ub=True, function=True): | ||
# gh-109580: Skip the test because it does crash randomly if Python is | ||
# built with ASAN. | ||
raise unittest.SkipTest("test crash randomly on ASAN/MSAN/UBSAN build") | ||
|
||
|
||
def supports_trampoline_profiling(): | ||
perf_trampoline = sysconfig.get_config_var("PY_HAVE_PERF_TRAMPOLINE") | ||
if not perf_trampoline: | ||
return False | ||
return int(perf_trampoline) == 1 | ||
|
||
|
||
if not supports_trampoline_profiling(): | ||
raise unittest.SkipTest("perf trampoline profiling not supported") | ||
|
||
|
||
def samply_command_works(): | ||
try: | ||
cmd = ["samply", "--help"] | ||
except (subprocess.SubprocessError, OSError): | ||
return False | ||
|
||
# Check that we can run a simple samply run | ||
with temp_dir() as script_dir: | ||
try: | ||
output_file = script_dir + "/profile.json.gz" | ||
cmd = ( | ||
"samply", | ||
"record", | ||
"--save-only", | ||
"--output", | ||
output_file, | ||
sys.executable, | ||
"-c", | ||
'print("hello")', | ||
) | ||
env = {**os.environ, "PYTHON_JIT": "0"} | ||
stdout = subprocess.check_output( | ||
cmd, cwd=script_dir, text=True, stderr=subprocess.STDOUT, env=env | ||
) | ||
except (subprocess.SubprocessError, OSError): | ||
return False | ||
|
||
if "hello" not in stdout: | ||
return False | ||
|
||
return True | ||
|
||
|
||
def run_samply(cwd, *args, **env_vars): | ||
env = os.environ.copy() | ||
if env_vars: | ||
env.update(env_vars) | ||
env["PYTHON_JIT"] = "0" | ||
output_file = cwd + "/profile.json.gz" | ||
base_cmd = ( | ||
"samply", | ||
"record", | ||
"--save-only", | ||
"-o", output_file, | ||
) | ||
proc = subprocess.run( | ||
base_cmd + args, | ||
stdout=subprocess.PIPE, | ||
stderr=subprocess.PIPE, | ||
env=env, | ||
) | ||
if proc.returncode: | ||
print(proc.stderr, file=sys.stderr) | ||
raise ValueError(f"Samply failed with return code {proc.returncode}") | ||
|
||
import gzip | ||
with gzip.open(output_file, mode="rt", encoding="utf-8") as f: | ||
return f.read() | ||
|
||
|
||
@unittest.skipUnless(samply_command_works(), "samply command doesn't work") | ||
class TestSamplyProfilerMixin: | ||
def run_samply(self, script_dir, perf_mode, script): | ||
raise NotImplementedError() | ||
|
||
def test_python_calls_appear_in_the_stack_if_perf_activated(self): | ||
with temp_dir() as script_dir: | ||
code = """if 1: | ||
def foo(n): | ||
x = 0 | ||
for i in range(n): | ||
x += i | ||
|
||
def bar(n): | ||
foo(n) | ||
|
||
def baz(n): | ||
bar(n) | ||
|
||
baz(10000000) | ||
""" | ||
script = make_script(script_dir, "perftest", code) | ||
output = self.run_samply(script_dir, script) | ||
|
||
self.assertIn(f"py::foo:{script}", output) | ||
self.assertIn(f"py::bar:{script}", output) | ||
self.assertIn(f"py::baz:{script}", output) | ||
|
||
def test_python_calls_do_not_appear_in_the_stack_if_perf_deactivated(self): | ||
with temp_dir() as script_dir: | ||
code = """if 1: | ||
def foo(n): | ||
x = 0 | ||
for i in range(n): | ||
x += i | ||
|
||
def bar(n): | ||
foo(n) | ||
|
||
def baz(n): | ||
bar(n) | ||
|
||
baz(10000000) | ||
""" | ||
script = make_script(script_dir, "perftest", code) | ||
output = self.run_samply( | ||
script_dir, script, activate_trampoline=False | ||
) | ||
|
||
self.assertNotIn(f"py::foo:{script}", output) | ||
self.assertNotIn(f"py::bar:{script}", output) | ||
self.assertNotIn(f"py::baz:{script}", output) | ||
|
||
|
||
@unittest.skipUnless(samply_command_works(), "samply command doesn't work") | ||
class TestSamplyProfiler(unittest.TestCase, TestSamplyProfilerMixin): | ||
def run_samply(self, script_dir, script, activate_trampoline=True): | ||
if activate_trampoline: | ||
return run_samply(script_dir, sys.executable, "-Xperf", script) | ||
return run_samply(script_dir, sys.executable, script) | ||
|
||
def setUp(self): | ||
super().setUp() | ||
self.perf_files = set(pathlib.Path("/tmp/").glob("perf-*.map")) | ||
|
||
def tearDown(self) -> None: | ||
super().tearDown() | ||
files_to_delete = ( | ||
set(pathlib.Path("/tmp/").glob("perf-*.map")) - self.perf_files | ||
) | ||
for file in files_to_delete: | ||
file.unlink() | ||
|
||
def test_pre_fork_compile(self): | ||
code = """if 1: | ||
import sys | ||
import os | ||
import sysconfig | ||
from _testinternalcapi import ( | ||
compile_perf_trampoline_entry, | ||
perf_trampoline_set_persist_after_fork, | ||
) | ||
|
||
def foo_fork(): | ||
pass | ||
|
||
def bar_fork(): | ||
foo_fork() | ||
|
||
def foo(): | ||
import time; time.sleep(1) | ||
|
||
def bar(): | ||
foo() | ||
|
||
def compile_trampolines_for_all_functions(): | ||
perf_trampoline_set_persist_after_fork(1) | ||
for _, obj in globals().items(): | ||
if callable(obj) and hasattr(obj, '__code__'): | ||
compile_perf_trampoline_entry(obj.__code__) | ||
|
||
if __name__ == "__main__": | ||
compile_trampolines_for_all_functions() | ||
pid = os.fork() | ||
if pid == 0: | ||
print(os.getpid()) | ||
bar_fork() | ||
else: | ||
bar() | ||
""" | ||
|
||
with temp_dir() as script_dir: | ||
script = make_script(script_dir, "perftest", code) | ||
env = {**os.environ, "PYTHON_JIT": "0"} | ||
with subprocess.Popen( | ||
[sys.executable, "-Xperf", script], | ||
universal_newlines=True, | ||
stderr=subprocess.PIPE, | ||
stdout=subprocess.PIPE, | ||
env=env, | ||
) as process: | ||
stdout, stderr = process.communicate() | ||
|
||
self.assertEqual(process.returncode, 0) | ||
self.assertNotIn("Error:", stderr) | ||
child_pid = int(stdout.strip()) | ||
perf_file = pathlib.Path(f"/tmp/perf-{process.pid}.map") | ||
perf_child_file = pathlib.Path(f"/tmp/perf-{child_pid}.map") | ||
self.assertTrue(perf_file.exists()) | ||
self.assertTrue(perf_child_file.exists()) | ||
|
||
perf_file_contents = perf_file.read_text() | ||
self.assertIn(f"py::foo:{script}", perf_file_contents) | ||
self.assertIn(f"py::bar:{script}", perf_file_contents) | ||
self.assertIn(f"py::foo_fork:{script}", perf_file_contents) | ||
self.assertIn(f"py::bar_fork:{script}", perf_file_contents) | ||
|
||
child_perf_file_contents = perf_child_file.read_text() | ||
self.assertIn(f"py::foo_fork:{script}", child_perf_file_contents) | ||
self.assertIn(f"py::bar_fork:{script}", child_perf_file_contents) | ||
|
||
# Pre-compiled perf-map entries of a forked process must be | ||
# identical in both the parent and child perf-map files. | ||
perf_file_lines = perf_file_contents.split("\n") | ||
for line in perf_file_lines: | ||
if f"py::foo_fork:{script}" in line or f"py::bar_fork:{script}" in line: | ||
self.assertIn(line, child_perf_file_contents) | ||
|
||
|
||
if __name__ == "__main__": | ||
unittest.main() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
3 changes: 3 additions & 0 deletions
3
Misc/NEWS.d/next/Core_and_Builtins/2025-07-09-11-15-42.gh-issue-136459.m4Udh8.rst
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
Add support for perf trampoline on macOS, to allow profilers wit JIT map | ||
support to read Python calls. While profiling, ``PYTHONPERFSUPPORT=1`` can | ||
be appended to enable the trampoline. |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added a shameless plug to samply 😄 Disclaimer: I'm not the maintainer of the project, but the maintainer is my colleague. But it doesn't change the fact that it's an awesome profiler! But I can revert it if you prefer not to include :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am happy with the plug, but this docs are going to need much more than this then. If
samply
is the main way to use this on macOS then we will need to update https://docs.python.org/3/howto/perf_profiling.html with full instructions for samply :)