1# SPDX-License-Identifier: Apache-2.0 2# 3# Copyright (C) 2015, ARM Limited and contributors. 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); you may 6# not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 13# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16# 17 18import devlib 19import json 20import os 21import psutil 22import time 23import logging 24 25from collections import namedtuple 26from subprocess import Popen, PIPE, STDOUT 27from time import sleep 28 29import numpy as np 30import pandas as pd 31 32from bart.common.Utils import area_under_curve 33 34# Default energy measurements for each board 35DEFAULT_ENERGY_METER = { 36 37 # ARM TC2: by default use HWMON 38 'tc2' : { 39 'instrument' : 'hwmon', 40 'channel_map' : { 41 'LITTLE' : 'A7 Jcore', 42 'big' : 'A15 Jcore', 43 } 44 }, 45 46 # ARM Juno: by default use HWMON 47 'juno' : { 48 'instrument' : 'hwmon', 49 # if the channels do not contain a core name we can match to the 50 # little/big cores on the board, use a channel_map section to 51 # indicate which channel is which 52 'channel_map' : { 53 'LITTLE' : 'BOARDLITTLE', 54 'big' : 'BOARDBIG', 55 } 56 }, 57 58} 59 60EnergyReport = namedtuple('EnergyReport', 61 ['channels', 'report_file', 'data_frame']) 62 63class EnergyMeter(object): 64 65 _meter = None 66 67 def __init__(self, target, res_dir=None): 68 self._target = target 69 self._res_dir = res_dir 70 if not self._res_dir: 71 self._res_dir = '/tmp' 72 73 # Setup logging 74 self._log = logging.getLogger('EnergyMeter') 75 76 @staticmethod 77 def getInstance(target, conf, force=False, res_dir=None): 78 79 if not force and EnergyMeter._meter: 80 return EnergyMeter._meter 81 82 log = logging.getLogger('EnergyMeter') 83 84 # Initialize energy meter based on configuration 85 if 'emeter' in conf: 86 emeter = conf['emeter'] 87 log.debug('using user-defined configuration') 88 89 # Initialize energy probe to board default 90 elif 'board' in conf and \ 91 conf['board'] in DEFAULT_ENERGY_METER: 92 emeter = DEFAULT_ENERGY_METER[conf['board']] 93 log.debug('using default energy meter for [%s]', 94 conf['board']) 95 else: 96 return None 97 98 if emeter['instrument'] == 'hwmon': 99 EnergyMeter._meter = HWMon(target, emeter, res_dir) 100 elif emeter['instrument'] == 'aep': 101 EnergyMeter._meter = AEP(target, emeter, res_dir) 102 elif emeter['instrument'] == 'monsoon': 103 EnergyMeter._meter = Monsoon(target, emeter, res_dir) 104 elif emeter['instrument'] == 'acme': 105 EnergyMeter._meter = ACME(target, emeter, res_dir) 106 107 log.debug('Results dir: %s', res_dir) 108 return EnergyMeter._meter 109 110 def sample(self): 111 raise NotImplementedError('Missing implementation') 112 113 def reset(self): 114 raise NotImplementedError('Missing implementation') 115 116 def report(self, out_dir): 117 raise NotImplementedError('Missing implementation') 118 119class HWMon(EnergyMeter): 120 121 def __init__(self, target, conf=None, res_dir=None): 122 super(HWMon, self).__init__(target, res_dir) 123 124 # The HWMon energy meter 125 self._hwmon = None 126 127 # Energy readings 128 self.readings = {} 129 130 if 'hwmon' not in self._target.modules: 131 self._log.info('HWMON module not enabled') 132 self._log.warning('Energy sampling disabled by configuration') 133 return 134 135 # Initialize HWMON instrument 136 self._log.info('Scanning for HWMON channels, may take some time...') 137 self._hwmon = devlib.HwmonInstrument(self._target) 138 139 # Decide which channels we'll collect data from. 140 # If the caller provided a channel_map, require that all the named 141 # channels exist. 142 # Otherwise, try using the big.LITTLE core names as channel names. 143 # If they don't match, just collect all available channels. 144 145 available_sites = [c.site for c in self._hwmon.get_channels('energy')] 146 147 self._channels = conf.get('channel_map') 148 if self._channels: 149 # If the user provides a channel_map then require it to be correct. 150 if not all (s in available_sites for s in self._channels.values()): 151 raise RuntimeError( 152 "Found sites {} but channel_map contains {}".format( 153 sorted(available_sites), sorted(self._channels.values()))) 154 elif self._target.big_core: 155 bl_sites = [self._target.big_core.upper(), 156 self._target.little_core.upper()] 157 if all(s in available_sites for s in bl_sites): 158 self._log.info('Using default big.LITTLE hwmon channels') 159 self._channels = dict(zip(['big', 'LITTLE'], bl_sites)) 160 161 if not self._channels: 162 self._log.info('Using all hwmon energy channels') 163 self._channels = {site: site for site in available_sites} 164 165 # Configure channels for energy measurements 166 self._log.debug('Enabling channels %s', self._channels.values()) 167 self._hwmon.reset(kinds=['energy'], sites=self._channels.values()) 168 169 # Logging enabled channels 170 self._log.info('Channels selected for energy sampling:') 171 for channel in self._hwmon.active_channels: 172 self._log.info(' %s', channel.label) 173 174 175 def sample(self): 176 if self._hwmon is None: 177 return None 178 samples = self._hwmon.take_measurement() 179 for s in samples: 180 site = s.channel.site 181 value = s.value 182 183 if site not in self.readings: 184 self.readings[site] = { 185 'last' : value, 186 'delta' : 0, 187 'total' : 0 188 } 189 continue 190 191 self.readings[site]['delta'] = value - self.readings[site]['last'] 192 self.readings[site]['last'] = value 193 self.readings[site]['total'] += self.readings[site]['delta'] 194 195 self._log.debug('SAMPLE: %s', self.readings) 196 return self.readings 197 198 def reset(self): 199 if self._hwmon is None: 200 return 201 self.sample() 202 for site in self.readings: 203 self.readings[site]['delta'] = 0 204 self.readings[site]['total'] = 0 205 self._log.debug('RESET: %s', self.readings) 206 207 def report(self, out_dir, out_file='energy.json'): 208 if self._hwmon is None: 209 return (None, None) 210 # Retrive energy consumption data 211 nrg = self.sample() 212 # Reformat data for output generation 213 clusters_nrg = {} 214 for channel, site in self._channels.iteritems(): 215 if site not in nrg: 216 raise RuntimeError('hwmon channel "{}" not available. ' 217 'Selected channels: {}'.format( 218 channel, nrg.keys())) 219 nrg_total = nrg[site]['total'] 220 self._log.debug('Energy [%16s]: %.6f', site, nrg_total) 221 clusters_nrg[channel] = nrg_total 222 223 # Dump data as JSON file 224 nrg_file = '{}/{}'.format(out_dir, out_file) 225 with open(nrg_file, 'w') as ofile: 226 json.dump(clusters_nrg, ofile, sort_keys=True, indent=4) 227 228 return EnergyReport(clusters_nrg, nrg_file, None) 229 230class _DevlibContinuousEnergyMeter(EnergyMeter): 231 """Common functionality for devlib Instruments in CONTINUOUS mode""" 232 233 def reset(self): 234 self._instrument.start() 235 236 def report(self, out_dir, out_energy='energy.json', out_samples='samples.csv'): 237 self._instrument.stop() 238 239 csv_path = os.path.join(out_dir, out_samples) 240 csv_data = self._instrument.get_data(csv_path) 241 with open(csv_path) as f: 242 # Each column in the CSV will be headed with 'SITE_measure' 243 # (e.g. 'BAT_power'). Convert that to a list of ('SITE', 'measure') 244 # tuples, then pass that as the `names` parameter to read_csv to get 245 # a nested column index. None of devlib's standard measurement types 246 # have '_' in the name so this use of rsplit should be fine. 247 exp_headers = [c.label for c in csv_data.channels] 248 headers = f.readline().strip().split(',') 249 if set(headers) != set(exp_headers): 250 raise ValueError( 251 'Unexpected headers in CSV from devlib instrument. ' 252 'Expected {}, found {}'.format(sorted(headers), 253 sorted(exp_headers))) 254 columns = [tuple(h.rsplit('_', 1)) for h in headers] 255 # Passing `names` means read_csv doesn't expect to find headers in 256 # the CSV (i.e. expects every line to hold data). This works because 257 # we have already consumed the first line of `f`. 258 df = pd.read_csv(f, names=columns) 259 260 sample_period = 1. / self._instrument.sample_rate_hz 261 df.index = np.linspace(0, sample_period * len(df), num=len(df)) 262 263 if df.empty: 264 raise RuntimeError('No energy data collected') 265 266 channels_nrg = {} 267 for site, measure in df: 268 if measure == 'power': 269 channels_nrg[site] = area_under_curve(df[site]['power']) 270 271 # Dump data as JSON file 272 nrg_file = '{}/{}'.format(out_dir, out_energy) 273 with open(nrg_file, 'w') as ofile: 274 json.dump(channels_nrg, ofile, sort_keys=True, indent=4) 275 276 return EnergyReport(channels_nrg, nrg_file, df) 277 278class AEP(_DevlibContinuousEnergyMeter): 279 280 def __init__(self, target, conf, res_dir): 281 super(AEP, self).__init__(target, res_dir) 282 283 # Configure channels for energy measurements 284 self._log.info('AEP configuration') 285 self._log.info(' %s', conf) 286 self._instrument = devlib.EnergyProbeInstrument( 287 self._target, labels=conf.get('channel_map'), **conf['conf']) 288 289 # Configure channels for energy measurements 290 self._log.debug('Enabling channels') 291 self._instrument.reset() 292 293 # Logging enabled channels 294 self._log.info('Channels selected for energy sampling:') 295 self._log.info(' %s', str(self._instrument.active_channels)) 296 self._log.debug('Results dir: %s', self._res_dir) 297 298class Monsoon(_DevlibContinuousEnergyMeter): 299 """ 300 Monsoon Solutions energy monitor 301 """ 302 303 def __init__(self, target, conf, res_dir): 304 super(Monsoon, self).__init__(target, res_dir) 305 306 self._instrument = devlib.MonsoonInstrument(self._target, **conf['conf']) 307 self._instrument.reset() 308 309_acme_install_instructions = ''' 310 311 If you need to measure energy using an ACME EnergyProbe, 312 please do follow installation instructions available here: 313 https://github.com/ARM-software/lisa/wiki/Energy-Meters-Requirements#iiocapture---baylibre-acme-cape 314 315 Othwerwise, please select a different energy meter in your 316 configuration file. 317 318''' 319 320class ACME(EnergyMeter): 321 """ 322 BayLibre's ACME board based EnergyMeter 323 """ 324 325 def __init__(self, target, conf, res_dir): 326 super(ACME, self).__init__(target, res_dir) 327 328 # Assume iio-capture is available in PATH 329 iioc = conf.get('conf', { 330 'iio-capture' : 'iio-capture', 331 'ip_address' : 'baylibre-acme.local', 332 }) 333 self._iiocapturebin = iioc.get('iio-capture', 'iio-capture') 334 self._hostname = iioc.get('ip_address', 'baylibre-acme.local') 335 336 self._channels = conf.get('channel_map', { 337 'CH0': '0' 338 }) 339 self._iio = {} 340 341 self._log.info('ACME configuration:') 342 self._log.info(' binary: %s', self._iiocapturebin) 343 self._log.info(' device: %s', self._hostname) 344 self._log.info(' channels:') 345 for channel in self._channels: 346 self._log.info(' %s', self._str(channel)) 347 348 # Check if iio-capture binary is available 349 try: 350 p = Popen([self._iiocapturebin, '-h'], stdout=PIPE, stderr=STDOUT) 351 except: 352 self._log.error('iio-capture binary [%s] not available', 353 self._iiocapturebin) 354 self._log.warning(_acme_install_instructions) 355 raise RuntimeError('Missing iio-capture binary') 356 357 def sample(self): 358 raise NotImplementedError('Not available for ACME') 359 360 def _iio_device(self, channel): 361 return 'iio:device{}'.format(self._channels[channel]) 362 363 def _str(self, channel): 364 return '{} ({})'.format(channel, self._iio_device(channel)) 365 366 def reset(self): 367 """ 368 Reset energy meter and start sampling from channels specified in the 369 target configuration. 370 """ 371 # Terminate already running iio-capture instance (if any) 372 wait_for_termination = 0 373 for proc in psutil.process_iter(): 374 if self._iiocapturebin not in proc.cmdline(): 375 continue 376 for channel in self._channels: 377 if self._iio_device(channel) in proc.cmdline(): 378 self._log.debug('Killing previous iio-capture for [%s]', 379 self._iio_device(channel)) 380 self._log.debug(proc.cmdline()) 381 proc.kill() 382 wait_for_termination = 2 383 384 # Wait for previous instances to be killed 385 sleep(wait_for_termination) 386 387 # Start iio-capture for all channels required 388 for channel in self._channels: 389 ch_id = self._channels[channel] 390 391 # Setup CSV file to collect samples for this channel 392 csv_file = '{}/{}'.format( 393 self._res_dir, 394 'samples_{}.csv'.format(channel) 395 ) 396 397 # Start a dedicated iio-capture instance for this channel 398 self._iio[ch_id] = Popen([self._iiocapturebin, '-n', 399 self._hostname, '-o', 400 '-c', '-f', 401 csv_file, 402 self._iio_device(channel)], 403 stdout=PIPE, stderr=STDOUT) 404 405 # Wait few milliseconds before to check if there is any output 406 sleep(1) 407 408 # Check that all required channels have been started 409 for channel in self._channels: 410 ch_id = self._channels[channel] 411 412 self._iio[ch_id].poll() 413 if self._iio[ch_id].returncode: 414 self._log.error('Failed to run %s for %s', 415 self._iiocapturebin, self._str(channel)) 416 self._log.warning('\n\n'\ 417 ' Make sure there are no iio-capture processes\n'\ 418 ' connected to %s and device %s\n', 419 self._hostname, self._str(channel)) 420 out, _ = self._iio[ch_id].communicate() 421 self._log.error('Output: [%s]', out.strip()) 422 self._iio[ch_id] = None 423 raise RuntimeError('iio-capture connection error') 424 425 self._log.debug('Started %s on %s...', 426 self._iiocapturebin, self._str(channel)) 427 428 def report(self, out_dir, out_energy='energy.json'): 429 """ 430 Stop iio-capture and collect sampled data. 431 432 :param out_dir: Output directory where to store results 433 :type out_dir: str 434 435 :param out_file: File name where to save energy data 436 :type out_file: str 437 """ 438 channels_nrg = {} 439 channels_stats = {} 440 for channel in self._channels: 441 ch_id = self._channels[channel] 442 443 if self._iio[ch_id] is None: 444 continue 445 446 self._iio[ch_id].poll() 447 if self._iio[ch_id].returncode: 448 # returncode not None means that iio-capture has terminated 449 # already, so there must have been an error 450 self._log.error('%s terminated for %s', 451 self._iiocapturebin, self._str(channel)) 452 out, _ = self._iio[ch_id].communicate() 453 self._log.error('[%s]', out) 454 self._iio[ch_id] = None 455 continue 456 457 # kill process and get return 458 self._iio[ch_id].terminate() 459 out, _ = self._iio[ch_id].communicate() 460 self._iio[ch_id].wait() 461 self._iio[ch_id] = None 462 463 self._log.debug('Completed IIOCapture for %s...', 464 self._str(channel)) 465 466 # iio-capture return "energy=value", add a simple format check 467 if '=' not in out: 468 self._log.error('Bad output format for %s:', 469 self._str(channel)) 470 self._log.error('[%s]', out) 471 continue 472 473 # Build energy counter object 474 nrg = {} 475 for kv_pair in out.split(): 476 key, val = kv_pair.partition('=')[::2] 477 nrg[key] = float(val) 478 channels_stats[channel] = nrg 479 480 self._log.debug(self._str(channel)) 481 self._log.debug(nrg) 482 483 # Save CSV samples file to out_dir 484 os.system('mv {}/samples_{}.csv {}' 485 .format(self._res_dir, channel, out_dir)) 486 487 # Add channel's energy to return results 488 channels_nrg['{}'.format(channel)] = nrg['energy'] 489 490 # Dump energy data 491 nrg_file = '{}/{}'.format(out_dir, out_energy) 492 with open(nrg_file, 'w') as ofile: 493 json.dump(channels_nrg, ofile, sort_keys=True, indent=4) 494 495 # Dump energy stats 496 nrg_stats_file = os.path.splitext(out_energy)[0] + \ 497 '_stats' + os.path.splitext(out_energy)[1] 498 nrg_stats_file = '{}/{}'.format(out_dir, nrg_stats_file) 499 with open(nrg_stats_file, 'w') as ofile: 500 json.dump(channels_stats, ofile, sort_keys=True, indent=4) 501 502 return EnergyReport(channels_nrg, nrg_file, None) 503 504# vim :set tabstop=4 shiftwidth=4 expandtab 505