1# Copyright 2023 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 datetime as dt 9import enum 10import subprocess 11from typing import TYPE_CHECKING, Optional, Sequence, Tuple 12 13from crossbench import compat, helper 14from crossbench.parse import DurationParser 15from crossbench.probes.probe import (Probe, ProbeConfigParser, ProbeContext, 16 ProbeKeyT) 17from crossbench.probes.result_location import ResultLocation 18 19if TYPE_CHECKING: 20 from crossbench.browsers.browser import Browser 21 from crossbench.env import HostEnvironment 22 from crossbench.path import AnyPath 23 from crossbench.probes.results import ProbeResult 24 from crossbench.runner.run import Run 25 26 27@enum.unique 28class SamplerType(compat.StrEnumWithHelp): 29 BATTERY = ("battery", "Battery level") 30 CPU_POWER = ("cpu_power", 31 "CPU power and per-core frequency and idle residency") 32 DISK = ("disk", "Number of read/write ops/bytes") 33 GPU_POWER = ("gpu_power", 34 "GPU power consumption, frequency and active residency") 35 INTERRUPTS = ("interrupts", "Per-core interrupt count") 36 NETWORK = ("network", "Number of in/out packets/bytes") 37 TASKS = ("tasks", "Per-task stats including CPU usage and wakeups") 38 THERMAL = ("thermal", "Thermal pressure state") 39 40 41class PowerMetricsProbe(Probe): 42 """ 43 Probe to collect data using macOS's powermetrics command-line tool. 44 """ 45 46 NAME = "powermetrics" 47 RESULT_LOCATION = ResultLocation.BROWSER 48 SAMPLERS: Tuple[SamplerType, 49 ...] = (SamplerType.BATTERY, SamplerType.CPU_POWER, 50 SamplerType.DISK, SamplerType.GPU_POWER, 51 SamplerType.INTERRUPTS, SamplerType.NETWORK, 52 SamplerType.TASKS, SamplerType.THERMAL) 53 54 @classmethod 55 def config_parser(cls) -> ProbeConfigParser: 56 parser = super().config_parser() 57 parser.add_argument( 58 "sampling_interval", 59 type=DurationParser.positive_duration, 60 default=1000) 61 parser.add_argument( 62 "samplers", type=SamplerType, default=cls.SAMPLERS, is_list=True) 63 return parser 64 65 def __init__(self, 66 sampling_interval: dt.timedelta = dt.timedelta(), 67 samplers: Sequence[SamplerType] = SAMPLERS): 68 super().__init__() 69 self._sampling_interval = sampling_interval 70 if sampling_interval.total_seconds() < 0: 71 raise ValueError(f"Invalid sampling_interval={sampling_interval}") 72 self._samplers = tuple(samplers) 73 74 @property 75 def key(self) -> ProbeKeyT: 76 return super().key + ( 77 ("sampling_interval", self.sampling_interval.total_seconds()), 78 ("samplers", tuple(map(str, self.samplers))), 79 ) 80 81 @property 82 def sampling_interval(self) -> dt.timedelta: 83 return self._sampling_interval 84 85 @property 86 def samplers(self) -> Tuple[SamplerType, ...]: 87 return self._samplers 88 89 def validate_browser(self, env: HostEnvironment, browser: Browser) -> None: 90 super().validate_browser(env, browser) 91 self.expect_macos(browser) 92 93 def get_context(self, run: Run) -> PowerMetricsProbeContext: 94 return PowerMetricsProbeContext(self, run) 95 96 97class PowerMetricsProbeContext(ProbeContext[PowerMetricsProbe]): 98 99 def __init__(self, probe: PowerMetricsProbe, run: Run) -> None: 100 super().__init__(probe, run) 101 self._power_metrics_process: Optional[subprocess.Popen] = None 102 self._output_plist_file: AnyPath = self.result_path.with_suffix(".plist") 103 104 def start(self) -> None: 105 self._power_metrics_process = self.browser_platform.popen( 106 "sudo", 107 "powermetrics", 108 "-f", 109 "plist", 110 f"--samplers={','.join(map(str, self.probe.samplers))}", 111 "-i", 112 f"{int(self.probe.sampling_interval.total_seconds())}", 113 "--output-file", 114 self._output_plist_file, 115 stdout=subprocess.DEVNULL) 116 if self._power_metrics_process.poll(): 117 raise ValueError("Could not start powermetrics") 118 atexit.register(self.stop_process) 119 120 def stop(self) -> None: 121 if self._power_metrics_process: 122 self._power_metrics_process.terminate() 123 124 def teardown(self) -> ProbeResult: 125 self.stop_process() 126 return self.browser_result(file=(self._output_plist_file,)) 127 128 def stop_process(self) -> None: 129 if self._power_metrics_process: 130 helper.wait_and_kill(self._power_metrics_process) 131 self._power_metrics_process = None 132