blob: f4a5e5820d73aaa08cb89c383f880a9a59247153 [file] [log] [blame]
# 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 atexit
import csv
import datetime as dt
import enum
import logging
import subprocess
from typing import TYPE_CHECKING, Optional, Sequence, Tuple
from crossbench import cli_helper, compat, helper
from crossbench.helper.path_finder import ChromiumBuildBinaryFinder
from crossbench.probes.probe import (Probe, ProbeConfigParser, ProbeContext,
ProbeKeyT, ProbeValidationError,
ResultLocation)
from crossbench.probes.results import ProbeResult
if TYPE_CHECKING:
from crossbench.browsers.browser import Browser
from crossbench.env import HostEnvironment
from crossbench.path import RemotePath
from crossbench.runner.run import Run
@enum.unique
class SamplerType(compat.StrEnumWithHelp):
MAIN_DISPLAY = ("main_display",
"Samples the backlight level of the main display.")
BATTERY = ("battery", "Provides data retrieved from the IOPMPowerSource.")
SMC = ("smc", ("Samples power usage from various hardware components "
"from the System Management Controller (SMC)"))
M1 = ("m1", "Samples the temperature of M1 P-Cores and E-Cores.")
USER_IDLE_LEVEL = (
"user_idle_level",
"Samples the machdep.user_idle_level sysctl value if it exists")
RESOURCE_COALITION = ("resource_coalition", (
"Provides resource usage data for a group of tasks that are part of a "
"'resource coalition', including those that have died."))
class PowerSamplerProbe(Probe):
"""
Probe for chrome's power_sampler helper binary to collect MacOS specific
battery and system usage metrics.
Note that the battery monitor only gets a value infrequently (> 30s), thus
this probe mostly makes sense for long-running benchmarks.
"""
NAME = "powersampler"
RESULT_LOCATION = ResultLocation.BROWSER
BATTERY_ONLY: bool = True
SAMPLERS: Tuple[SamplerType,
...] = (SamplerType.SMC, SamplerType.USER_IDLE_LEVEL,
SamplerType.MAIN_DISPLAY)
@classmethod
def config_parser(cls) -> ProbeConfigParser:
parser = super().config_parser()
parser.add_argument("bin_path", type=cli_helper.parse_binary_path)
parser.add_argument(
"sampling_interval",
type=cli_helper.Duration.parse_non_zero,
default=dt.timedelta(seconds=10))
parser.add_argument(
"samplers", type=SamplerType, default=cls.SAMPLERS, is_list=True)
parser.add_argument(
"wait_for_battery",
type=bool,
default=True,
help="Wait for the first non-100% battery measurement before "
"running the benchmark to ensure accurate readings.")
return parser
def __init__(self,
bin_path: Optional[RemotePath] = None,
sampling_interval: dt.timedelta = dt.timedelta(),
samplers: Sequence[SamplerType] = SAMPLERS,
wait_for_battery: bool = True):
super().__init__()
self._bin_path: Optional[RemotePath] = bin_path
if not self._bin_path:
logging.debug("No default power_sampler binary provided.")
self._sampling_interval = sampling_interval
if sampling_interval.total_seconds() < 0:
raise ValueError(f"Invalid sampling_interval={sampling_interval}")
assert SamplerType.BATTERY not in samplers
self._samplers = tuple(samplers)
self._wait_for_battery = wait_for_battery
@property
def key(self) -> ProbeKeyT:
return super().key + (
("bin_path", str(self.bin_path)),
("sampling_interval", self.sampling_interval.total_seconds()),
("samplers", tuple(map(str, self.samplers))),
("wait_for_battery", self.wait_for_battery),
)
@property
def bin_path(self) -> Optional[RemotePath]:
return self._bin_path
@property
def sampling_interval(self) -> dt.timedelta:
return self._sampling_interval
@property
def samplers(self) -> Tuple[SamplerType, ...]:
return self._samplers
@property
def wait_for_battery(self) -> bool:
return self._wait_for_battery
def validate_browser(self, env: HostEnvironment, browser: Browser) -> None:
self.expect_macos(browser)
if not browser.platform.is_battery_powered:
env.handle_warning("Power Sampler only works on battery power, "
f"but Browser {browser} is connected to power.")
# TODO() warn when external monitors are connected
# TODO() warn about open terminals
self.find_power_sampler_bin(browser)
def find_power_sampler_bin(self, browser: Browser) -> RemotePath:
browser_platform = browser.platform
maybe_path = self.bin_path
if maybe_path and browser_platform.is_file(maybe_path):
return maybe_path
# .../chrome/src/out/x64.Release/App.path
# Don't use parents[] access to stop at the root.
maybe_build_dir: RemotePath = browser.app_path.parent
finder = ChromiumBuildBinaryFinder(browser_platform, "power_sampler",
(maybe_build_dir,))
if maybe_path := finder.path:
if browser_platform.is_file(maybe_path):
logging.info("Using fallback power_sampler: %s", maybe_path)
return maybe_path
raise self.missing_power_sampler_error(browser_platform, maybe_build_dir)
def missing_power_sampler_error(self, browser_platform, maybe_build_dir):
is_build_dir = browser_platform.is_file(maybe_build_dir / "args.gn")
if not is_build_dir:
maybe_build_dir = browser_platform.path(
"path/to/chromium/src/out/Release")
error_message = [
"Could not find custom chromium power_sampler helper binary.",
"Please build 'power_sampler manually for local builds'",
f"autoninja -C {maybe_build_dir} power_sampler"
]
return ProbeValidationError(self, "\n".join(error_message))
def get_context(self, run: Run) -> PowerSamplerProbeContext:
return PowerSamplerProbeContext(self, run)
class PowerSamplerProbeContext(ProbeContext[PowerSamplerProbe]):
def __init__(self, probe: PowerSamplerProbe, run: Run) -> None:
super().__init__(probe, run)
self._bin_path: RemotePath = probe.find_power_sampler_bin(self.browser)
self._active_user_process: Optional[subprocess.Popen] = None
self._power_process: Optional[subprocess.Popen] = None
self._power_battery_process: Optional[subprocess.Popen] = None
self._power_output: RemotePath = self.result_path.with_suffix(".power.json")
self._power_battery_output: RemotePath = self.result_path.with_suffix(
".power_battery.json")
def setup(self) -> None:
self._active_user_process = self.browser_platform.popen(
self._bin_path,
"--no-samplers",
"--simulate-user-active",
stdout=subprocess.DEVNULL)
if self._active_user_process.poll():
raise ValueError("Could not start active user background sampler")
atexit.register(self.stop_processes)
if self.probe.wait_for_battery:
self._wait_for_battery_not_full(self.run)
def start(self) -> None:
assert self._active_user_process
if sampling_interval := self.probe.sampling_interval.total_seconds():
self._power_process = self.browser_platform.popen(
self._bin_path,
f"--sample-interval={int(sampling_interval)}",
f"--samplers={','.join(map(str, self.probe.samplers))}",
f"--json-output-file={self._power_output}",
f"--resource-coalition-pid={self.browser_pid}",
stdout=subprocess.DEVNULL)
if self._power_process.poll():
raise ValueError("Could not start power sampler")
self._power_battery_process = self.browser_platform.popen(
self._bin_path,
"--sample-on-notification",
f"--samplers={','.join(map(str, self.probe.samplers))+',battery'}",
f"--json-output-file={self._power_battery_output}",
f"--resource-coalition-pid={self.browser_pid}",
stdout=subprocess.DEVNULL)
if self._power_battery_process.poll():
raise ValueError("Could not start power and battery sampler")
def stop(self) -> None:
if self._power_process:
self._power_process.terminate()
if self._power_battery_process:
self._power_battery_process.terminate()
def teardown(self) -> ProbeResult:
self.stop_processes()
if self.probe.sampling_interval:
return self.browser_result(
json=[self._power_output, self._power_battery_output])
return self.browser_result(json=[self._power_battery_output])
def stop_processes(self) -> None:
if self._power_process:
helper.wait_and_kill(self._power_process)
self._power_process = None
if self._power_battery_process:
helper.wait_and_kill(self._power_battery_process)
self._power_battery_process = None
if self._active_user_process:
helper.wait_and_kill(self._active_user_process)
self._active_user_process = None
def _wait_for_battery_not_full(self, run: Run) -> None:
"""
Empirical evidence has shown that right after a full battery charge, the
current capacity stays equal to the maximum capacity for several minutes,
despite the fact that power is definitely consumed. To ensure that power
consumption estimates from battery level are meaningful, wait until the
battery is no longer reporting being fully charged before crossbench.
"""
del run
logging.info("POWER SAMPLER: Waiting for non-100% battery or "
"initial sample to synchronize")
while True:
assert self.browser_platform.is_battery_powered, (
"Cannot wait for draining if power is connected.")
power_sampler_output = self.browser_platform.sh_stdout(
self._bin_path, "--sample-on-notification", "--samplers=battery",
"--sample-count=1")
for row in csv.DictReader(power_sampler_output.splitlines()):
max_capacity = float(row["battery_max_capacity(Ah)"])
current_capacity = float(row["battery_current_capacity(Ah)"])
percent = 100 * current_capacity / max_capacity
logging.debug("POWER SAMPLER: Battery level is %.2f%%", percent)
if max_capacity != current_capacity:
return