1# Copyright 2022 The Chromium Authors 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5from __future__ import annotations 6 7import atexit 8import csv 9import datetime as dt 10import enum 11import logging 12import subprocess 13from typing import TYPE_CHECKING, Optional, Sequence, Tuple 14 15from crossbench import compat, helper 16from crossbench.helper.path_finder import ChromiumBuildBinaryFinder 17from crossbench.parse import DurationParser, PathParser 18from crossbench.probes.probe import (Probe, ProbeConfigParser, ProbeContext, 19 ProbeKeyT, ProbeValidationError) 20from crossbench.probes.result_location import ResultLocation 21 22if TYPE_CHECKING: 23 from crossbench.browsers.browser import Browser 24 from crossbench.env import HostEnvironment 25 from crossbench.path import AnyPath 26 from crossbench.probes.results import ProbeResult 27 from crossbench.runner.run import Run 28 29 30@enum.unique 31class SamplerType(compat.StrEnumWithHelp): 32 MAIN_DISPLAY = ("main_display", 33 "Samples the backlight level of the main display.") 34 BATTERY = ("battery", "Provides data retrieved from the IOPMPowerSource.") 35 SMC = ("smc", ("Samples power usage from various hardware components " 36 "from the System Management Controller (SMC)")) 37 M1 = ("m1", "Samples the temperature of M1 P-Cores and E-Cores.") 38 USER_IDLE_LEVEL = ( 39 "user_idle_level", 40 "Samples the machdep.user_idle_level sysctl value if it exists") 41 RESOURCE_COALITION = ("resource_coalition", ( 42 "Provides resource usage data for a group of tasks that are part of a " 43 "'resource coalition', including those that have died.")) 44 45 46class PowerSamplerProbe(Probe): 47 """ 48 Probe for chrome's power_sampler helper binary to collect MacOS specific 49 battery and system usage metrics. 50 Note that the battery monitor only gets a value infrequently (> 30s), thus 51 this probe mostly makes sense for long-running benchmarks. 52 """ 53 54 NAME = "powersampler" 55 RESULT_LOCATION = ResultLocation.BROWSER 56 BATTERY_ONLY: bool = True 57 SAMPLERS: Tuple[SamplerType, 58 ...] = (SamplerType.SMC, SamplerType.USER_IDLE_LEVEL, 59 SamplerType.MAIN_DISPLAY) 60 61 @classmethod 62 def config_parser(cls) -> ProbeConfigParser: 63 parser = super().config_parser() 64 parser.add_argument("bin_path", type=PathParser.binary_path) 65 parser.add_argument( 66 "sampling_interval", 67 type=DurationParser.positive_duration, 68 default=dt.timedelta(seconds=10)) 69 parser.add_argument( 70 "samplers", type=SamplerType, default=cls.SAMPLERS, is_list=True) 71 parser.add_argument( 72 "wait_for_battery", 73 type=bool, 74 default=True, 75 help="Wait for the first non-100% battery measurement before " 76 "running the benchmark to ensure accurate readings.") 77 return parser 78 79 def __init__(self, 80 bin_path: Optional[AnyPath] = None, 81 sampling_interval: dt.timedelta = dt.timedelta(), 82 samplers: Sequence[SamplerType] = SAMPLERS, 83 wait_for_battery: bool = True): 84 super().__init__() 85 self._bin_path: Optional[AnyPath] = bin_path 86 if not self._bin_path: 87 logging.debug("No default power_sampler binary provided.") 88 self._sampling_interval = sampling_interval 89 if sampling_interval.total_seconds() < 0: 90 raise ValueError(f"Invalid sampling_interval={sampling_interval}") 91 assert SamplerType.BATTERY not in samplers 92 self._samplers = tuple(samplers) 93 self._wait_for_battery = wait_for_battery 94 95 @property 96 def key(self) -> ProbeKeyT: 97 return super().key + ( 98 ("bin_path", str(self.bin_path)), 99 ("sampling_interval", self.sampling_interval.total_seconds()), 100 ("samplers", tuple(map(str, self.samplers))), 101 ("wait_for_battery", self.wait_for_battery), 102 ) 103 104 @property 105 def bin_path(self) -> Optional[AnyPath]: 106 return self._bin_path 107 108 @property 109 def sampling_interval(self) -> dt.timedelta: 110 return self._sampling_interval 111 112 @property 113 def samplers(self) -> Tuple[SamplerType, ...]: 114 return self._samplers 115 116 @property 117 def wait_for_battery(self) -> bool: 118 return self._wait_for_battery 119 120 def validate_browser(self, env: HostEnvironment, browser: Browser) -> None: 121 self.expect_macos(browser) 122 if not browser.platform.is_battery_powered: 123 env.handle_warning("Power Sampler only works on battery power, " 124 f"but Browser {browser} is connected to power.") 125 # TODO() warn when external monitors are connected 126 # TODO() warn about open terminals 127 self.find_power_sampler_bin(browser) 128 129 def find_power_sampler_bin(self, browser: Browser) -> AnyPath: 130 browser_platform = browser.platform 131 maybe_path = self.bin_path 132 if maybe_path and browser_platform.is_file(maybe_path): 133 return maybe_path 134 # .../chrome/src/out/x64.Release/App.path 135 # Don't use parents[] access to stop at the root. 136 maybe_build_dir: AnyPath = browser.app_path.parent 137 finder = ChromiumBuildBinaryFinder(browser_platform, "power_sampler", 138 (maybe_build_dir,)) 139 if maybe_path := finder.path: 140 if browser_platform.is_file(maybe_path): 141 logging.info("Using fallback power_sampler: %s", maybe_path) 142 return maybe_path 143 raise self.missing_power_sampler_error(browser_platform, maybe_build_dir) 144 145 def missing_power_sampler_error(self, browser_platform, maybe_build_dir): 146 is_build_dir = browser_platform.is_file(maybe_build_dir / "args.gn") 147 if not is_build_dir: 148 maybe_build_dir = browser_platform.path( 149 "path/to/chromium/src/out/Release") 150 error_message = [ 151 "Could not find custom chromium power_sampler helper binary.", 152 "Please build 'power_sampler manually for local builds'", 153 f"autoninja -C {maybe_build_dir} power_sampler" 154 ] 155 return ProbeValidationError(self, "\n".join(error_message)) 156 157 def get_context(self, run: Run) -> PowerSamplerProbeContext: 158 return PowerSamplerProbeContext(self, run) 159 160 161class PowerSamplerProbeContext(ProbeContext[PowerSamplerProbe]): 162 163 def __init__(self, probe: PowerSamplerProbe, run: Run) -> None: 164 super().__init__(probe, run) 165 self._bin_path: AnyPath = probe.find_power_sampler_bin(self.browser) 166 self._active_user_process: Optional[subprocess.Popen] = None 167 self._power_process: Optional[subprocess.Popen] = None 168 self._power_battery_process: Optional[subprocess.Popen] = None 169 self._power_output: AnyPath = self.result_path.with_suffix(".power.json") 170 self._power_battery_output: AnyPath = self.result_path.with_suffix( 171 ".power_battery.json") 172 173 def setup(self) -> None: 174 self._active_user_process = self.browser_platform.popen( 175 self._bin_path, 176 "--no-samplers", 177 "--simulate-user-active", 178 stdout=subprocess.DEVNULL) 179 if self._active_user_process.poll(): 180 raise ValueError("Could not start active user background sampler") 181 atexit.register(self.stop_processes) 182 if self.probe.wait_for_battery: 183 self._wait_for_battery_not_full(self.run) 184 185 def start(self) -> None: 186 assert self._active_user_process 187 if sampling_interval := self.probe.sampling_interval.total_seconds(): 188 self._power_process = self.browser_platform.popen( 189 self._bin_path, 190 f"--sample-interval={int(sampling_interval)}", 191 f"--samplers={','.join(map(str, self.probe.samplers))}", 192 f"--json-output-file={self._power_output}", 193 f"--resource-coalition-pid={self.browser_pid}", 194 stdout=subprocess.DEVNULL) 195 if self._power_process.poll(): 196 raise ValueError("Could not start power sampler") 197 self._power_battery_process = self.browser_platform.popen( 198 self._bin_path, 199 "--sample-on-notification", 200 f"--samplers={','.join(map(str, self.probe.samplers))+',battery'}", 201 f"--json-output-file={self._power_battery_output}", 202 f"--resource-coalition-pid={self.browser_pid}", 203 stdout=subprocess.DEVNULL) 204 if self._power_battery_process.poll(): 205 raise ValueError("Could not start power and battery sampler") 206 207 def stop(self) -> None: 208 if self._power_process: 209 self._power_process.terminate() 210 if self._power_battery_process: 211 self._power_battery_process.terminate() 212 213 def teardown(self) -> ProbeResult: 214 self.stop_processes() 215 if self.probe.sampling_interval: 216 return self.browser_result( 217 json=[self._power_output, self._power_battery_output]) 218 return self.browser_result(json=[self._power_battery_output]) 219 220 def stop_processes(self) -> None: 221 if self._power_process: 222 helper.wait_and_kill(self._power_process) 223 self._power_process = None 224 if self._power_battery_process: 225 helper.wait_and_kill(self._power_battery_process) 226 self._power_battery_process = None 227 if self._active_user_process: 228 helper.wait_and_kill(self._active_user_process) 229 self._active_user_process = None 230 231 def _wait_for_battery_not_full(self, run: Run) -> None: 232 """ 233 Empirical evidence has shown that right after a full battery charge, the 234 current capacity stays equal to the maximum capacity for several minutes, 235 despite the fact that power is definitely consumed. To ensure that power 236 consumption estimates from battery level are meaningful, wait until the 237 battery is no longer reporting being fully charged before crossbench. 238 """ 239 del run 240 logging.info("POWER SAMPLER: Waiting for non-100% battery or " 241 "initial sample to synchronize") 242 while True: 243 assert self.browser_platform.is_battery_powered, ( 244 "Cannot wait for draining if power is connected.") 245 246 power_sampler_output = self.browser_platform.sh_stdout( 247 self._bin_path, "--sample-on-notification", "--samplers=battery", 248 "--sample-count=1") 249 250 for row in csv.DictReader(power_sampler_output.splitlines()): 251 max_capacity = float(row["battery_max_capacity(Ah)"]) 252 current_capacity = float(row["battery_current_capacity(Ah)"]) 253 percent = 100 * current_capacity / max_capacity 254 logging.debug("POWER SAMPLER: Battery level is %.2f%%", percent) 255 if max_capacity != current_capacity: 256 return 257