|  | 
|  | 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