diff --git a/gprofiler/main.py b/gprofiler/main.py index 3cda1a33a..41ee07dd8 100644 --- a/gprofiler/main.py +++ b/gprofiler/main.py @@ -51,7 +51,12 @@ from gprofiler.profiler_state import ProfilerState from gprofiler.profilers.factory import get_profilers from gprofiler.profilers.profiler_base import NoopProfiler, ProcessProfilerBase, ProfilerInterface -from gprofiler.profilers.registry import get_profilers_registry +from gprofiler.profilers.registry import ( + ProfilerArgument, + get_runtime_possible_modes, + get_runtimes_registry, + get_sorted_profilers, +) from gprofiler.spark.sampler import SparkSampler from gprofiler.state import State, init_state from gprofiler.system_metrics import Metrics, NoopSystemMetricsMonitor, SystemMetricsMonitor, SystemMetricsMonitorBase @@ -824,7 +829,7 @@ def parse_cmd_args() -> configargparse.Namespace: if args.extract_resources and args.resources_dest is None: parser.error("Must provide --resources-dest when extract-resources") - if args.perf_dwarf_stack_size > 65528: + if args.perf_mode not in ("disabled", "none") and args.perf_dwarf_stack_size > 65528: parser.error("--perf-dwarf-stack-size maximum size is 65528") if args.profiling_mode == CPU_PROFILING_MODE and args.perf_mode in ("dwarf", "smart") and args.frequency > 100: @@ -840,29 +845,52 @@ def parse_cmd_args() -> configargparse.Namespace: def _add_profilers_arguments(parser: configargparse.ArgumentParser) -> None: - registry = get_profilers_registry() - for name, config in registry.items(): - arg_group = parser.add_argument_group(name) - mode_var = f"{name.lower()}_mode" + # add command-line arguments for each profiling runtime, but only for profilers that are working + # with current architecture. + runtimes_registry = get_runtimes_registry() + for runtime_class, runtime_config in runtimes_registry.items(): + runtime = runtime_config.runtime_name + runtime_possible_modes = get_runtime_possible_modes(runtime_class) + arg_group = parser.add_argument_group(runtime) + mode_var = f"{runtime.lower()}_mode" + if not runtime_possible_modes: + # if no mode is possible for this runtime, skip this runtime, and register it as disabled + # to overcome issue with Perf showing up on Windows + parser.add_argument( + f"--{runtime.lower()}-mode", + dest=mode_var, + default="disabled", + help=configargparse.SUPPRESS, + ) + continue + arg_group.add_argument( - f"--{name.lower()}-mode", + f"--{runtime.lower()}-mode", dest=mode_var, - default=config.default_mode, - help=config.profiler_mode_help, - choices=config.possible_modes, + default=runtime_config.default_mode, + help=runtime_config.mode_help, + choices=runtime_possible_modes, ) arg_group.add_argument( - f"--no-{name.lower()}", + f"--no-{runtime.lower()}", action="store_const", const="disabled", dest=mode_var, default=True, - help=config.disablement_help, + help=runtime_config.disablement_help, ) - for arg in config.profiler_args: + # add each available profiler's arguments and runtime common arguments + profiling_args: List[ProfilerArgument] = [] + profiling_args.extend(runtime_config.common_arguments) + for config in get_sorted_profilers(runtime_class): + profiling_args.extend(config.profiler_args) + + for arg in profiling_args: profiler_arg_kwargs = arg.get_dict() - name = profiler_arg_kwargs.pop("name") - arg_group.add_argument(name, **profiler_arg_kwargs) + # do not add parser entries for profiler internal arguments + if "internal" not in profiler_arg_kwargs: + name = profiler_arg_kwargs.pop("name") + arg_group.add_argument(name, **profiler_arg_kwargs) def verify_preconditions(args: configargparse.Namespace, processes_to_profile: Optional[List[Process]]) -> None: diff --git a/gprofiler/profilers/__init__.py b/gprofiler/profilers/__init__.py index 295a83f19..4d9602d45 100644 --- a/gprofiler/profilers/__init__.py +++ b/gprofiler/profilers/__init__.py @@ -1,17 +1,18 @@ # NOTE: Make sure to import any new process profilers to load it from gprofiler.platform import is_linux from gprofiler.profilers.dotnet import DotnetProfiler -from gprofiler.profilers.python import PythonProfiler +from gprofiler.profilers.python import PySpyProfiler if is_linux(): from gprofiler.profilers.java import JavaProfiler from gprofiler.profilers.perf import SystemProfiler from gprofiler.profilers.php import PHPSpyProfiler + from gprofiler.profilers.python_ebpf import PythonEbpfProfiler from gprofiler.profilers.ruby import RbSpyProfiler -__all__ = ["PythonProfiler", "DotnetProfiler"] +__all__ = ["PySpyProfiler", "DotnetProfiler"] if is_linux(): - __all__ += ["JavaProfiler", "PHPSpyProfiler", "RbSpyProfiler", "SystemProfiler"] + __all__ += ["JavaProfiler", "PHPSpyProfiler", "RbSpyProfiler", "SystemProfiler", "PythonEbpfProfiler"] del is_linux diff --git a/gprofiler/profilers/dotnet.py b/gprofiler/profilers/dotnet.py index f52a48a47..3e03e1d5a 100644 --- a/gprofiler/profilers/dotnet.py +++ b/gprofiler/profilers/dotnet.py @@ -19,7 +19,7 @@ from gprofiler.platform import is_windows from gprofiler.profiler_state import ProfilerState from gprofiler.profilers.profiler_base import ProcessProfilerBase -from gprofiler.profilers.registry import register_profiler +from gprofiler.profilers.registry import ProfilingRuntime, register_profiler, register_runtime from gprofiler.utils import pgrep_exe, pgrep_maps, random_prefix, removed_path, resource_path, run_process from gprofiler.utils.process import process_comm from gprofiler.utils.speedscope import load_speedscope_as_collapsed @@ -49,12 +49,17 @@ def make_application_metadata(self, process: Process) -> Dict[str, Any]: return metadata +@register_runtime("dotnet", default_mode="disabled") +class DotnetRuntime(ProfilingRuntime): + pass + + @register_profiler( "dotnet", + runtime_class=DotnetRuntime, possible_modes=["dotnet-trace", "disabled"], supported_archs=["x86_64", "aarch64"], supported_windows_archs=["AMD64"], - default_mode="disabled", supported_profiling_modes=["cpu"], ) class DotnetProfiler(ProcessProfilerBase): diff --git a/gprofiler/profilers/factory.py b/gprofiler/profilers/factory.py index 763a4d2cb..662638033 100644 --- a/gprofiler/profilers/factory.py +++ b/gprofiler/profilers/factory.py @@ -2,11 +2,9 @@ from typing import TYPE_CHECKING, Any, List, Tuple, Union from gprofiler.log import get_logger_adapter -from gprofiler.metadata.system_metadata import get_arch -from gprofiler.platform import is_windows from gprofiler.profilers.perf import SystemProfiler from gprofiler.profilers.profiler_base import NoopProfiler -from gprofiler.profilers.registry import get_profilers_registry +from gprofiler.profilers.registry import ProfilerConfig, get_runtimes_registry, get_sorted_profilers if TYPE_CHECKING: from gprofiler.gprofiler_types import UserArgs @@ -24,44 +22,62 @@ def get_profilers( process_profilers_instances: List["ProcessProfilerBase"] = [] system_profiler: Union["SystemProfiler", "NoopProfiler"] = NoopProfiler() - if profiling_mode != "none": - arch = get_arch() - for profiler_name, profiler_config in get_profilers_registry().items(): - lower_profiler_name = profiler_name.lower() - profiler_mode = user_args.get(f"{lower_profiler_name}_mode") - if profiler_mode in ("none", "disabled"): - continue - - supported_archs = ( - profiler_config.supported_windows_archs if is_windows() else profiler_config.supported_archs - ) - if arch not in supported_archs: - logger.warning(f"Disabling {profiler_name} because it doesn't support this architecture ({arch})") - continue + if profiling_mode == "none": + return system_profiler, process_profilers_instances - if profiling_mode not in profiler_config.supported_profiling_modes: + for runtime_class, runtime_config in get_runtimes_registry().items(): + runtime = runtime_config.runtime_name + runtime_args_prefix = runtime.lower() + runtime_mode = user_args.get(f"{runtime_args_prefix}_mode") + if runtime_mode in ProfilerConfig.DISABLED_MODES: + continue + # select configs supporting requested runtime_mode or all configs in order of preference + requested_configs: List[ProfilerConfig] = get_sorted_profilers(runtime_class) + if runtime_mode != ProfilerConfig.ENABLED_MODE: + requested_configs = [c for c in requested_configs if runtime_mode in c.get_active_modes()] + # select profilers that support this architecture and profiling mode + selected_configs: List[ProfilerConfig] = [] + for config in requested_configs: + profiler_name = config.profiler_name + if profiling_mode not in config.supported_profiling_modes: logger.warning( f"Disabling {profiler_name} because it doesn't support profiling mode {profiling_mode!r}" ) continue + selected_configs.append(config) + if not selected_configs: + logger.warning(f"Disabling {runtime} profiling because no profilers were selected") + continue + # create instances of selected profilers one by one, select first that is ready + ready_profiler = None + mode_var = f"{runtime.lower()}_mode" + runtime_arg_names: List[str] = [arg.dest for arg in runtime_config.common_arguments] + [mode_var] + for profiler_config in selected_configs: + profiler_name = profiler_config.profiler_name profiler_kwargs = profiler_init_kwargs.copy() + profiler_arg_names = [arg.dest for arg in profiler_config.profiler_args] for key, value in user_args.items(): - if key.startswith(lower_profiler_name) or key in COMMON_PROFILER_ARGUMENT_NAMES: + if key in profiler_arg_names or key in runtime_arg_names or key in COMMON_PROFILER_ARGUMENT_NAMES: profiler_kwargs[key] = value try: profiler_instance = profiler_config.profiler_class(**profiler_kwargs) + if profiler_instance.check_readiness(): + ready_profiler = profiler_instance + break except Exception: - logger.critical( - f"Couldn't create the {profiler_name} profiler, not continuing." - f" Run with --no-{profiler_name.lower()} to disable this profiler", - exc_info=True, - ) - sys.exit(1) - else: - if isinstance(profiler_instance, SystemProfiler): - system_profiler = profiler_instance - else: - process_profilers_instances.append(profiler_instance) - + if len(requested_configs) == 1: + logger.critical( + f"Couldn't create the {profiler_name} profiler for runtime {runtime}, not continuing." + f" Request different profiler for runtime with --{runtime_args_prefix}-mode, or disable" + f" {runtime} profiling with --{runtime_args_prefix}-mode=disabled to disable this profiler", + exc_info=True, + ) + sys.exit(1) + if isinstance(ready_profiler, SystemProfiler): + system_profiler = ready_profiler + elif ready_profiler is not None: + process_profilers_instances.append(ready_profiler) + else: + logger.warning(f"Disabling {runtime} profiling because no profilers were ready") return system_profiler, process_profilers_instances diff --git a/gprofiler/profilers/java.py b/gprofiler/profilers/java.py index 9bcc4cb67..cdf291178 100644 --- a/gprofiler/profilers/java.py +++ b/gprofiler/profilers/java.py @@ -67,7 +67,7 @@ from gprofiler.metadata.application_metadata import ApplicationMetadata from gprofiler.profiler_state import ProfilerState from gprofiler.profilers.profiler_base import SpawningProcessProfilerBase -from gprofiler.profilers.registry import ProfilerArgument, register_profiler +from gprofiler.profilers.registry import ProfilerArgument, ProfilingRuntime, register_profiler, register_runtime from gprofiler.utils import ( GPROFILER_DIRECTORY_NAME, TEMPORARY_STORAGE_PATH, @@ -785,10 +785,18 @@ def read_output(self) -> Optional[str]: raise +@register_runtime( + "Java", + default_mode="ap", +) +class JavaRuntime(ProfilingRuntime): + pass + + @register_profiler( "Java", + runtime_class=JavaRuntime, possible_modes=["ap", "disabled"], - default_mode="ap", supported_archs=["x86_64", "aarch64"], profiler_arguments=[ ProfilerArgument( diff --git a/gprofiler/profilers/perf.py b/gprofiler/profilers/perf.py index 7cdd954ad..d98f10d7a 100644 --- a/gprofiler/profilers/perf.py +++ b/gprofiler/profilers/perf.py @@ -35,7 +35,13 @@ from gprofiler.profiler_state import ProfilerState from gprofiler.profilers.node import clean_up_node_maps, generate_map_for_node_processes, get_node_processes from gprofiler.profilers.profiler_base import ProfilerBase -from gprofiler.profilers.registry import ProfilerArgument, register_profiler +from gprofiler.profilers.registry import ( + InternalArgument, + ProfilerArgument, + ProfilingRuntime, + register_profiler, + register_runtime, +) from gprofiler.utils import ( reap_process, remove_files_by_prefix, @@ -374,15 +380,33 @@ def wait_and_script(self) -> str: remove_path(inject_data, missing_ok=True) -@register_profiler( +@register_runtime( "Perf", - possible_modes=["fp", "dwarf", "smart", "disabled"], default_mode="fp", - supported_archs=["x86_64", "aarch64"], - profiler_mode_argument_help="Run perf with either FP (Frame Pointers), DWARF, or run both and intelligently merge" + mode_help="Run perf with either FP (Frame Pointers), DWARF, or run both and intelligently merge" " them by choosing the best result per process. If 'disabled' is chosen, do not invoke" " 'perf' at all. The output, in that case, is the concatenation of the results from all" - " of the runtime profilers. Defaults to 'smart'.", + " of the runtime profilers. Defaults to 'fp'.", + common_arguments=[ + ProfilerArgument( + "--perf-no-memory-restart", + help="Disable checking if perf used memory exceeds threshold and restarting perf", + action="store_false", + dest="perf_memory_restart", + ), + ], + disablement_help="Disable the global perf of processes," + " and instead only concatenate runtime-specific profilers results", +) +class PerfRuntime(ProfilingRuntime): + pass + + +@register_profiler( + "Perf", + runtime_class=PerfRuntime, + possible_modes=["fp", "dwarf", "smart", "disabled"], + supported_archs=["x86_64", "aarch64"], profiler_arguments=[ ProfilerArgument( "--perf-dwarf-stack-size", @@ -392,15 +416,9 @@ def wait_and_script(self) -> str: default=DEFAULT_PERF_DWARF_STACK_SIZE, dest="perf_dwarf_stack_size", ), - ProfilerArgument( - "--perf-no-memory-restart", - help="Disable checking if perf used memory exceeds threshold and restarting perf", - action="store_false", - dest="perf_memory_restart", - ), + InternalArgument(dest="perf_inject"), + InternalArgument(dest="perf_node_attach"), ], - disablement_help="Disable the global perf of processes," - " and instead only concatenate runtime-specific profilers results", supported_profiling_modes=["cpu"], ) class SystemProfiler(ProfilerBase): diff --git a/gprofiler/profilers/php.py b/gprofiler/profilers/php.py index 739fbb16f..e49cfa7c7 100644 --- a/gprofiler/profilers/php.py +++ b/gprofiler/profilers/php.py @@ -18,7 +18,7 @@ from gprofiler.log import get_logger_adapter from gprofiler.profiler_state import ProfilerState from gprofiler.profilers.profiler_base import ProfilerBase -from gprofiler.profilers.registry import ProfilerArgument, register_profiler +from gprofiler.profilers.registry import ProfilerArgument, ProfilingRuntime, register_profiler, register_runtime from gprofiler.utils import random_prefix, reap_process, resource_path, start_process, wait_event logger = get_logger_adapter(__name__) @@ -26,11 +26,16 @@ DEFAULT_PROCESS_FILTER = "php-fpm" +@register_runtime("PHP", default_mode="disabled") +class PHPRuntime(ProfilingRuntime): + pass + + @register_profiler( "PHP", + runtime_class=PHPRuntime, possible_modes=["phpspy", "disabled"], supported_archs=["x86_64", "aarch64"], - default_mode="disabled", profiler_arguments=[ ProfilerArgument( "--php-proc-filter", diff --git a/gprofiler/profilers/profiler_base.py b/gprofiler/profilers/profiler_base.py index 2c26ffe48..8762eb353 100644 --- a/gprofiler/profilers/profiler_base.py +++ b/gprofiler/profilers/profiler_base.py @@ -48,6 +48,12 @@ def snapshot(self) -> ProcessToProfileData: """ raise NotImplementedError + def check_readiness(self) -> bool: + """ + Check that profiler is ready for use on current platform. + """ + raise NotImplementedError + def stop(self) -> None: pass @@ -99,6 +105,9 @@ def __init__( f"profiling mode: {profiler_state.profiling_mode}" ) + def check_readiness(self) -> bool: + return True + class NoopProfiler(ProfilerInterface): """ @@ -108,6 +117,9 @@ class NoopProfiler(ProfilerInterface): def snapshot(self) -> ProcessToProfileData: return {} + def check_readiness(self) -> bool: + return True + @classmethod def is_noop_profiler(cls, profile_instance: ProfilerInterface) -> bool: return isinstance(profile_instance, cls) diff --git a/gprofiler/profilers/python.py b/gprofiler/profilers/python.py index 5d29c611e..7612b2c04 100644 --- a/gprofiler/profilers/python.py +++ b/gprofiler/profilers/python.py @@ -27,28 +27,17 @@ ProcessStoppedException, StopEventSetException, ) -from gprofiler.gprofiler_types import ( - ProcessToProfileData, - ProcessToStackSampleCounters, - ProfileData, - StackToSampleCount, - nonnegative_integer, -) +from gprofiler.gprofiler_types import ProcessToStackSampleCounters, ProfileData, StackToSampleCount from gprofiler.log import get_logger_adapter from gprofiler.metadata import application_identifiers from gprofiler.metadata.application_metadata import ApplicationMetadata from gprofiler.metadata.py_module_version import get_modules_versions -from gprofiler.metadata.system_metadata import get_arch from gprofiler.platform import is_linux, is_windows from gprofiler.profiler_state import ProfilerState -from gprofiler.profilers.profiler_base import ProfilerInterface, SpawningProcessProfilerBase -from gprofiler.profilers.registry import ProfilerArgument, register_profiler -from gprofiler.utils.collapsed_format import parse_one_collapsed_file - -if is_linux(): - from gprofiler.profilers.python_ebpf import PythonEbpfProfiler, PythonEbpfError - +from gprofiler.profilers.profiler_base import SpawningProcessProfilerBase +from gprofiler.profilers.registry import ProfilerArgument, ProfilingRuntime, register_profiler, register_runtime from gprofiler.utils import pgrep_exe, pgrep_maps, random_prefix, removed_path, resource_path, run_process +from gprofiler.utils.collapsed_format import parse_one_collapsed_file from gprofiler.utils.process import process_comm, search_proc_maps logger = get_logger_adapter(__name__) @@ -163,6 +152,40 @@ def make_application_metadata(self, process: Process) -> Dict[str, Any]: return metadata +@register_runtime( + "Python", + default_mode="auto", + mode_help="Select the Python profiling mode: auto (try PyPerf, resort to py-spy if it fails), " + "pyspy (always use py-spy), pyperf (always use PyPerf, and avoid py-spy even if it fails)" + " or disabled (no runtime profilers for Python).", + common_arguments=[ + # TODO should be prefixed with --python- + ProfilerArgument( + "--no-python-versions", + dest="python_add_versions", + action="store_false", + default=True, + help="Don't add version information to Python frames. If not set, frames from packages are displayed with " + "the name of the package and its version, and frames from Python built-in modules are displayed with " + "Python's full version.", + ), + ], +) +class PythonRuntime(ProfilingRuntime): + pass + + +@register_profiler( + "PySpy", + runtime_class=PythonRuntime, + possible_modes=["auto", "pyspy", "py-spy"], + # we build pyspy for both + supported_archs=["x86_64", "aarch64"], + supported_windows_archs=["AMD64"], + # no specific arguments for PySpy besides the common args from runtime + profiler_arguments=[], + supported_profiling_modes=["cpu"], +) class PySpyProfiler(SpawningProcessProfilerBase): MAX_FREQUENCY = 50 _EXTRA_TIMEOUT = 10 # give py-spy some seconds to run (added to the duration) @@ -173,10 +196,14 @@ def __init__( duration: int, profiler_state: ProfilerState, *, - add_versions: bool, + python_mode: str, + python_add_versions: bool, ): super().__init__(frequency, duration, profiler_state) - self.add_versions = add_versions + if python_mode == "py-spy": + python_mode = "pyspy" + assert python_mode in ("auto", "pyspy"), f"unexpected mode: {python_mode}" + self.add_versions = python_add_versions self._metadata = PythonMetadata(self._profiler_state.stop_event) def _make_command(self, pid: int, output_path: str, duration: int) -> List[str]: @@ -289,150 +316,5 @@ def _should_skip_process(self, process: Process) -> bool: return False - -@register_profiler( - "Python", - # py-spy is like pyspy, it's confusing and I mix between them - possible_modes=["auto", "pyperf", "pyspy", "py-spy", "disabled"], - default_mode="auto", - # we build pyspy for both, pyperf only for x86_64. - # TODO: this inconsistency shows that py-spy and pyperf should have different Profiler classes, - # we should split them in the future. - supported_archs=["x86_64", "aarch64"], - supported_windows_archs=["AMD64"], - profiler_mode_argument_help="Select the Python profiling mode: auto (try PyPerf, resort to py-spy if it fails), " - "pyspy (always use py-spy), pyperf (always use PyPerf, and avoid py-spy even if it fails)" - " or disabled (no runtime profilers for Python).", - profiler_arguments=[ - # TODO should be prefixed with --python- - ProfilerArgument( - "--no-python-versions", - dest="python_add_versions", - action="store_false", - default=True, - help="Don't add version information to Python frames. If not set, frames from packages are displayed with " - "the name of the package and its version, and frames from Python built-in modules are displayed with " - "Python's full version.", - ), - # TODO should be prefixed with --python- - ProfilerArgument( - "--pyperf-user-stacks-pages", - dest="python_pyperf_user_stacks_pages", - default=None, - type=nonnegative_integer, - help="Number of user stack-pages that PyPerf will collect, this controls the maximum stack depth of native " - "user frames. Pass 0 to disable user native stacks altogether.", - ), - ProfilerArgument( - "--python-pyperf-verbose", - dest="python_pyperf_verbose", - action="store_true", - help="Enable PyPerf in verbose mode (max verbosity)", - ), - ], - supported_profiling_modes=["cpu"], -) -class PythonProfiler(ProfilerInterface): - """ - Controls PySpyProfiler & PythonEbpfProfiler as needed, providing a clean interface - to GProfiler. - """ - - def __init__( - self, - frequency: int, - duration: int, - profiler_state: ProfilerState, - python_mode: str, - python_add_versions: bool, - python_pyperf_user_stacks_pages: Optional[int], - python_pyperf_verbose: bool, - ): - if python_mode == "py-spy": - python_mode = "pyspy" - - assert python_mode in ("auto", "pyperf", "pyspy"), f"unexpected mode: {python_mode}" - - if get_arch() != "x86_64" or is_windows(): - if python_mode == "pyperf": - raise Exception(f"PyPerf is supported only on x86_64 (and not on this arch {get_arch()})") - python_mode = "pyspy" - - if python_mode in ("auto", "pyperf"): - self._ebpf_profiler = self._create_ebpf_profiler( - frequency, - duration, - profiler_state, - python_add_versions, - python_pyperf_user_stacks_pages, - python_pyperf_verbose, - ) - else: - self._ebpf_profiler = None - - if python_mode == "pyspy" or (self._ebpf_profiler is None and python_mode == "auto"): - self._pyspy_profiler: Optional[PySpyProfiler] = PySpyProfiler( - frequency, - duration, - profiler_state, - add_versions=python_add_versions, - ) - else: - self._pyspy_profiler = None - - if is_linux(): - - def _create_ebpf_profiler( - self, - frequency: int, - duration: int, - profiler_state: ProfilerState, - add_versions: bool, - user_stacks_pages: Optional[int], - verbose: bool, - ) -> Optional[PythonEbpfProfiler]: - try: - profiler = PythonEbpfProfiler( - frequency, - duration, - profiler_state, - add_versions=add_versions, - user_stacks_pages=user_stacks_pages, - verbose=verbose, - ) - profiler.test() - return profiler - except Exception as e: - logger.debug(f"eBPF profiler error: {str(e)}") - logger.info("Python eBPF profiler initialization failed") - return None - - def start(self) -> None: - if self._ebpf_profiler is not None: - self._ebpf_profiler.start() - elif self._pyspy_profiler is not None: - self._pyspy_profiler.start() - - def snapshot(self) -> ProcessToProfileData: - if self._ebpf_profiler is not None: - try: - return self._ebpf_profiler.snapshot() - except PythonEbpfError as e: - assert not self._ebpf_profiler.is_running() - logger.warning( - "Python eBPF profiler failed, restarting PyPerf...", - pyperf_exit_code=e.returncode, - pyperf_stdout=e.stdout, - pyperf_stderr=e.stderr, - ) - self._ebpf_profiler.start() - return {} # empty this round - else: - assert self._pyspy_profiler is not None - return self._pyspy_profiler.snapshot() - - def stop(self) -> None: - if self._ebpf_profiler is not None: - self._ebpf_profiler.stop() - elif self._pyspy_profiler is not None: - self._pyspy_profiler.stop() + def check_readiness(self) -> bool: + return True diff --git a/gprofiler/profilers/python_ebpf.py b/gprofiler/profilers/python_ebpf.py index a331b97df..7d1070e7f 100644 --- a/gprofiler/profilers/python_ebpf.py +++ b/gprofiler/profilers/python_ebpf.py @@ -14,12 +14,14 @@ from psutil import NoSuchProcess, Process from gprofiler.exceptions import CalledProcessError, StopEventSetException -from gprofiler.gprofiler_types import ProcessToProfileData, ProfileData +from gprofiler.gprofiler_types import ProcessToProfileData, ProfileData, nonnegative_integer from gprofiler.log import get_logger_adapter from gprofiler.metadata import application_identifiers from gprofiler.profiler_state import ProfilerState from gprofiler.profilers import python from gprofiler.profilers.profiler_base import ProfilerBase +from gprofiler.profilers.python import PythonRuntime +from gprofiler.profilers.registry import ProfilerArgument, register_profiler from gprofiler.utils import ( poll_process, random_prefix, @@ -41,6 +43,31 @@ class PythonEbpfError(CalledProcessError): """ +@register_profiler( + "PyPerf", + runtime_class=PythonRuntime, + is_preferred=True, + possible_modes=["auto", "pyperf"], + supported_archs=["x86_64"], + profiler_arguments=[ + # TODO should be prefixed with --python- + ProfilerArgument( + "--pyperf-user-stacks-pages", + dest="python_pyperf_user_stacks_pages", + default=None, + type=nonnegative_integer, + help="Number of user stack-pages that PyPerf will collect, this controls the maximum stack depth of native " + "user frames. Pass 0 to disable user native stacks altogether.", + ), + ProfilerArgument( + "--python-pyperf-verbose", + dest="python_pyperf_verbose", + action="store_true", + help="Enable PyPerf in verbose mode (max verbosity)", + ), + ], + supported_profiling_modes=["cpu"], +) class PythonEbpfProfiler(ProfilerBase): MAX_FREQUENCY = 1000 PYPERF_RESOURCE = "python/pyperf/PyPerf" @@ -61,18 +88,20 @@ def __init__( duration: int, profiler_state: ProfilerState, *, - add_versions: bool, - user_stacks_pages: Optional[int] = None, - verbose: bool, + python_mode: str, + python_add_versions: bool, + python_pyperf_user_stacks_pages: Optional[int] = None, + python_pyperf_verbose: bool, ): super().__init__(frequency, duration, profiler_state) + assert python_mode in ("auto", "pyperf"), f"unexpected mode: {python_mode}" self.process: Optional[Popen] = None self.output_path = Path(self._profiler_state.storage_dir) / f"pyperf.{random_prefix()}.col" - self.add_versions = add_versions - self.user_stacks_pages = user_stacks_pages + self.add_versions = python_add_versions + self.user_stacks_pages = python_pyperf_user_stacks_pages self._kernel_offsets: Dict[str, int] = {} self._metadata = python.PythonMetadata(self._profiler_state.stop_event) - self._verbose = verbose + self._verbose = python_pyperf_verbose @classmethod def _check_output(cls, process: Popen, output_path: Path) -> None: @@ -230,7 +259,16 @@ def _dump(self) -> Path: ), process.args # mypy raise PythonEbpfError(exit_status, process.args, stdout, stderr) - def snapshot(self) -> ProcessToProfileData: + def check_readiness(self) -> bool: + try: + self.test() + return True + except Exception as e: + logger.debug(f"eBPF profiler error: {str(e)}") + logger.info("Python eBPF profiler cannot be used on this system") + return False + + def _ebpf_snapshot(self) -> ProcessToProfileData: if self._profiler_state.stop_event.wait(self._duration): raise StopEventSetException() collapsed_path = self._dump() @@ -262,6 +300,20 @@ def snapshot(self) -> ProcessToProfileData: profiles[pid] = ProfileData(parsed[pid], appid, app_metadata, container_name) return profiles + def snapshot(self) -> ProcessToProfileData: + try: + return self._ebpf_snapshot() + except PythonEbpfError as e: + assert not self.is_running() + logger.warning( + "Python eBPF profiler failed, restarting PyPerf...", + pyperf_exit_code=e.returncode, + pyperf_stdout=e.stdout, + pyperf_stderr=e.stderr, + ) + self.start() + return {} # empty this round + def _terminate(self) -> Tuple[Optional[int], str, str]: if self.is_running(): assert self.process is not None # for mypy diff --git a/gprofiler/profilers/registry.py b/gprofiler/profilers/registry.py index 9cba93761..248d28af3 100644 --- a/gprofiler/profilers/registry.py +++ b/gprofiler/profilers/registry.py @@ -1,105 +1,210 @@ -from typing import Any, Callable, Dict, List, Optional, Sequence, Type, Union +from collections import defaultdict +from dataclasses import asdict, dataclass +from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Type, Union +from gprofiler.metadata.system_metadata import get_arch +from gprofiler.platform import is_windows + +@dataclass class ProfilerArgument: - # TODO convert to a dataclass - def __init__( - self, - name: str, - dest: str, - help: Optional[str] = None, - default: Any = None, - action: Optional[str] = None, - choices: Sequence[Any] = None, - type: Union[Type, Callable[[str], Any]] = None, - metavar: str = None, - const: Any = None, - nargs: str = None, - ): - self.name = name - self.dest = dest - self.help = help - self.default = default - self.action = action - self.choices = choices - self.type = type - self.metavar = metavar - self.const = const - self.nargs = nargs + name: str + dest: str + help: Optional[str] = None + default: Any = None + action: Optional[str] = None + choices: Union[Sequence[Any], None] = None + type: Union[Type, Callable[[str], Any], None] = None + metavar: Optional[str] = None + const: Any = None + nargs: Optional[str] = None def get_dict(self) -> Dict[str, Any]: - return {key: value for key, value in self.__dict__.items() if value is not None} + return {key: value for key, value in asdict(self).items() if value is not None} + + +@dataclass +class InternalArgument(ProfilerArgument): + """ + Represents arguments internal to profiler, provided only by initialization code not on command line. + Name should be empty and only dest field is meaningful, as it names the argument expected by profiler instance. + """ + + name: str = "" + dest: str = "" + internal: Optional[bool] = True + + def __post_init__(self) -> None: + assert ( + set(self.get_dict()) == {"dest", "name", "internal"} and self.name == "" + ), "InternalArgument doesn't use any other fields than dest" + + +class ProfilingRuntime: + pass + + +@dataclass +class RuntimeConfig: + runtime_name: str + default_mode: str + mode_help: Optional[str] + disablement_help: Optional[str] + common_arguments: List[ProfilerArgument] class ProfilerConfig: def __init__( self, - profiler_mode_help: Optional[str], - disablement_help: Optional[str], + profiler_name: str, + runtime_class: Type[ProfilingRuntime], + is_preferred: bool, profiler_class: Any, possible_modes: List[str], supported_archs: List[str], supported_profiling_modes: List[str], supported_windows_archs: List[str] = None, - default_mode: str = "enabled", arguments: List[ProfilerArgument] = None, ) -> None: - self.profiler_mode_help = profiler_mode_help + self.profiler_name = profiler_name + self.runtime_class = runtime_class + self.is_preferred = is_preferred self.possible_modes = possible_modes self.supported_archs = supported_archs self.supported_windows_archs = supported_windows_archs if supported_windows_archs is not None else [] - self.default_mode = default_mode self.profiler_args: List[ProfilerArgument] = arguments if arguments is not None else [] - self.disablement_help = disablement_help self.profiler_class = profiler_class self.supported_profiling_modes = supported_profiling_modes + ENABLED_MODE: str = "enabled" + DISABLED_MODES: List[str] = ["disabled", "none"] -profilers_config: Dict[str, ProfilerConfig] = {} + def get_active_modes(self) -> List[str]: + return [ + mode + for mode in self.possible_modes + if mode not in ProfilerConfig.DISABLED_MODES and mode != ProfilerConfig.ENABLED_MODE + ] + + def get_supported_archs(self) -> List[str]: + return self.supported_windows_archs if is_windows() else self.supported_archs + + +runtimes_config: Dict[Type[ProfilingRuntime], RuntimeConfig] = {} +profilers_config: Dict[Type[ProfilingRuntime], List[ProfilerConfig]] = defaultdict(list) + + +def register_runtime( + runtime_name: str, + default_mode: str = "enabled", + mode_help: Optional[str] = None, + disablement_help: Optional[str] = None, + common_arguments: List[ProfilerArgument] = None, +) -> Any: + if mode_help is None: + mode_help = ( + f"Choose the mode for profiling {runtime_name} processes. '{default_mode}'" + f" to profile them with the default method, or 'disabled' to disable {runtime_name}-specific profiling" + ) + if disablement_help is None: + disablement_help = f"Disable the runtime-profiling of {runtime_name} processes" + + def runtime_decorator(runtime_class: Type[ProfilingRuntime]) -> Any: + assert runtime_class not in runtimes_config, f"Runtime {runtime_name} is already registered" + assert all( + arg.dest.startswith(runtime_name.lower()) for arg in common_arguments or [] + ), f"{runtime_name}: Runtime common args dest must be prefixed with the runtime name" + + runtimes_config[runtime_class] = RuntimeConfig( + runtime_name, + default_mode, + mode_help, + disablement_help, + common_arguments if common_arguments is not None else [], + ) + return runtime_class + + return runtime_decorator def register_profiler( profiler_name: str, - default_mode: str, + runtime_class: Type[ProfilingRuntime], possible_modes: List[str], supported_archs: List[str], supported_profiling_modes: List[str], + is_preferred: bool = False, supported_windows_archs: Optional[List[str]] = None, - profiler_mode_argument_help: Optional[str] = None, profiler_arguments: Optional[List[ProfilerArgument]] = None, - disablement_help: Optional[str] = None, ) -> Any: - if profiler_mode_argument_help is None: - profiler_mode_argument_help = ( - f"Choose the mode for profiling {profiler_name} processes. '{default_mode}'" - f" to profile them with the default method, or 'disabled' to disable {profiler_name}-specific profiling" - ) # Add the legacy "none" value, which is replaced by "disabled" possible_modes.append("none") - if disablement_help is None: - disablement_help = f"Disable the runtime-profiling of {profiler_name} processes" def profiler_decorator(profiler_class: Any) -> Any: - assert profiler_name not in profilers_config, f"{profiler_name} is already registered!" + assert profiler_name is not None, "Profiler name must be defined" + assert ( + runtime_class in runtimes_config + ), f"Profiler {profiler_name} refers to runtime {runtime_class}, which is not registered." + runtime_name = runtimes_config[runtime_class].runtime_name + assert profiler_name not in ( + config.profiler_name for profilers in profilers_config.values() for config in profilers + ), f"{profiler_name} is already registered!" assert all( - arg.dest.startswith(profiler_name.lower()) for arg in profiler_arguments or [] - ), f"{profiler_name}: Profiler args dest must be prefixed with the profiler name" - profilers_config[profiler_name] = ProfilerConfig( - profiler_mode_argument_help, - disablement_help, - profiler_class, - possible_modes, - supported_archs, - supported_profiling_modes, - supported_windows_archs, - default_mode, - profiler_arguments, - ) + arg.dest.startswith(runtime_name.lower()) for arg in profiler_arguments or [] + ), f"{profiler_name}: Profiler args dest must be prefixed with the profiler runtime name" + profilers_config[runtime_class] += [ + ProfilerConfig( + profiler_name, + runtime_class, + is_preferred, + profiler_class, + possible_modes, + supported_archs, + supported_profiling_modes, + supported_windows_archs, + profiler_arguments, + ) + ] profiler_class.name = profiler_name return profiler_class return profiler_decorator -def get_profilers_registry() -> Dict[str, ProfilerConfig]: +def get_runtimes_registry() -> Dict[Type[ProfilingRuntime], RuntimeConfig]: + return runtimes_config + + +def get_profilers_registry() -> Dict[Type[ProfilingRuntime], List[ProfilerConfig]]: return profilers_config + + +def get_profilers_by_name() -> Dict[str, ProfilerConfig]: + return {config.profiler_name: config for configs in profilers_config.values() for config in configs} + + +def get_runtime_possible_modes(runtime_class: Type[ProfilingRuntime]) -> List[str]: + """ + Get profiler modes supported for given runtime and available for current architecture. + """ + arch = get_arch() + added_modes: Set[str] = set() + for config in (c for c in profilers_config[runtime_class] if arch in c.get_supported_archs()): + added_modes.update(config.get_active_modes()) + if not added_modes: + return [] + initial_modes = [ProfilerConfig.ENABLED_MODE] if len(profilers_config[runtime_class]) > 1 else [] + return initial_modes + sorted(added_modes) + ProfilerConfig.DISABLED_MODES + + +def get_sorted_profilers(runtime_class: Type[ProfilingRuntime]) -> List[ProfilerConfig]: + """ + Get all profiler configs registered for given runtime filtered for current architecture and sorted by preference. + """ + arch = get_arch() + profiler_configs = sorted( + (c for c in profilers_config[runtime_class] if arch in c.get_supported_archs()), + key=lambda c: (c.is_preferred, c.profiler_name), + reverse=True, + ) + return profiler_configs diff --git a/gprofiler/profilers/ruby.py b/gprofiler/profilers/ruby.py index ed4baf5f7..1befeeea0 100644 --- a/gprofiler/profilers/ruby.py +++ b/gprofiler/profilers/ruby.py @@ -20,7 +20,7 @@ from gprofiler.metadata.application_metadata import ApplicationMetadata from gprofiler.profiler_state import ProfilerState from gprofiler.profilers.profiler_base import SpawningProcessProfilerBase -from gprofiler.profilers.registry import register_profiler +from gprofiler.profilers.registry import ProfilingRuntime, register_profiler, register_runtime from gprofiler.utils import pgrep_maps, random_prefix, removed_path, resource_path, run_process from gprofiler.utils.collapsed_format import parse_one_collapsed_file from gprofiler.utils.process import process_comm, search_proc_maps @@ -54,11 +54,16 @@ def make_application_metadata(self, process: Process) -> Dict[str, Any]: return metadata +@register_runtime("Ruby", default_mode="rbspy") +class RubyRuntime(ProfilingRuntime): + pass + + @register_profiler( "Ruby", + runtime_class=RubyRuntime, possible_modes=["rbspy", "disabled"], supported_archs=["x86_64", "aarch64"], - default_mode="rbspy", supported_profiling_modes=["cpu"], ) class RbSpyProfiler(SpawningProcessProfilerBase): diff --git a/tests/test_python.py b/tests/test_python.py index 4321b76b5..e9069bc02 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -3,20 +3,30 @@ # Licensed under the AGPL3 License. See LICENSE.md in the project root for license information. # import os +from contextlib import contextmanager +from pathlib import Path +from typing import List import psutil import pytest +from docker import DockerClient +from docker.models.containers import Container +from docker.models.images import Image from granulate_utils.linux.process import is_musl from gprofiler.profiler_state import ProfilerState -from gprofiler.profilers.python import PythonProfiler +from gprofiler.profilers.python import PySpyProfiler +from gprofiler.profilers.python_ebpf import PythonEbpfProfiler from tests.conftest import AssertInCollapsed from tests.utils import ( assert_function_in_collapsed, is_aarch64, is_pattern_in_collapsed, + make_python_profiler, snapshot_pid_collapsed, snapshot_pid_profile, + start_gprofiler_in_container_for_one_session, + wait_for_log, ) @@ -25,12 +35,22 @@ def runtime() -> str: return "python" +@contextmanager +def stop_container_after_use(container: Container) -> Container: + try: + yield container + finally: + container.stop() + + @pytest.mark.parametrize("in_container", [True]) @pytest.mark.parametrize("application_image_tag", ["libpython"]) +@pytest.mark.parametrize("profiler_type", ["pyspy"]) def test_python_select_by_libpython( application_pid: int, assert_collapsed: AssertInCollapsed, profiler_state: ProfilerState, + profiler_type: str, ) -> None: """ Tests that profiling of processes running Python, whose basename(readlink("/proc/pid/exe")) isn't "python" @@ -38,7 +58,7 @@ def test_python_select_by_libpython( We expect to select these because they have "libpython" in their "/proc/pid/maps". This test runs a Python named "shmython". """ - with PythonProfiler(1000, 1, profiler_state, "pyspy", True, None, False) as profiler: + with make_python_profiler(1000, 1, profiler_state, profiler_type, True, None, False) as profiler: process_collapsed = snapshot_pid_collapsed(profiler, application_pid) assert_collapsed(process_collapsed) assert all(stack.startswith("shmython") for stack in process_collapsed.keys()) @@ -98,7 +118,7 @@ def test_python_matrix( if python_version in ["3.7", "3.8", "3.9", "3.10", "3.11"] and profiler_type == "py-spy" and libc == "musl": pytest.xfail("This combination fails, see https://github.com/Granulate/gprofiler/issues/714") - with PythonProfiler(1000, 2, profiler_state, profiler_type, True, None, False) as profiler: + with make_python_profiler(1000, 2, profiler_state, profiler_type, True, None, False) as profiler: profile = snapshot_pid_profile(profiler, application_pid) collapsed = profile.stacks @@ -155,7 +175,7 @@ def test_dso_name_in_pyperf_profile( "PyPerf doesn't support aarch64 architecture, see https://github.com/Granulate/gprofiler/issues/499" ) - with PythonProfiler(1000, 2, profiler_state, profiler_type, True, None, False) as profiler: + with make_python_profiler(1000, 2, profiler_state, profiler_type, True, None, False) as profiler: profile = snapshot_pid_profile(profiler, application_pid) python_version, _, _ = application_image_tag.split("-") interpreter_frame = "PyEval_EvalFrameEx" if python_version == "2.7" else "_PyEval_EvalFrameDefault" @@ -165,3 +185,45 @@ def test_dso_name_in_pyperf_profile( assert insert_dso_name == is_pattern_in_collapsed( rf"{interpreter_frame} \(.+?/libpython{python_version}.*?\.so.*?\)_\[pn\]", collapsed ) + + +@pytest.mark.parametrize( + "runtime,profiler_type,profiler_class_name", + [ + ("python", "py-spy", PySpyProfiler.__name__), + ("python", "pyperf", PythonEbpfProfiler.__name__), + ("python", "auto", PythonEbpfProfiler.__name__ if not is_aarch64() else PySpyProfiler.__name__), + ("python", "enabled", PythonEbpfProfiler.__name__ if not is_aarch64() else PySpyProfiler.__name__), + ], +) +def test_select_specific_python_profiler( + docker_client: DockerClient, + gprofiler_docker_image: Image, + output_directory: Path, + output_collapsed: Path, + runtime_specific_args: List[str], + profiler_flags: List[str], + profiler_type: str, + profiler_class_name: str, +) -> None: + """ + Test that correct profiler class is selected as given by --python-mode argument. + """ + if profiler_type == "pyperf" and is_aarch64(): + pytest.xfail("PyPerf doesn't run on Aarch64 - https://github.com/Granulate/gprofiler/issues/499") + elif profiler_type == "enabled": + # make sure the default behavior, with implicit enabled mode leads to auto selection + profiler_flags.remove(f"--python-mode={profiler_type}") + profiler_flags.extend(["--no-perf", "--disable-metadata-collection"]) + with stop_container_after_use( + start_gprofiler_in_container_for_one_session( + docker_client, + gprofiler_docker_image, + output_directory, + output_collapsed, + runtime_specific_args, + profiler_flags, + ) + ) as gprofiler: + wait_for_log(gprofiler, "gProfiler initialized and ready to start profiling", 0, timeout=30) + assert f"Initialized {profiler_class_name}".encode() in gprofiler.logs() diff --git a/tests/test_runtime_profilers.py b/tests/test_runtime_profilers.py new file mode 100644 index 000000000..36dc77122 --- /dev/null +++ b/tests/test_runtime_profilers.py @@ -0,0 +1,326 @@ +# +# Copyright (c) Granulate. All rights reserved. +# Licensed under the AGPL3 License. See LICENSE.md in the project root for license information. +# +import sys +from collections import defaultdict +from typing import Any, Dict, Iterable, List, Optional, Set, Type, cast + +import pytest +from pytest import MonkeyPatch + +from gprofiler import profilers +from gprofiler.gprofiler_types import ProcessToProfileData +from gprofiler.profilers import registry +from gprofiler.profilers.factory import get_profilers +from gprofiler.profilers.perf import PerfRuntime +from gprofiler.profilers.profiler_base import NoopProfiler, ProfilerBase +from gprofiler.profilers.registry import ( + ProfilerArgument, + ProfilerConfig, + ProfilingRuntime, + RuntimeConfig, + register_profiler, + register_runtime, +) +from tests.utils import get_arch + + +class MockRuntime(ProfilingRuntime): + pass + + +def _register_mock_runtime( + runtime_name: str, + default_mode: str = "enabled", + runtime_class: Any = MockRuntime, + common_arguments: Optional[List[ProfilerArgument]] = None, +) -> None: + register_runtime(runtime_name, default_mode, common_arguments=common_arguments)(runtime_class) + + +def _register_mock_profiler( + profiler_name: str, + runtime_class: Any, + profiler_class: Any = NoopProfiler, + is_preferred: bool = False, + possible_modes: List[str] = ["disabled"], + supported_archs: List[str] = ["x86_64"], + supported_profiling_modes: List[str] = ["cpu"], + profiler_arguments: Optional[List[ProfilerArgument]] = None, +) -> None: + register_profiler( + profiler_name=profiler_name, + runtime_class=runtime_class, + is_preferred=is_preferred, + possible_modes=possible_modes, + supported_archs=supported_archs, + supported_profiling_modes=supported_profiling_modes, + profiler_arguments=profiler_arguments, + )(profiler_class) + + +def _subset_of_profilers( + keys: Iterable[Type[ProfilingRuntime]] = [], +) -> Dict[Type[ProfilingRuntime], List[ProfilerConfig]]: + return defaultdict(list, {k: v[:] for (k, v) in registry.profilers_config.items() if k in keys}) + + +def _subset_of_runtimes(keys: Iterable[Type[ProfilingRuntime]] = []) -> Dict[Type[ProfilingRuntime], RuntimeConfig]: + return {k: v for (k, v) in registry.runtimes_config.items() if k in keys} + + +def test_profiler_names_are_unique(monkeypatch: MonkeyPatch) -> None: + """ + Make sure that registered profilers are checked for uniqueness. + """ + # register mock class under the same profiler name but for different runtimes; + # define mock modes, to let registration complete. + with monkeypatch.context() as m: + # clear registry before registering mock profilers + m.setattr(profilers.registry, "profilers_config", defaultdict(list)) + m.setattr(profilers.registry, "runtimes_config", _subset_of_runtimes(keys=[PerfRuntime])) + _register_mock_runtime("mock", runtime_class=MockRuntime) + + _register_mock_profiler( + profiler_name="mock", runtime_class=MockRuntime, possible_modes=["mock-profiler", "disabled"] + ) + with pytest.raises(AssertionError) as excinfo: + _register_mock_profiler( + profiler_name="mock", runtime_class=PerfRuntime, possible_modes=["mock-spy", "disabled"] + ) + assert "mock is already registered" in str(excinfo.value) + + +class MockProfiler(ProfilerBase): + def __init__(self, mock_mode: str = "", *args: Any, **kwargs: Any): + self.mock_mode = mock_mode + self.kwargs = dict(**kwargs) + + def snapshot(self) -> ProcessToProfileData: + return {} + + +@pytest.mark.parametrize("profiler_mode", ["mock-perf", "mock-profiler", "disabled"]) +def test_union_of_runtime_profilers_modes( + profiler_mode: str, + monkeypatch: MonkeyPatch, +) -> None: + """ + Test that generated command line arguments allow union of modes from all profilers for a runtime. + """ + with monkeypatch.context() as m: + # clear registry before registering mock profilers; + # keep Perf profiler, as some of its arguments (perf_dwarf_stack_size) are checked in command line parsing + m.setattr(profilers.registry, "profilers_config", _subset_of_profilers(keys=[PerfRuntime])) + m.setattr(profilers.registry, "runtimes_config", _subset_of_runtimes(keys=[PerfRuntime])) + _register_mock_runtime("mock", runtime_class=MockRuntime) + _register_mock_profiler( + profiler_name="mock-profiler", + runtime_class=MockRuntime, + profiler_class=MockProfiler, + possible_modes=["mock-profiler", "disabled"], + ) + MockPerf = type("MockPerf", (MockProfiler,), dict(**MockProfiler.__dict__)) + _register_mock_profiler( + profiler_name="mock-perf", + runtime_class=MockRuntime, + profiler_class=MockPerf, + possible_modes=["mock-perf", "disabled"], + ) + from gprofiler.main import parse_cmd_args + + # replace command-line args to include mode we're targeting + m.setattr( + sys, + "argv", + sys.argv[:1] + + [ + "--output-dir", + "./", + "--mock-mode", + profiler_mode, + ], + ) + args = parse_cmd_args() + assert args.mock_mode == profiler_mode + + +@pytest.mark.parametrize("profiler_mode", ["mock-perf", "mock-profiler"]) +def test_select_specific_runtime_profiler( + profiler_mode: str, + monkeypatch: MonkeyPatch, +) -> None: + with monkeypatch.context() as m: + # clear registry before registering mock profilers + m.setattr(profilers.registry, "profilers_config", _subset_of_profilers(keys=[PerfRuntime])) + m.setattr(profilers.registry, "runtimes_config", _subset_of_runtimes(keys=[PerfRuntime])) + _register_mock_runtime("mock", runtime_class=MockRuntime) + _register_mock_profiler( + profiler_name="mock-profiler", + runtime_class=MockRuntime, + profiler_class=MockProfiler, + possible_modes=["mock-profiler", "disabled"], + supported_archs=[get_arch()], + ) + MockPerf = type("MockPerf", (MockProfiler,), dict(**MockProfiler.__dict__)) + + _register_mock_profiler( + profiler_name="mock-perf", + runtime_class=MockRuntime, + profiler_class=MockPerf, + possible_modes=["mock-perf", "disabled"], + supported_archs=[get_arch()], + ) + from gprofiler.main import parse_cmd_args + + # replace command-line args to include mode we're targeting + m.setattr( + sys, + "argv", + sys.argv[:1] + ["--output-dir", "./", "--mock-mode", profiler_mode, "--no-perf"], + ) + args = parse_cmd_args() + _, process_profilers = get_profilers(args.__dict__) + assert len(process_profilers) == 1 + profiler = process_profilers[0] + assert profiler.__class__.__name__ == {"mock-perf": "MockPerf", "mock-profiler": "MockProfiler"}[profiler_mode] + assert cast(MockProfiler, profiler).mock_mode == profiler_mode + + +@pytest.mark.parametrize("preferred_profiler", ["mock-perf", "mock-profiler"]) +def test_auto_select_preferred_profiler( + preferred_profiler: str, + monkeypatch: MonkeyPatch, +) -> None: + """ + Test that auto selection mechanism correctly selects one of profilers. + """ + with monkeypatch.context() as m: + # clear registry before registering mock profilers + m.setattr(profilers.registry, "profilers_config", _subset_of_profilers(keys=[PerfRuntime])) + m.setattr(profilers.registry, "runtimes_config", _subset_of_runtimes(keys=[PerfRuntime])) + _register_mock_runtime("mock", runtime_class=MockRuntime) + _register_mock_profiler( + profiler_name="mock-profiler", + runtime_class=MockRuntime, + profiler_class=MockProfiler, + is_preferred="mock-profiler" == preferred_profiler, + possible_modes=["mock-profiler", "disabled"], + supported_archs=[get_arch()], + ) + MockPerf = type("MockPerf", (MockProfiler,), dict(**MockProfiler.__dict__)) + + _register_mock_profiler( + profiler_name="mock-perf", + runtime_class=MockRuntime, + profiler_class=MockPerf, + is_preferred="mock-perf" == preferred_profiler, + possible_modes=["mock-perf", "disabled"], + supported_archs=[get_arch()], + ) + from gprofiler.main import parse_cmd_args + + m.setattr( + sys, + "argv", + sys.argv[:1] + ["--output-dir", "./", "--mock-mode", "enabled", "--no-perf"], + ) + args = parse_cmd_args() + _, process_profilers = get_profilers(args.__dict__) + assert len(process_profilers) == 1 + profiler = process_profilers[0] + assert ( + profiler.__class__.__name__ + == {"mock-perf": "MockPerf", "mock-profiler": "MockProfiler"}[preferred_profiler] + ) + assert cast(MockProfiler, profiler).mock_mode == "enabled" + + +@pytest.mark.parametrize( + "preferred_profiler,expected_args,unwanted_args", + [ + pytest.param( + "mock-perf", + {"duration", "frequency", "mock_one", "mock_two", "mock_mock_perf_two"}, + {"mock_mock_profiler_one"}, + id="mock_perf", + ), + pytest.param( + "mock-profiler", + {"duration", "frequency", "mock_one", "mock_two", "mock_mock_profiler_one"}, + {"mock_mock_perf_two"}, + id="mock_profiler", + ), + ], +) +def test_assign_correct_profiler_arguments( + monkeypatch: MonkeyPatch, + preferred_profiler: str, + expected_args: Set[str], + unwanted_args: Set[str], +) -> None: + """ + Test that selected profiler gets all of its own or common arguments, none from other profiler. + """ + with monkeypatch.context() as m: + # clear registry before registering mock profilers + m.setattr(profilers.registry, "profilers_config", _subset_of_profilers(keys=[PerfRuntime])) + m.setattr(profilers.registry, "runtimes_config", _subset_of_runtimes(keys=[PerfRuntime])) + _register_mock_runtime( + "mock", + runtime_class=MockRuntime, + common_arguments=[ + ProfilerArgument("--mock-common-one", "mock_one"), + ProfilerArgument("--mock-common-two", "mock_two"), + ], + ) + _register_mock_profiler( + profiler_name="mock-profiler", + runtime_class=MockRuntime, + profiler_class=MockProfiler, + is_preferred="mock-profiler" == preferred_profiler, + possible_modes=["mock-profiler", "disabled"], + profiler_arguments=[ + ProfilerArgument("--mock-mock-profiler-one", "mock_mock_profiler_one"), + ], + supported_archs=[get_arch()], + ) + MockPerf = type("MockPerf", (MockProfiler,), dict(**MockProfiler.__dict__)) + + _register_mock_profiler( + profiler_name="mock-perf", + runtime_class=MockRuntime, + profiler_class=MockPerf, + is_preferred="mock-perf" == preferred_profiler, + possible_modes=["mock-perf", "disabled"], + profiler_arguments=[ + ProfilerArgument("--mock-mock-perf-two", "mock_mock_perf_two"), + ], + supported_archs=[get_arch()], + ) + from gprofiler.main import parse_cmd_args + + m.setattr( + sys, + "argv", + sys.argv[:1] + + [ + "--output-dir", + "./", + "--mock-mode", + "enabled", + "--no-perf", + "--mock-common-one=check", + "--mock-common-two=check", + "--mock-mock-profiler-one=check", + "--mock-mock-perf-two=check", + ], + ) + args = parse_cmd_args() + _, process_profilers = get_profilers(args.__dict__) + assert len(process_profilers) == 1 + profiler = process_profilers[0] + mock = cast(MockProfiler, profiler) + assert set(mock.kwargs.keys()).intersection(unwanted_args) == set() + assert set(mock.kwargs.keys()) == expected_args diff --git a/tests/test_sanity.py b/tests/test_sanity.py index 01765066e..9ff0df852 100644 --- a/tests/test_sanity.py +++ b/tests/test_sanity.py @@ -65,7 +65,7 @@ def test_pyspy( profiler_state: ProfilerState, ) -> None: _ = assert_app_id # Required for mypy unused argument warning - with PySpyProfiler(1000, 3, profiler_state, add_versions=True) as profiler: + with PySpyProfiler(1000, 3, profiler_state, python_mode="pyspy", python_add_versions=True) as profiler: # not using snapshot_one_collapsed because there are multiple Python processes running usually. process_collapsed = snapshot_pid_collapsed(profiler, application_pid) assert_collapsed(process_collapsed) @@ -157,7 +157,9 @@ def test_python_ebpf( ) _ = assert_app_id # Required for mypy unused argument warning - with PythonEbpfProfiler(1000, 5, profiler_state, add_versions=True, verbose=False) as profiler: + with PythonEbpfProfiler( + 1000, 5, profiler_state, python_mode="pyperf", python_add_versions=True, python_pyperf_verbose=False + ) as profiler: try: process_collapsed = snapshot_pid_collapsed(profiler, application_pid) except UnicodeDecodeError as e: @@ -270,7 +272,6 @@ def test_container_name_when_stopped( output_directory: Path, output_collapsed: Path, runtime_specific_args: List[str], - profiler_type: str, profiler_flags: List[str], application_docker_container: Container, ) -> None: @@ -310,7 +311,6 @@ def test_profiling_provided_pids( runtime_specific_args: List[str], profiler_flags: List[str], application_factory: Callable[[], _GeneratorContextManager], - profiler_type: str, ) -> None: """ Tests that gprofiler will profile only processes provided via flag --pids diff --git a/tests/utils.py b/tests/utils.py index 1961804bc..fa1c0db1f 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -29,7 +29,9 @@ JavaFlagCollectionOptions, JavaProfiler, ) -from gprofiler.profilers.profiler_base import ProfilerInterface +from gprofiler.profilers.profiler_base import ProfilerBase, ProfilerInterface +from gprofiler.profilers.python import PySpyProfiler +from gprofiler.profilers.python_ebpf import PythonEbpfProfiler from gprofiler.utils import remove_path, wait_event RUNTIME_PROFILERS = [ @@ -171,8 +173,12 @@ def is_pattern_in_collapsed(pattern: str, collapsed: StackToSampleCount) -> bool return any(regex.search(record) is not None for record in collapsed.keys()) +def get_arch() -> str: + return platform.machine() + + def is_aarch64() -> bool: - return platform.machine() == "aarch64" + return get_arch() == "aarch64" def assert_function_in_collapsed(function_name: str, collapsed: StackToSampleCount) -> None: @@ -238,6 +244,32 @@ def make_java_profiler( ) +def make_python_profiler( + frequency: int, + duration: int, + profiler_state: ProfilerState, + python_mode: str, + python_add_versions: bool, + python_pyperf_user_stacks_pages: Optional[int], + python_pyperf_verbose: bool, +) -> ProfilerBase: + assert python_mode in ["pyperf", "pyspy", "py-spy"] + if python_mode == "pyperf": + return PythonEbpfProfiler( + frequency, + duration, + profiler_state, + python_mode=python_mode, + python_add_versions=python_add_versions, + python_pyperf_user_stacks_pages=python_pyperf_user_stacks_pages, + python_pyperf_verbose=python_pyperf_verbose, + ) + else: + return PySpyProfiler( + frequency, duration, profiler_state, python_mode=python_mode, python_add_versions=python_add_versions + ) + + def start_gprofiler_in_container_for_one_session( docker_client: DockerClient, gprofiler_docker_image: Image,