blob: 879102eb97cb3531585645e7320a8045be5b8bf5 [file]
# Copyright 2022 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
from __future__ import annotations
import logging
import shlex
from typing import TYPE_CHECKING, Any, ClassVar, Final, Iterable, Optional, \
Self, Sequence, cast
from typing_extensions import override
from crossbench import path as pth
from crossbench import plt
from crossbench.browsers.chromium_based.chromium_based import ChromiumBased
from crossbench.helper import fs_helper
from crossbench.parse import NumberParser, ObjectParser
from crossbench.probes.probe import Probe, ProbeConfigParser, \
ProbeIncompatibleBrowser, ProbeKeyT
from crossbench.probes.profiling.context.android import AndroidProfilingContext
from crossbench.probes.profiling.context.linux import LinuxProfilingContext
from crossbench.probes.profiling.context.macos import MacOSProfilingContext
from crossbench.probes.profiling.enum import CallGraphMode, CleanupMode, \
TargetMode
from crossbench.probes.result_location import ResultLocation
from crossbench.probes.trace_processor import profile_helper
if TYPE_CHECKING:
from crossbench.browsers.browser import Browser
from crossbench.env.runner_env import RunnerEnv
from crossbench.probes.profiling.context.base import ProfilingContext
from crossbench.runner.groups.browsers import BrowsersRunGroup
from crossbench.runner.run import Run
from crossbench.runner.runner import Runner
V8_INTERPRETED_FRAMES_FLAG: Final = "--interpreted-frames-native-stack"
RENDERER_CMD_PATH: Final = pth.LocalPath(
__file__).parent / "linux-perf-chrome-renderer-cmd.sh"
def perf_frequency(value: Any) -> str | int:
if value == "max":
return "max"
return NumberParser.positive_int(value, "frequency")
class ProfilingProbe(Probe):
"""
General-purpose sampling profiling probe.
Implementation:
- Uses linux-perf on linux platforms (per browser/renderer process)
- Uses xctrace on MacOS (currently only system-wide)
- Uses simpleperf on Android (renderer-only, browser-only, or system-wide)
For linux-based Chromium browsers it also injects JS stack samples with names
from V8. For Googlers it additionally can auto-upload symbolized profiles to
pprof.
"""
NAME: ClassVar = "profiling"
RESULT_LOCATION: ClassVar = ResultLocation.BROWSER
@classmethod
@override
def config_parser(cls) -> ProbeConfigParser[Self]:
parser = super().config_parser()
parser.add_argument(
"js",
type=bool,
default=True,
help=("Chrome-on-Linux-only: expose JS function names to the native "
"profiler"))
parser.add_argument(
"browser_process",
type=bool,
default=False,
help=("Chrome-on-Linux-only: also profile the browser process, "
"(as opposed to only renderer processes)"))
parser.add_argument(
"spare_renderer_process",
type=bool,
default=False,
help=("Chrome-only: Enable/Disable spare renderer processes via \n"
"--enable-/--disable-features=SpareRendererForSitePerProcess.\n"
"Spare renderers are disabled by default when profiling "
"for fewer uninteresting processes."))
parser.add_argument(
"v8_interpreted_frames",
type=ObjectParser.optional_bool,
default=None,
help=(
f"Chrome-only: Sets the {V8_INTERPRETED_FRAMES_FLAG} flag for "
"V8, which exposes interpreted frames as native frames. "
"Note that this comes at an additional performance and memory cost."
))
parser.add_argument(
"pprof",
type=bool,
default=True,
help="linux-only: process collected samples with pprof.")
parser.add_argument(
"cleanup",
type=CleanupMode,
default=CleanupMode.AUTO,
help="Automatically clean up any temp files "
"(perf.data.jitted and temporary .so files on linux "
"cleaned up automatically if pprof is set to True)")
# Android/simpleperf-specific arguments.
parser.add_default_argument(
"target",
type=TargetMode,
default=TargetMode.AUTO,
help=("Chrome-on-Android/Chrome-on-Mac: "
"Profile either Renderer main/process only, "
"or all processes of the Browser App, or system-wide. "
"If Renderer main/process profiling is selected, "
"profiling begins **after** browser has started "
"and the benchmark story has been setup."))
parser.add_argument(
"pin_renderer_main_core",
type=NumberParser.positive_zero_int,
default=None,
help=("Chrome-on-Android-only: "
"Whether to pin the renderer main thread to a given core"))
parser.add_argument(
"call_graph_mode",
aliases=("call-graph",),
type=CallGraphMode,
default=CallGraphMode.FRAME_POINTER,
help=("Android/Linux-only: Specify whether to record a call graph, "
"and, if yes, which kind of stack unwinding to run."))
# Advanced Android/simpleperf/linux-perf-specific arguments.
# Generally, the defaults should suffice.
parser.add_argument(
"frequency",
aliases=("freq",),
type=perf_frequency,
default=None,
help=("Android/Linux-only: Event sampling frequency "
"(record at most `frequency` samples every second). "
"Please refer to '--freq' in the simpleperf/linux perf "
"documentation for more details."))
parser.add_argument(
"count",
type=NumberParser.positive_int,
default=None,
help=("Android/Linux-only: Event sampling period "
"(record one sample every `count` events). "
"Please refer to '--count' in the simpleperf/linux perf "
"documentation for more details."))
parser.add_argument(
"clockid",
type=ObjectParser.non_empty_str,
default=None,
help=("Android/Linux-only: Defines the clock id used in perf events. "
"Please refer to '--clockid' in the simpleperf/linux perf "
"documentation for more details. Defaults to 'mono'."))
parser.add_argument(
"cpu",
type=NumberParser.positive_zero_int,
is_list=True,
default=(),
help=("Android/Linux-only: Sample only on the selected cpus, "
"specified as a list of 0-indexed cpu indices. "
"Please refer to '--cpu' in the simpleperf/linux-perf "
"documentation for more details."))
parser.add_argument(
"events",
type=str,
is_list=True,
default=(),
help=("Android/Linux-only-only: Events to record. "
"Please refer to the '-e' simpleperf/linux-perf "
"documentation for more details."))
parser.add_argument(
"grouped_events",
type=str,
is_list=True,
default=(),
help=("Android-only: Events to record as a single group. "
"These events are monitored as a group, "
"and scheduled in and out together. "
"Please refer to simpleperf documentation for `--group` "
"for more details."))
parser.add_argument(
"add_counters",
type=str,
is_list=True,
default=(),
help=("Android-only: Add additional event counts in samples. NOTE: If "
"`add_counter` is used, `--no-inherit` is implicitly set, since "
"this is required by simpleperf. Please refer to simpleperf "
"documentation for `--add-counter` and `--no-inherit` for more "
"details."))
return parser
def __init__(
self,
js: bool = True,
v8_interpreted_frames: Optional[bool] = None,
pprof: bool = True,
cleanup: CleanupMode = CleanupMode.AUTO,
browser_process: bool = False,
spare_renderer_process: bool = False,
target: TargetMode = TargetMode.AUTO,
pin_renderer_main_core: Optional[int] = None,
call_graph_mode: CallGraphMode = CallGraphMode.FRAME_POINTER,
frequency: Optional[int | str] = None,
clockid: Optional[str] = None,
count: Optional[int] = None,
cpu: Sequence[int] = (),
events: Sequence[str] = (),
grouped_events: Sequence[str] = (),
add_counters: Sequence[str] = ()
) -> None:
super().__init__()
self._sample_js: bool = js
self._sample_browser_process: bool = browser_process
self._spare_renderer_process: bool = spare_renderer_process
self._run_pprof: bool = pprof
self._cleanup_mode = cleanup
if v8_interpreted_frames is None:
v8_interpreted_frames = js
self._expose_v8_interpreted_frames: bool = v8_interpreted_frames
if v8_interpreted_frames:
assert js, "Cannot expose V8 interpreted frames without js profiling."
self._target: TargetMode = target
self._pin_renderer_main_core: int | None = pin_renderer_main_core
self._call_graph_mode: CallGraphMode = call_graph_mode
self._frequency: int | str | None = frequency
self._clockid: str | None = clockid
self._count: int | None = count
self._cpu: tuple[int, ...] = tuple(cpu)
self._events: tuple[str, ...] = tuple(events)
self._grouped_events: tuple[str, ...] = tuple(grouped_events)
self._add_counters: tuple[str, ...] = tuple(add_counters)
@property
@override
def key(self) -> ProbeKeyT:
return super().key + (
("js", self._sample_js),
("v8_interpreted_frames", self._expose_v8_interpreted_frames),
("pprof", self._run_pprof),
("cleanup", self._cleanup_mode),
("browser_process", self._sample_browser_process),
("spare_renderer_process", self._spare_renderer_process),
("target", str(self._target)),
("pin_renderer_main_core", self._pin_renderer_main_core),
("call_graph_mode", str(self._call_graph_mode)),
("target", str(self.target)),
("frequency", self._frequency),
("count", self._count),
("cpu", self._cpu),
("events", self._events),
("grouped_events", self._grouped_events),
("add_counters", self._add_counters),
)
@property
def sample_js(self) -> bool:
return self._sample_js
@property
def expose_v8_interpreted_frames(self) -> bool:
return self._expose_v8_interpreted_frames
@property
def sample_browser_process(self) -> bool:
return self._sample_browser_process
@property
def run_pprof(self) -> bool:
return self._run_pprof
@property
def cleanup_mode(self) -> CleanupMode:
return self._cleanup_mode
@property
def target(self) -> TargetMode:
return self._target
@property
def pin_renderer_main_core(self) -> Optional[int]:
return self._pin_renderer_main_core
@property
def call_graph_mode(self) -> CallGraphMode:
return self._call_graph_mode
@property
def frequency(self) -> Optional[int | str]:
return self._frequency
@property
def clockid(self) -> Optional[str]:
return self._clockid
@property
def count(self) -> Optional[int]:
return self._count
@property
def cpu(self) -> tuple[int, ...]:
return self._cpu
@property
def events(self) -> tuple[str, ...]:
return self._events
@property
def grouped_events(self) -> tuple[str, ...]:
return self._grouped_events
@property
def add_counters(self) -> tuple[str, ...]:
return self._add_counters
def resolve_target_mode(self, browser: Browser) -> TargetMode:
if self.target is not TargetMode.AUTO:
return self.target
if browser.platform.is_macos:
return TargetMode.RENDERER_PROCESS_ONLY
return TargetMode.BROWSER_APP_ONLY
def start_profiling_after_setup(self, target: TargetMode) -> bool:
return target in (TargetMode.RENDERER_MAIN_ONLY,
TargetMode.RENDERER_PROCESS_ONLY
) or self.pin_renderer_main_core is not None
@override
def validate_browser(self, env: RunnerEnv, browser: Browser) -> None:
browser_platform = browser.platform
if browser_platform.is_linux:
self._validate_linux(env, browser)
elif browser_platform.is_macos:
self._validate_macos(env, browser)
elif browser_platform.is_android:
self._validate_android(env, browser)
else:
raise ProbeIncompatibleBrowser(self, browser)
if browser.attributes().is_chromium_based:
chromium = cast(ChromiumBased, browser)
self._validate_chromium_based(chromium)
if self.run_pprof:
self._validate_pprof(env, browser)
# Check that certain Android-only options are
# not provided by on other platforms.
if not browser_platform.is_android and not browser_platform.is_linux:
self._validate_perf_settings(browser)
if not browser_platform.is_android:
self._validate_non_android_perf_settings(browser)
def _validate_chromium_based(self, browser: ChromiumBased) -> None:
browser_target_mode = self.resolve_target_mode(browser)
if self.start_profiling_after_setup(browser_target_mode):
self._validate_benchmarking_extension_version(browser)
def _validate_perf_settings(self, browser: Browser) -> None:
unsupported_settings = (
("frequency", self._frequency),
("count", self._count),
("cpu", self._cpu),
("events", self._events),
)
self._validate_unsupported_settings(browser, unsupported_settings,
"Android and Linux")
def _validate_non_android_perf_settings(self, browser: Browser) -> None:
unsupported_settings = (
("grouped_events", self._grouped_events),
("add_counters", self._add_counters),
)
self._validate_unsupported_settings(browser, unsupported_settings,
"Android")
def _validate_unsupported_settings(self, browser: Browser,
unsupported_settings: Iterable[tuple[str,
Any]],
platforms: str) -> None:
for name, value in unsupported_settings:
if value:
raise ProbeIncompatibleBrowser(
self, browser,
f"{repr(name)} is currently only supported on {platforms}")
def _validate_linux(self, env: RunnerEnv, browser: Browser) -> None:
env.check_installed(binaries=["pprof"])
assert browser.platform.which("perf"), "Please install linux-perf"
def _validate_macos(self, env: RunnerEnv, browser: Browser) -> None:
assert browser.platform.which(
"xctrace"), "Please install Xcode to use xctrace"
# Only Linux-perf and Android-simpleperf results can be merged
if env.repetitions > 1:
env.handle_warning(f"Probe={self.NAME} cannot merge data over multiple "
f"repetitions={env.repetitions}.")
supported_mac_targets = (TargetMode.AUTO, TargetMode.SYSTEM_WIDE,
TargetMode.RENDERER_PROCESS_ONLY)
assert self._target in supported_mac_targets, (
f"Unsupported profile target for Mac: {self._target}. "
f"Should be one of {str(supported_mac_targets)}.")
def _validate_android(self, env: RunnerEnv, browser: Browser) -> None:
del env
assert browser.platform.which("simpleperf"), "simpleperf is not available"
def _validate_benchmarking_extension_version(self,
browser: ChromiumBased) -> None:
assert (
browser.attributes().is_chromium_based and
browser.version.major >= 124), (
"For RENDERER_MAIN_ONLY/RENDERER_PROCESS_ONLY profiling, "
"browser version >= M124 https://crrev.com/c/5374765 is required.")
def _validate_pprof(self, env: RunnerEnv, browser: Browser) -> None:
assert self._run_pprof
host_platform = browser.host_platform
self._run_pprof = host_platform.which("gcert") is not None
if not self.run_pprof:
logging.warning(
"Disabled automatic pprof uploading for non-googler machine.")
return
if browser.platform.is_macos:
# Converting xctrace to pprof is not supported on macos
return
try:
if gcertstatus := host_platform.which("gcertstatus"):
host_platform.sh(gcertstatus)
return
env.handle_warning("Could not find gcertstatus")
except plt.SubprocessError:
env.handle_warning("Please run gcert for generating pprof results")
@override
def attach(self, browser: Browser) -> None:
super().attach(browser)
if browser.platform.is_linux or browser.platform.is_android:
assert browser.attributes().is_chromium_based, (
f"Expected Chromium-based browser, found {type(browser)}.")
if browser.attributes().is_chromium_based:
chromium = cast(ChromiumBased, browser)
self._attach_chromium(chromium)
def _attach_chromium(self, browser: ChromiumBased) -> None:
if not self._spare_renderer_process:
browser.features.disable("SpareRendererForSitePerProcess")
browser_target = self.resolve_target_mode(browser)
if self.start_profiling_after_setup(browser_target):
browser.flags.enable_benchmarking_api()
if self._sample_js:
if browser.platform.is_linux:
browser.js_flags.set("--perf-prof")
if self._expose_v8_interpreted_frames:
browser.js_flags.set(V8_INTERPRETED_FRAMES_FLAG)
if browser.platform.is_linux and browser.platform.is_local:
self._set_renderer_cmd_prefix(browser)
# Disable sandbox to write profiling data
browser.flags.set("--no-sandbox")
def _set_renderer_cmd_prefix(self, browser: Browser) -> None:
assert not browser.platform.is_remote, (
"Copying renderer command prefix to remote platform is "
"not implemented yet")
assert RENDERER_CMD_PATH.is_file(), f"Didn't find {RENDERER_CMD_PATH}"
cmd_prefix = [str(RENDERER_CMD_PATH), f"--perf-data-dir={self.NAME}"]
if freq := self.frequency:
cmd_prefix.append(f"--perf-freq={freq}")
if count := self.count:
cmd_prefix.append(f"--perf-count={count}")
if self.call_graph_mode != CallGraphMode.FRAME_POINTER:
cmd_prefix.append(f"--perf-call-graph={self.call_graph_mode}")
if clockid := self.clockid:
cmd_prefix.append(f"--perf-clockid={clockid}")
custom_perf_args = []
if cpu := self.cpu:
cpu_str = ",".join(map(str, cpu))
custom_perf_args.append(f"--cpu={cpu_str}")
if events := self.events:
events_str = ",".join(events)
custom_perf_args.append(f"--event={events_str}")
if custom_perf_args:
cmd_prefix.append(f"--perf-args={shlex.join(custom_perf_args)}")
browser.flags["--renderer-cmd-prefix"] = shlex.join(cmd_prefix)
@override
def log_run_result(self, run: Run) -> None:
self._log_results([run])
@override
def log_browsers_result(self, group: BrowsersRunGroup) -> None:
self._log_results(group.runs)
def _log_results(self, runs: Iterable[Run]) -> None:
filtered_runs = [run for run in runs if self in run.results]
if not filtered_runs:
return
logging.info("-" * 80)
logging.critical("Profiling results:")
self._log_results_overview(filtered_runs)
logging.info("- " * 40)
for i, run in enumerate(filtered_runs):
self._log_run_result_summary(run, i)
def _log_results_overview(self, filtered_runs: Sequence[Run]) -> None:
if len(filtered_runs) <= 1:
return
if any(run.browser_platform.is_macos for run in filtered_runs):
logging.info(" *.trace: 'open $FILE'")
if any(run.browser_platform.is_linux or run.browser_platform.is_android
for run in filtered_runs):
logging.info(" *.perf.data: 'perf report -i $FILE'")
def _log_run_result_summary(self, run: Run, i: int) -> None:
if self not in run.results:
return
urls = run.results[self].url_list
perf_files = run.results[self].file_list
if not urls and not perf_files:
return
logging.info("Run %d: %s", i + 1, run.name)
if urls:
logging.critical(" %s", urls[-1])
if not perf_files:
return
largest_perf_file = perf_files[-1]
logging.critical(" %s [%s]", largest_perf_file,
fs_helper.get_file_size(largest_perf_file))
if len(perf_files) <= 1:
return
glob = "*.perf.data"
if run.browser_platform.is_macos:
glob = "*.trace"
logging.info(" %s/%s: %d more files", largest_perf_file.parent, glob,
len(perf_files))
def create_context(self, run: Run) -> ProfilingContext:
if run.browser_platform.is_linux:
return LinuxProfilingContext(self, run)
if run.browser_platform.is_macos:
return MacOSProfilingContext(self, run)
if run.browser_platform.is_android:
return AndroidProfilingContext(self, run)
raise NotImplementedError("Invalid platform")
@override
def get_extra_probes(self, runner: Runner) -> Iterable[Probe]:
return profile_helper.get_extra_trace_processor(runner)