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