| # SPDX-License-Identifier: Apache-2.0 |
| # |
| # Copyright (C) 2015, ARM Limited and contributors. |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); you may |
| # not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| # |
| |
| import devlib |
| import json |
| import os |
| import psutil |
| import time |
| import logging |
| |
| from collections import namedtuple |
| from subprocess import Popen, PIPE, STDOUT |
| from time import sleep |
| |
| import numpy as np |
| import pandas as pd |
| |
| from bart.common.Utils import area_under_curve |
| |
| # Default energy measurements for each board |
| DEFAULT_ENERGY_METER = { |
| |
| # ARM TC2: by default use HWMON |
| 'tc2' : { |
| 'instrument' : 'hwmon', |
| 'channel_map' : { |
| 'LITTLE' : 'A7 Jcore', |
| 'big' : 'A15 Jcore', |
| } |
| }, |
| |
| # ARM Juno: by default use HWMON |
| 'juno' : { |
| 'instrument' : 'hwmon', |
| # if the channels do not contain a core name we can match to the |
| # little/big cores on the board, use a channel_map section to |
| # indicate which channel is which |
| 'channel_map' : { |
| 'LITTLE' : 'BOARDLITTLE', |
| 'big' : 'BOARDBIG', |
| } |
| }, |
| |
| } |
| |
| EnergyReport = namedtuple('EnergyReport', |
| ['channels', 'report_file', 'data_frame']) |
| |
| class EnergyMeter(object): |
| |
| _meter = None |
| |
| def __init__(self, target, res_dir=None): |
| self._target = target |
| self._res_dir = res_dir |
| if not self._res_dir: |
| self._res_dir = '/tmp' |
| |
| # Setup logging |
| self._log = logging.getLogger('EnergyMeter') |
| |
| @staticmethod |
| def getInstance(target, conf, force=False, res_dir=None): |
| |
| if not force and EnergyMeter._meter: |
| return EnergyMeter._meter |
| |
| log = logging.getLogger('EnergyMeter') |
| |
| # Initialize energy meter based on configuration |
| if 'emeter' in conf: |
| emeter = conf['emeter'] |
| log.debug('using user-defined configuration') |
| |
| # Initialize energy probe to board default |
| elif 'board' in conf and \ |
| conf['board'] in DEFAULT_ENERGY_METER: |
| emeter = DEFAULT_ENERGY_METER[conf['board']] |
| log.debug('using default energy meter for [%s]', |
| conf['board']) |
| else: |
| return None |
| |
| if emeter['instrument'] == 'hwmon': |
| EnergyMeter._meter = HWMon(target, emeter, res_dir) |
| elif emeter['instrument'] == 'aep': |
| EnergyMeter._meter = AEP(target, emeter, res_dir) |
| elif emeter['instrument'] == 'monsoon': |
| EnergyMeter._meter = Monsoon(target, emeter, res_dir) |
| elif emeter['instrument'] == 'acme': |
| EnergyMeter._meter = ACME(target, emeter, res_dir) |
| |
| log.debug('Results dir: %s', res_dir) |
| return EnergyMeter._meter |
| |
| def sample(self): |
| raise NotImplementedError('Missing implementation') |
| |
| def reset(self): |
| raise NotImplementedError('Missing implementation') |
| |
| def report(self, out_dir): |
| raise NotImplementedError('Missing implementation') |
| |
| class HWMon(EnergyMeter): |
| |
| def __init__(self, target, conf=None, res_dir=None): |
| super(HWMon, self).__init__(target, res_dir) |
| |
| # The HWMon energy meter |
| self._hwmon = None |
| |
| # Energy readings |
| self.readings = {} |
| |
| if 'hwmon' not in self._target.modules: |
| self._log.info('HWMON module not enabled') |
| self._log.warning('Energy sampling disabled by configuration') |
| return |
| |
| # Initialize HWMON instrument |
| self._log.info('Scanning for HWMON channels, may take some time...') |
| self._hwmon = devlib.HwmonInstrument(self._target) |
| |
| # Decide which channels we'll collect data from. |
| # If the caller provided a channel_map, require that all the named |
| # channels exist. |
| # Otherwise, try using the big.LITTLE core names as channel names. |
| # If they don't match, just collect all available channels. |
| |
| available_sites = [c.site for c in self._hwmon.get_channels('energy')] |
| |
| self._channels = conf.get('channel_map') |
| if self._channels: |
| # If the user provides a channel_map then require it to be correct. |
| if not all (s in available_sites for s in self._channels.values()): |
| raise RuntimeError( |
| "Found sites {} but channel_map contains {}".format( |
| sorted(available_sites), sorted(self._channels.values()))) |
| elif self._target.big_core: |
| bl_sites = [self._target.big_core.upper(), |
| self._target.little_core.upper()] |
| if all(s in available_sites for s in bl_sites): |
| self._log.info('Using default big.LITTLE hwmon channels') |
| self._channels = dict(zip(['big', 'LITTLE'], bl_sites)) |
| |
| if not self._channels: |
| self._log.info('Using all hwmon energy channels') |
| self._channels = {site: site for site in available_sites} |
| |
| # Configure channels for energy measurements |
| self._log.debug('Enabling channels %s', self._channels.values()) |
| self._hwmon.reset(kinds=['energy'], sites=self._channels.values()) |
| |
| # Logging enabled channels |
| self._log.info('Channels selected for energy sampling:') |
| for channel in self._hwmon.active_channels: |
| self._log.info(' %s', channel.label) |
| |
| |
| def sample(self): |
| if self._hwmon is None: |
| return None |
| samples = self._hwmon.take_measurement() |
| for s in samples: |
| site = s.channel.site |
| value = s.value |
| |
| if site not in self.readings: |
| self.readings[site] = { |
| 'last' : value, |
| 'delta' : 0, |
| 'total' : 0 |
| } |
| continue |
| |
| self.readings[site]['delta'] = value - self.readings[site]['last'] |
| self.readings[site]['last'] = value |
| self.readings[site]['total'] += self.readings[site]['delta'] |
| |
| self._log.debug('SAMPLE: %s', self.readings) |
| return self.readings |
| |
| def reset(self): |
| if self._hwmon is None: |
| return |
| self.sample() |
| for site in self.readings: |
| self.readings[site]['delta'] = 0 |
| self.readings[site]['total'] = 0 |
| self._log.debug('RESET: %s', self.readings) |
| |
| def report(self, out_dir, out_file='energy.json'): |
| if self._hwmon is None: |
| return (None, None) |
| # Retrive energy consumption data |
| nrg = self.sample() |
| # Reformat data for output generation |
| clusters_nrg = {} |
| for channel, site in self._channels.iteritems(): |
| if site not in nrg: |
| raise RuntimeError('hwmon channel "{}" not available. ' |
| 'Selected channels: {}'.format( |
| channel, nrg.keys())) |
| nrg_total = nrg[site]['total'] |
| self._log.debug('Energy [%16s]: %.6f', site, nrg_total) |
| clusters_nrg[channel] = nrg_total |
| |
| # Dump data as JSON file |
| nrg_file = '{}/{}'.format(out_dir, out_file) |
| with open(nrg_file, 'w') as ofile: |
| json.dump(clusters_nrg, ofile, sort_keys=True, indent=4) |
| |
| return EnergyReport(clusters_nrg, nrg_file, None) |
| |
| class _DevlibContinuousEnergyMeter(EnergyMeter): |
| """Common functionality for devlib Instruments in CONTINUOUS mode""" |
| |
| def reset(self): |
| self._instrument.start() |
| |
| def report(self, out_dir, out_energy='energy.json', out_samples='samples.csv'): |
| self._instrument.stop() |
| |
| csv_path = os.path.join(out_dir, out_samples) |
| csv_data = self._instrument.get_data(csv_path) |
| with open(csv_path) as f: |
| # Each column in the CSV will be headed with 'SITE_measure' |
| # (e.g. 'BAT_power'). Convert that to a list of ('SITE', 'measure') |
| # tuples, then pass that as the `names` parameter to read_csv to get |
| # a nested column index. None of devlib's standard measurement types |
| # have '_' in the name so this use of rsplit should be fine. |
| exp_headers = [c.label for c in csv_data.channels] |
| headers = f.readline().strip().split(',') |
| if set(headers) != set(exp_headers): |
| raise ValueError( |
| 'Unexpected headers in CSV from devlib instrument. ' |
| 'Expected {}, found {}'.format(sorted(headers), |
| sorted(exp_headers))) |
| columns = [tuple(h.rsplit('_', 1)) for h in headers] |
| # Passing `names` means read_csv doesn't expect to find headers in |
| # the CSV (i.e. expects every line to hold data). This works because |
| # we have already consumed the first line of `f`. |
| df = pd.read_csv(f, names=columns) |
| |
| sample_period = 1. / self._instrument.sample_rate_hz |
| df.index = np.linspace(0, sample_period * len(df), num=len(df)) |
| |
| if df.empty: |
| raise RuntimeError('No energy data collected') |
| |
| channels_nrg = {} |
| for site, measure in df: |
| if measure == 'power': |
| channels_nrg[site] = area_under_curve(df[site]['power']) |
| |
| # Dump data as JSON file |
| nrg_file = '{}/{}'.format(out_dir, out_energy) |
| with open(nrg_file, 'w') as ofile: |
| json.dump(channels_nrg, ofile, sort_keys=True, indent=4) |
| |
| return EnergyReport(channels_nrg, nrg_file, df) |
| |
| class AEP(_DevlibContinuousEnergyMeter): |
| |
| def __init__(self, target, conf, res_dir): |
| super(AEP, self).__init__(target, res_dir) |
| |
| # Configure channels for energy measurements |
| self._log.info('AEP configuration') |
| self._log.info(' %s', conf) |
| self._instrument = devlib.EnergyProbeInstrument( |
| self._target, labels=conf.get('channel_map'), **conf['conf']) |
| |
| # Configure channels for energy measurements |
| self._log.debug('Enabling channels') |
| self._instrument.reset() |
| |
| # Logging enabled channels |
| self._log.info('Channels selected for energy sampling:') |
| self._log.info(' %s', str(self._instrument.active_channels)) |
| self._log.debug('Results dir: %s', self._res_dir) |
| |
| class Monsoon(_DevlibContinuousEnergyMeter): |
| """ |
| Monsoon Solutions energy monitor |
| """ |
| |
| def __init__(self, target, conf, res_dir): |
| super(Monsoon, self).__init__(target, res_dir) |
| |
| self._instrument = devlib.MonsoonInstrument(self._target, **conf['conf']) |
| self._instrument.reset() |
| |
| _acme_install_instructions = ''' |
| |
| If you need to measure energy using an ACME EnergyProbe, |
| please do follow installation instructions available here: |
| https://github.com/ARM-software/lisa/wiki/Energy-Meters-Requirements#iiocapture---baylibre-acme-cape |
| |
| Othwerwise, please select a different energy meter in your |
| configuration file. |
| |
| ''' |
| |
| class ACME(EnergyMeter): |
| """ |
| BayLibre's ACME board based EnergyMeter |
| """ |
| |
| def __init__(self, target, conf, res_dir): |
| super(ACME, self).__init__(target, res_dir) |
| |
| # Assume iio-capture is available in PATH |
| iioc = conf.get('conf', { |
| 'iio-capture' : 'iio-capture', |
| 'ip_address' : 'baylibre-acme.local', |
| }) |
| self._iiocapturebin = iioc.get('iio-capture', 'iio-capture') |
| self._hostname = iioc.get('ip_address', 'baylibre-acme.local') |
| |
| self._channels = conf.get('channel_map', { |
| 'CH0': '0' |
| }) |
| self._iio = {} |
| |
| self._log.info('ACME configuration:') |
| self._log.info(' binary: %s', self._iiocapturebin) |
| self._log.info(' device: %s', self._hostname) |
| self._log.info(' channels:') |
| for channel in self._channels: |
| self._log.info(' %s', self._str(channel)) |
| |
| # Check if iio-capture binary is available |
| try: |
| p = Popen([self._iiocapturebin, '-h'], stdout=PIPE, stderr=STDOUT) |
| except: |
| self._log.error('iio-capture binary [%s] not available', |
| self._iiocapturebin) |
| self._log.warning(_acme_install_instructions) |
| raise RuntimeError('Missing iio-capture binary') |
| |
| def sample(self): |
| raise NotImplementedError('Not available for ACME') |
| |
| def _iio_device(self, channel): |
| return 'iio:device{}'.format(self._channels[channel]) |
| |
| def _str(self, channel): |
| return '{} ({})'.format(channel, self._iio_device(channel)) |
| |
| def reset(self): |
| """ |
| Reset energy meter and start sampling from channels specified in the |
| target configuration. |
| """ |
| # Terminate already running iio-capture instance (if any) |
| wait_for_termination = 0 |
| for proc in psutil.process_iter(): |
| if self._iiocapturebin not in proc.cmdline(): |
| continue |
| for channel in self._channels: |
| if self._iio_device(channel) in proc.cmdline(): |
| self._log.debug('Killing previous iio-capture for [%s]', |
| self._iio_device(channel)) |
| self._log.debug(proc.cmdline()) |
| proc.kill() |
| wait_for_termination = 2 |
| |
| # Wait for previous instances to be killed |
| sleep(wait_for_termination) |
| |
| # Start iio-capture for all channels required |
| for channel in self._channels: |
| ch_id = self._channels[channel] |
| |
| # Setup CSV file to collect samples for this channel |
| csv_file = '{}/{}'.format( |
| self._res_dir, |
| 'samples_{}.csv'.format(channel) |
| ) |
| |
| # Start a dedicated iio-capture instance for this channel |
| self._iio[ch_id] = Popen([self._iiocapturebin, '-n', |
| self._hostname, '-o', |
| '-c', '-f', |
| csv_file, |
| self._iio_device(channel)], |
| stdout=PIPE, stderr=STDOUT) |
| |
| # Wait few milliseconds before to check if there is any output |
| sleep(1) |
| |
| # Check that all required channels have been started |
| for channel in self._channels: |
| ch_id = self._channels[channel] |
| |
| self._iio[ch_id].poll() |
| if self._iio[ch_id].returncode: |
| self._log.error('Failed to run %s for %s', |
| self._iiocapturebin, self._str(channel)) |
| self._log.warning('\n\n'\ |
| ' Make sure there are no iio-capture processes\n'\ |
| ' connected to %s and device %s\n', |
| self._hostname, self._str(channel)) |
| out, _ = self._iio[ch_id].communicate() |
| self._log.error('Output: [%s]', out.strip()) |
| self._iio[ch_id] = None |
| raise RuntimeError('iio-capture connection error') |
| |
| self._log.debug('Started %s on %s...', |
| self._iiocapturebin, self._str(channel)) |
| |
| def report(self, out_dir, out_energy='energy.json'): |
| """ |
| Stop iio-capture and collect sampled data. |
| |
| :param out_dir: Output directory where to store results |
| :type out_dir: str |
| |
| :param out_file: File name where to save energy data |
| :type out_file: str |
| """ |
| channels_nrg = {} |
| channels_stats = {} |
| for channel in self._channels: |
| ch_id = self._channels[channel] |
| |
| if self._iio[ch_id] is None: |
| continue |
| |
| self._iio[ch_id].poll() |
| if self._iio[ch_id].returncode: |
| # returncode not None means that iio-capture has terminated |
| # already, so there must have been an error |
| self._log.error('%s terminated for %s', |
| self._iiocapturebin, self._str(channel)) |
| out, _ = self._iio[ch_id].communicate() |
| self._log.error('[%s]', out) |
| self._iio[ch_id] = None |
| continue |
| |
| # kill process and get return |
| self._iio[ch_id].terminate() |
| out, _ = self._iio[ch_id].communicate() |
| self._iio[ch_id].wait() |
| self._iio[ch_id] = None |
| |
| self._log.debug('Completed IIOCapture for %s...', |
| self._str(channel)) |
| |
| # iio-capture return "energy=value", add a simple format check |
| if '=' not in out: |
| self._log.error('Bad output format for %s:', |
| self._str(channel)) |
| self._log.error('[%s]', out) |
| continue |
| |
| # Build energy counter object |
| nrg = {} |
| for kv_pair in out.split(): |
| key, val = kv_pair.partition('=')[::2] |
| nrg[key] = float(val) |
| channels_stats[channel] = nrg |
| |
| self._log.debug(self._str(channel)) |
| self._log.debug(nrg) |
| |
| # Save CSV samples file to out_dir |
| os.system('mv {}/samples_{}.csv {}' |
| .format(self._res_dir, channel, out_dir)) |
| |
| # Add channel's energy to return results |
| channels_nrg['{}'.format(channel)] = nrg['energy'] |
| |
| # Dump energy data |
| nrg_file = '{}/{}'.format(out_dir, out_energy) |
| with open(nrg_file, 'w') as ofile: |
| json.dump(channels_nrg, ofile, sort_keys=True, indent=4) |
| |
| # Dump energy stats |
| nrg_stats_file = os.path.splitext(out_energy)[0] + \ |
| '_stats' + os.path.splitext(out_energy)[1] |
| nrg_stats_file = '{}/{}'.format(out_dir, nrg_stats_file) |
| with open(nrg_stats_file, 'w') as ofile: |
| json.dump(channels_stats, ofile, sort_keys=True, indent=4) |
| |
| return EnergyReport(channels_nrg, nrg_file, None) |
| |
| # vim :set tabstop=4 shiftwidth=4 expandtab |