From b3b2d678d771b8685f77bf58095449c3d255fc9b Mon Sep 17 00:00:00 2001 From: maurycy <5383+maurycy@users.noreply.github.com> Date: Sat, 24 Jan 2026 21:43:55 +0100 Subject: [PATCH 1/3] tachyon --- pyperf/_hooks.py | 158 +++++++++++++++++++++++++++++++++++++++++++++++ pyperf/_utils.py | 4 ++ pyproject.toml | 1 + 3 files changed, 163 insertions(+) diff --git a/pyperf/_hooks.py b/pyperf/_hooks.py index 8c9e7cdf..fdcf3c26 100644 --- a/pyperf/_hooks.py +++ b/pyperf/_hooks.py @@ -5,8 +5,10 @@ import abc import importlib.metadata +import os import os.path import shlex +import signal import subprocess import sys import tempfile @@ -168,3 +170,159 @@ def exec_perf_cmd(self, cmd): self.ctl_fd.write(f"{cmd}\n") self.ctl_fd.flush() self.ack_fd.readline() + + +class tachyon(HookBase): + """Profile the benchmark using sampling profiler (Tachyon). + + Profile data is written to the current directory by default, or + to the value of the `PYPERF_TACHYON_DATA_DIR` environment variable. + + Profile data files have a basename `tachyon..`. + + Configuration environment variables: + PYPERF_TACHYON_DATA_DIR: Output directory (default: current) + PYPERF_TACHYON_FORMAT: Output format - pstats, collapsed, flamegraph, + gecko, heatmap, binary (default: pstats) + PYPERF_TACHYON_MODE: Profiling mode - wall, cpu, gil, exception + (default: cpu) + PYPERF_TACHYON_INTERVAL: Sampling interval in microseconds (default: 1000) + PYPERF_TACHYON_ALL_THREADS: Set to "1" to profile all threads + PYPERF_TACHYON_NATIVE: Set to "1" to include the native frames + PYPERF_TACHYON_ASYNC_AWARE: Set to "1" for async-aware profiling + """ + + FORMAT_EXTENSIONS = { + "pstats": "pstats", + "collapsed": "txt", + "flamegraph": "html", + "gecko": "json", + "heatmap": "", + "binary": "bin", + } + VALID_MODES = {"wall", "cpu", "gil", "exception"} + + def __init__(self): + if sys.platform == "win32": + raise HookError("tachyon hook is not supported on Windows") + + if sys.version_info < (3, 15): + raise HookError( + "tachyon hook requires Python 3.15+, " + "current version: %s.%s" + % (sys.version_info.major, sys.version_info.minor) + ) + + try: + import profiling.sampling # noqa: F401 + except ImportError: + raise HookError("profiling.sampling module not available") + + self.format = os.environ.get("PYPERF_TACHYON_FORMAT", "pstats") + self.mode = os.environ.get("PYPERF_TACHYON_MODE", "cpu") + self.interval = int(os.environ.get("PYPERF_TACHYON_INTERVAL", "1000")) + self.data_dir = os.environ.get("PYPERF_TACHYON_DATA_DIR", "") + self.all_threads = os.environ.get("PYPERF_TACHYON_ALL_THREADS", "") == "1" + self.native = os.environ.get("PYPERF_TACHYON_NATIVE", "") == "1" + self.async_aware = os.environ.get("PYPERF_TACHYON_ASYNC_AWARE", "") == "1" + + if self.format not in self.FORMAT_EXTENSIONS: + raise HookError( + "Invalid PYPERF_TACHYON_FORMAT: %s (valid: %s)" + % (self.format, ", ".join(sorted(self.FORMAT_EXTENSIONS))) + ) + + if self.mode not in self.VALID_MODES: + raise HookError( + "Invalid PYPERF_TACHYON_MODE: %s (valid: %s)" + % (self.mode, ", ".join(sorted(self.VALID_MODES))) + ) + + if self.format == "gecko" and "PYPERF_TACHYON_MODE" in os.environ: + raise HookError("--mode is not compatible with gecko output") + + if self.async_aware: + if self.native: + raise HookError("--async-aware is incompatible with --native") + if self.all_threads: + raise HookError("--async-aware is incompatible with --all-threads") + if self.mode in ("cpu", "gil"): + raise HookError("--async-aware is incompatible with --mode=cpu or --mode=gil") + + self._proc = None + self.output_paths = [] + + def __enter__(self): + if self._proc is not None: + self._stop_profiler() + + if self.data_dir: + os.makedirs(self.data_dir, exist_ok=True) + + ext = self.FORMAT_EXTENSIONS[self.format] + basename = f"tachyon.{uuid.uuid4()}" + if ext: + basename = f"{basename}.{ext}" + output_path = os.path.join(self.data_dir, basename) + + cmd = [ + sys.executable, + "-m", "profiling.sampling", + "attach", + str(os.getpid()), + "-i", str(self.interval), + ] + + if self.format == "gecko": + cmd.append("--gecko") + else: + cmd.append(f"--{self.format}") + cmd.extend(["--mode", self.mode]) + + cmd.extend(["-o", output_path]) + + if self.all_threads: + cmd.append("-a") + if self.native: + cmd.append("--native") + if self.async_aware: + cmd.append("--async-aware") + + self._proc = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + self.output_paths.append(output_path) + + def __exit__(self, _exc_type, _exc_value, _traceback): + self._stop_profiler() + + def _stop_profiler(self): + if not self._proc: + return + + if self._proc.poll() is None: + self._proc.send_signal(signal.SIGINT) + try: + self._proc.wait(timeout=30) + except subprocess.TimeoutExpired: + self._proc.terminate() + try: + self._proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self._proc.kill() + self._proc.wait() + + self._proc = None + + def teardown(self, metadata): + self._stop_profiler() + if self.output_paths: + metadata["tachyon_profiles"] = os.pathsep.join(self.output_paths) + metadata["tachyon_output_dir"] = self.data_dir or "." + metadata["tachyon_format"] = self.format + if self.format != "gecko": + metadata["tachyon_mode"] = self.mode + metadata["tachyon_interval"] = self.interval + metadata["tachyon_async_aware"] = int(self.async_aware) diff --git a/pyperf/_utils.py b/pyperf/_utils.py index 540e7eed..20beb5d5 100644 --- a/pyperf/_utils.py +++ b/pyperf/_utils.py @@ -259,6 +259,10 @@ def create_environ(inherit_environ, locale, copy_all): "PYTHONPATH", "PYTHON_CPU_COUNT", "PYTHON_GIL", # Pyperf specific variables "PYPERF_PERF_RECORD_DATA_DIR", "PYPERF_PERF_RECORD_EXTRA_OPTS", + "PYPERF_TACHYON_DATA_DIR", "PYPERF_TACHYON_FORMAT", + "PYPERF_TACHYON_MODE", "PYPERF_TACHYON_RATE", + "PYPERF_TACHYON_ALL_THREADS", "PYPERF_TACHYON_NATIVE", + "PYPERF_TACHYON_ASYNC_AWARE", ] if locale: copy_env.extend(('LANG', 'LC_ADDRESS', 'LC_ALL', 'LC_COLLATE', diff --git a/pyproject.toml b/pyproject.toml index 04efe88f..caa49942 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ pyperf = "pyperf.__main__:main" [project.entry-points."pyperf.hook"] perf_record = "pyperf._hooks:perf_record" pystats = "pyperf._hooks:pystats" +tachyon = "pyperf._hooks:tachyon" _test_hook = "pyperf._hooks:_test_hook" [tool.setuptools] From 1c0c255880b3c2f3d11e6b89ef81ac6be6a73e5d Mon Sep 17 00:00:00 2001 From: maurycy <5383+maurycy@users.noreply.github.com> Date: Sun, 25 Jan 2026 23:11:04 +0100 Subject: [PATCH 2/3] no way to keep up with the flags --- pyperf/_hooks.py | 99 ++++++------------------------------------------ pyperf/_utils.py | 5 +-- 2 files changed, 13 insertions(+), 91 deletions(-) diff --git a/pyperf/_hooks.py b/pyperf/_hooks.py index fdcf3c26..00937ce8 100644 --- a/pyperf/_hooks.py +++ b/pyperf/_hooks.py @@ -175,33 +175,19 @@ def exec_perf_cmd(self, cmd): class tachyon(HookBase): """Profile the benchmark using sampling profiler (Tachyon). - Profile data is written to the current directory by default, or - to the value of the `PYPERF_TACHYON_DATA_DIR` environment variable. + The value of the `PYPERF_TACHYON_EXTRA_OPTS` environment variable is + appended to the `profiling.sampling attach` command line. - Profile data files have a basename `tachyon..`. + This hook does not generate output filenames. Use -o or --output in + `PYPERF_TACHYON_EXTRA_OPTS` to control output. For most formats, -o can + point at an existing directory and the profiler will auto-generate a + filename inside it. Configuration environment variables: - PYPERF_TACHYON_DATA_DIR: Output directory (default: current) - PYPERF_TACHYON_FORMAT: Output format - pstats, collapsed, flamegraph, - gecko, heatmap, binary (default: pstats) - PYPERF_TACHYON_MODE: Profiling mode - wall, cpu, gil, exception - (default: cpu) - PYPERF_TACHYON_INTERVAL: Sampling interval in microseconds (default: 1000) - PYPERF_TACHYON_ALL_THREADS: Set to "1" to profile all threads - PYPERF_TACHYON_NATIVE: Set to "1" to include the native frames - PYPERF_TACHYON_ASYNC_AWARE: Set to "1" for async-aware profiling + PYPERF_TACHYON_EXTRA_OPTS: Extra arguments passed to + `python -m profiling.sampling attach`. """ - FORMAT_EXTENSIONS = { - "pstats": "pstats", - "collapsed": "txt", - "flamegraph": "html", - "gecko": "json", - "heatmap": "", - "binary": "bin", - } - VALID_MODES = {"wall", "cpu", "gil", "exception"} - def __init__(self): if sys.platform == "win32": raise HookError("tachyon hook is not supported on Windows") @@ -218,82 +204,27 @@ def __init__(self): except ImportError: raise HookError("profiling.sampling module not available") - self.format = os.environ.get("PYPERF_TACHYON_FORMAT", "pstats") - self.mode = os.environ.get("PYPERF_TACHYON_MODE", "cpu") - self.interval = int(os.environ.get("PYPERF_TACHYON_INTERVAL", "1000")) - self.data_dir = os.environ.get("PYPERF_TACHYON_DATA_DIR", "") - self.all_threads = os.environ.get("PYPERF_TACHYON_ALL_THREADS", "") == "1" - self.native = os.environ.get("PYPERF_TACHYON_NATIVE", "") == "1" - self.async_aware = os.environ.get("PYPERF_TACHYON_ASYNC_AWARE", "") == "1" - - if self.format not in self.FORMAT_EXTENSIONS: - raise HookError( - "Invalid PYPERF_TACHYON_FORMAT: %s (valid: %s)" - % (self.format, ", ".join(sorted(self.FORMAT_EXTENSIONS))) - ) - - if self.mode not in self.VALID_MODES: - raise HookError( - "Invalid PYPERF_TACHYON_MODE: %s (valid: %s)" - % (self.mode, ", ".join(sorted(self.VALID_MODES))) - ) - - if self.format == "gecko" and "PYPERF_TACHYON_MODE" in os.environ: - raise HookError("--mode is not compatible with gecko output") - - if self.async_aware: - if self.native: - raise HookError("--async-aware is incompatible with --native") - if self.all_threads: - raise HookError("--async-aware is incompatible with --all-threads") - if self.mode in ("cpu", "gil"): - raise HookError("--async-aware is incompatible with --mode=cpu or --mode=gil") + self.extra_opts = os.environ.get("PYPERF_TACHYON_EXTRA_OPTS", "") self._proc = None - self.output_paths = [] def __enter__(self): if self._proc is not None: self._stop_profiler() - if self.data_dir: - os.makedirs(self.data_dir, exist_ok=True) - - ext = self.FORMAT_EXTENSIONS[self.format] - basename = f"tachyon.{uuid.uuid4()}" - if ext: - basename = f"{basename}.{ext}" - output_path = os.path.join(self.data_dir, basename) - cmd = [ sys.executable, "-m", "profiling.sampling", "attach", str(os.getpid()), - "-i", str(self.interval), ] - - if self.format == "gecko": - cmd.append("--gecko") - else: - cmd.append(f"--{self.format}") - cmd.extend(["--mode", self.mode]) - - cmd.extend(["-o", output_path]) - - if self.all_threads: - cmd.append("-a") - if self.native: - cmd.append("--native") - if self.async_aware: - cmd.append("--async-aware") + cmd += shlex.split(self.extra_opts) self._proc = subprocess.Popen( cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) - self.output_paths.append(output_path) def __exit__(self, _exc_type, _exc_value, _traceback): self._stop_profiler() @@ -318,11 +249,5 @@ def _stop_profiler(self): def teardown(self, metadata): self._stop_profiler() - if self.output_paths: - metadata["tachyon_profiles"] = os.pathsep.join(self.output_paths) - metadata["tachyon_output_dir"] = self.data_dir or "." - metadata["tachyon_format"] = self.format - if self.format != "gecko": - metadata["tachyon_mode"] = self.mode - metadata["tachyon_interval"] = self.interval - metadata["tachyon_async_aware"] = int(self.async_aware) + if self.extra_opts: + metadata["tachyon_extra_opts"] = self.extra_opts diff --git a/pyperf/_utils.py b/pyperf/_utils.py index 20beb5d5..af1b75a7 100644 --- a/pyperf/_utils.py +++ b/pyperf/_utils.py @@ -259,10 +259,7 @@ def create_environ(inherit_environ, locale, copy_all): "PYTHONPATH", "PYTHON_CPU_COUNT", "PYTHON_GIL", # Pyperf specific variables "PYPERF_PERF_RECORD_DATA_DIR", "PYPERF_PERF_RECORD_EXTRA_OPTS", - "PYPERF_TACHYON_DATA_DIR", "PYPERF_TACHYON_FORMAT", - "PYPERF_TACHYON_MODE", "PYPERF_TACHYON_RATE", - "PYPERF_TACHYON_ALL_THREADS", "PYPERF_TACHYON_NATIVE", - "PYPERF_TACHYON_ASYNC_AWARE", + "PYPERF_TACHYON_EXTRA_OPTS", ] if locale: copy_env.extend(('LANG', 'LC_ADDRESS', 'LC_ALL', 'LC_COLLATE', From 5791a4eaccf78ebcfbfd9d2715fcbca8e380f2e8 Mon Sep 17 00:00:00 2001 From: maurycy <5383+maurycy@users.noreply.github.com> Date: Wed, 28 Jan 2026 15:03:32 +0100 Subject: [PATCH 3/3] s/PYPERF_TACHYON_EXTRA_OPTS/PYPERF_TACHYON_OPTS/g --- pyperf/_hooks.py | 8 ++++---- pyperf/_utils.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyperf/_hooks.py b/pyperf/_hooks.py index 00937ce8..9d910327 100644 --- a/pyperf/_hooks.py +++ b/pyperf/_hooks.py @@ -175,16 +175,16 @@ def exec_perf_cmd(self, cmd): class tachyon(HookBase): """Profile the benchmark using sampling profiler (Tachyon). - The value of the `PYPERF_TACHYON_EXTRA_OPTS` environment variable is + The value of the `PYPERF_TACHYON_OPTS` environment variable is appended to the `profiling.sampling attach` command line. This hook does not generate output filenames. Use -o or --output in - `PYPERF_TACHYON_EXTRA_OPTS` to control output. For most formats, -o can + `PYPERF_TACHYON_OPTS` to control output. For most formats, -o can point at an existing directory and the profiler will auto-generate a filename inside it. Configuration environment variables: - PYPERF_TACHYON_EXTRA_OPTS: Extra arguments passed to + PYPERF_TACHYON_OPTS: Extra arguments passed to `python -m profiling.sampling attach`. """ @@ -204,7 +204,7 @@ def __init__(self): except ImportError: raise HookError("profiling.sampling module not available") - self.extra_opts = os.environ.get("PYPERF_TACHYON_EXTRA_OPTS", "") + self.extra_opts = os.environ.get("PYPERF_TACHYON_OPTS", "") self._proc = None diff --git a/pyperf/_utils.py b/pyperf/_utils.py index af1b75a7..c8261522 100644 --- a/pyperf/_utils.py +++ b/pyperf/_utils.py @@ -259,7 +259,7 @@ def create_environ(inherit_environ, locale, copy_all): "PYTHONPATH", "PYTHON_CPU_COUNT", "PYTHON_GIL", # Pyperf specific variables "PYPERF_PERF_RECORD_DATA_DIR", "PYPERF_PERF_RECORD_EXTRA_OPTS", - "PYPERF_TACHYON_EXTRA_OPTS", + "PYPERF_TACHYON_OPTS", ] if locale: copy_env.extend(('LANG', 'LC_ADDRESS', 'LC_ALL', 'LC_COLLATE',