• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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