504 lines
18 KiB
Python
504 lines
18 KiB
Python
# 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
|