# 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