1# Copyright 2014 The Chromium Authors. All rights reserved. 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5import collections 6import logging 7import os 8import plistlib 9import shutil 10import tempfile 11import xml.parsers.expat 12 13from telemetry.core import os_version 14from telemetry.core import util 15from telemetry import decorators 16from telemetry.internal.platform import power_monitor 17 18 19# TODO: rename this class (seems like this is used by mac) 20class PowerMetricsPowerMonitor(power_monitor.PowerMonitor): 21 22 def __init__(self, backend): 23 super(PowerMetricsPowerMonitor, self).__init__() 24 self._powermetrics_process = None 25 self._backend = backend 26 self._output_filename = None 27 self._output_directory = None 28 29 @property 30 def binary_path(self): 31 return '/usr/bin/powermetrics' 32 33 def StartMonitoringPower(self, browser): 34 self._CheckStart() 35 # Empirically powermetrics creates an empty output file immediately upon 36 # starting. We detect file creation as a signal that measurement has 37 # started. In order to avoid various race conditions in tempfile creation 38 # we create a temp directory and have powermetrics create it's output 39 # there rather than say, creating a tempfile, deleting it and reusing its 40 # name. 41 self._output_directory = tempfile.mkdtemp() 42 self._output_filename = os.path.join(self._output_directory, 43 'powermetrics.output') 44 args = ['-f', 'plist', 45 '-u', self._output_filename, 46 '-i0', 47 '--show-usage-summary'] 48 self._powermetrics_process = self._backend.LaunchApplication( 49 self.binary_path, args, elevate_privilege=True) 50 51 # Block until output file is written to ensure this function call is 52 # synchronous in respect to powermetrics starting. 53 def _OutputFileExists(): 54 return os.path.isfile(self._output_filename) 55 util.WaitFor(_OutputFileExists, 1) 56 57 @decorators.Cache 58 def CanMonitorPower(self): 59 mavericks_or_later = ( 60 self._backend.GetOSVersionName() >= os_version.MAVERICKS) 61 binary_path = self.binary_path 62 return mavericks_or_later and self._backend.CanLaunchApplication( 63 binary_path) 64 65 @staticmethod 66 def _ParsePlistString(plist_string): 67 """Wrapper to parse a plist from a string and catch any errors. 68 69 Sometimes powermetrics will exit in the middle of writing it's output, 70 empirically it seems that it always writes at least one sample in it's 71 entirety so we can safely ignore any errors in it's output. 72 73 Returns: 74 Parser output on successful parse, None on parse error. 75 """ 76 try: 77 return plistlib.readPlistFromString(plist_string) 78 except xml.parsers.expat.ExpatError: 79 return None 80 81 @staticmethod 82 def ParsePowerMetricsOutput(powermetrics_output): 83 """Parse output of powermetrics command line utility. 84 85 Returns: 86 Dictionary in the format returned by StopMonitoringPower() or None 87 if |powermetrics_output| is empty - crbug.com/353250 . 88 """ 89 if len(powermetrics_output) == 0: 90 logging.warning('powermetrics produced zero length output') 91 return {} 92 93 # Container to collect samples for running averages. 94 # out_path - list containing the key path in the output dictionary. 95 # src_path - list containing the key path to get the data from in 96 # powermetrics' output. 97 def ConstructMetric(out_path, src_path): 98 RunningAverage = collections.namedtuple('RunningAverage', [ 99 'out_path', 'src_path', 'samples']) 100 return RunningAverage(out_path, src_path, []) 101 102 # List of RunningAverage objects specifying metrics we want to aggregate. 103 metrics = [ 104 ConstructMetric( 105 ['platform_info', 'average_frequency_hz'], 106 ['processor', 'freq_hz']), 107 ConstructMetric( 108 ['platform_info', 'idle_percent'], 109 ['processor', 'packages', 0, 'c_state_ratio'])] 110 111 def DataWithMetricKeyPath(metric, powermetrics_output): 112 """Retrieve the sample from powermetrics' output for a given metric. 113 114 Args: 115 metric: The RunningAverage object we want to collect a new sample for. 116 powermetrics_output: Dictionary containing powermetrics output. 117 118 Returns: 119 The sample corresponding to |metric|'s keypath.""" 120 # Get actual data corresponding to key path. 121 out_data = powermetrics_output 122 for k in metric.src_path: 123 out_data = out_data[k] 124 125 assert type(out_data) in [int, float], ( 126 'Was expecting a number: %s (%s)' % (type(out_data), out_data)) 127 return float(out_data) 128 129 sample_durations = [] 130 total_energy_consumption_mwh = 0 131 # powermetrics outputs multiple plists separated by null terminators. 132 raw_plists = powermetrics_output.split('\0') 133 raw_plists = [x for x in raw_plists if len(x) > 0] 134 assert len(raw_plists) == 1 135 136 # -------- Examine contents of first plist for systems specs. -------- 137 plist = PowerMetricsPowerMonitor._ParsePlistString(raw_plists[0]) 138 if not plist: 139 logging.warning('powermetrics produced invalid output, output length: ' 140 '%d', len(powermetrics_output)) 141 return {} 142 143 # Powermetrics doesn't record power usage when running on a VM. 144 hw_model = plist.get('hw_model') 145 if hw_model and hw_model.startswith('VMware'): 146 return {} 147 148 if 'GPU' in plist: 149 metrics.extend([ 150 ConstructMetric( 151 ['component_utilization', 'gpu', 'average_frequency_hz'], 152 ['GPU', 0, 'freq_hz']), 153 ConstructMetric( 154 ['component_utilization', 'gpu', 'idle_percent'], 155 ['GPU', 0, 'c_state_ratio'])]) 156 157 # There's no way of knowing ahead of time how many cpus and packages the 158 # current system has. Iterate over cores and cpus - construct metrics for 159 # each one. 160 if 'processor' in plist: 161 core_dict = plist['processor']['packages'][0]['cores'] 162 num_cores = len(core_dict) 163 cpu_num = 0 164 for core_idx in xrange(num_cores): 165 num_cpus = len(core_dict[core_idx]['cpus']) 166 base_src_path = ['processor', 'packages', 0, 'cores', core_idx] 167 for cpu_idx in xrange(num_cpus): 168 base_out_path = ['component_utilization', 'cpu%d' % cpu_num] 169 # C State ratio is per-package, component CPUs of that package may 170 # have different frequencies. 171 metrics.append(ConstructMetric( 172 base_out_path + ['average_frequency_hz'], 173 base_src_path + ['cpus', cpu_idx, 'freq_hz'])) 174 metrics.append(ConstructMetric( 175 base_out_path + ['idle_percent'], 176 base_src_path + ['c_state_ratio'])) 177 cpu_num += 1 178 179 # -------- Parse Data Out of Plists -------- 180 plist = PowerMetricsPowerMonitor._ParsePlistString(raw_plists[0]) 181 if not plist: 182 logging.error('Error parsing plist.') 183 return {} 184 185 # Duration of this sample. 186 sample_duration_ms = int(plist['elapsed_ns']) / 10 ** 6 187 sample_durations.append(sample_duration_ms) 188 189 if 'processor' not in plist: 190 logging.error("'processor' field not found in plist.") 191 return {} 192 processor = plist['processor'] 193 194 total_energy_consumption_mwh = ( 195 (float(processor.get('package_joules', 0)) / 3600.) * 10 ** 3) 196 197 for m in metrics: 198 try: 199 m.samples.append(DataWithMetricKeyPath(m, plist)) 200 except KeyError: 201 # Old CPUs don't have c-states, so if data is missing, just ignore it. 202 logging.info('Field missing from powermetrics output: %s', m.src_path) 203 continue 204 205 # -------- Collect and Process Data -------- 206 out_dict = {} 207 out_dict['identifier'] = 'powermetrics' 208 out_dict['energy_consumption_mwh'] = total_energy_consumption_mwh 209 210 def StoreMetricAverage(metric, sample_durations, out): 211 """Calculate average value of samples in a metric and store in output 212 path as specified by metric. 213 214 Args: 215 metric: A RunningAverage object containing samples to average. 216 sample_durations: A list which parallels the samples list containing 217 the time slice for each sample. 218 out: The output dicat, average is stored in the location specified by 219 metric.out_path. 220 """ 221 if len(metric.samples) == 0: 222 return 223 224 assert len(metric.samples) == len(sample_durations) 225 avg = 0 226 for i in xrange(len(metric.samples)): 227 avg += metric.samples[i] * sample_durations[i] 228 avg /= sum(sample_durations) 229 230 # Store data in output, creating empty dictionaries as we go. 231 for k in metric.out_path[:-1]: 232 if not out.has_key(k): 233 out[k] = {} 234 out = out[k] 235 out[metric.out_path[-1]] = avg 236 237 for m in metrics: 238 StoreMetricAverage(m, sample_durations, out_dict) 239 return out_dict 240 241 def _KillPowerMetricsProcess(self): 242 """Kill a running powermetrics process.""" 243 try: 244 if self._powermetrics_process.poll() is None: 245 self._powermetrics_process.terminate() 246 except OSError as e: 247 logging.warning( 248 'Error when trying to terminate powermetric process: %s', repr(e)) 249 if self._powermetrics_process.poll() is None: 250 # terminate() can fail when Powermetrics does not have the SetUID set. 251 self._backend.LaunchApplication( 252 '/usr/bin/pkill', 253 ['-SIGTERM', os.path.basename(self.binary_path)], 254 elevate_privilege=True) 255 256 def StopMonitoringPower(self): 257 self._CheckStop() 258 # Tell powermetrics to take an immediate sample. 259 try: 260 self._KillPowerMetricsProcess() 261 (power_stdout, power_stderr) = self._powermetrics_process.communicate() 262 returncode = self._powermetrics_process.returncode 263 assert returncode in [0, -15], ( 264 """powermetrics error 265 return code=%d 266 stdout=(%s) 267 stderr=(%s)""" % (returncode, power_stdout, power_stderr)) 268 269 with open(self._output_filename, 'rb') as output_file: 270 powermetrics_output = output_file.read() 271 return PowerMetricsPowerMonitor.ParsePowerMetricsOutput( 272 powermetrics_output) 273 except Exception as e: 274 logging.warning( 275 'Error when trying to collect power monitoring data: %s', repr(e)) 276 return PowerMetricsPowerMonitor.ParsePowerMetricsOutput('') 277 finally: 278 shutil.rmtree(self._output_directory) 279 self._output_directory = None 280 self._output_filename = None 281 self._powermetrics_process = None 282