Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add generic perf trace instrument #388

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 99 additions & 102 deletions devlib/trace/perf.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2018 ARM Limited
# Copyright 2018-2019 ARM Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand All @@ -11,131 +11,128 @@
# 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.
#

# pylint: disable=missing-docstring

import collections
import os
import re
from past.builtins import basestring, zip
import sys

from devlib.utils.cli import Command
from devlib.host import PACKAGE_BIN_DIRECTORY
from devlib.trace import TraceCollector
from devlib.utils.misc import ensure_file_directory_exists as _f

if sys.version_info >= (3, 0):
from shlex import quote
else:
from pipes import quote
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implies that compatibility with Python2.7 is attempted to be retained however is not present elsewhere in this PR. Is this aimed to be merged before the next release aka supporting Python 2.7 or to be held until after support has been dropped?


PERF_COMMAND_TEMPLATE = '{} stat {} {} sleep 1000 > {} 2>&1 '

PERF_COUNT_REGEX = re.compile(r'^(CPU\d+)?\s*(\d+)\s*(.*?)\s*(\[\s*\d+\.\d+%\s*\])?\s*$')
class PerfCommandDict(collections.OrderedDict):

DEFAULT_EVENTS = [
'migrations',
'cs',
]
def __init__(self, yaml_dict):
super().__init__()
self._stat_command_labels = set()
if isinstance(yaml_dict, self.__class__):
for key, val in yaml_dict.items():
self[key] = val
return
yaml_dict_copy = yaml_dict.copy()
for label, parameters in yaml_dict_copy.items():
self[label] = Command(kwflags_join=',',
kwflags_sep='=',
end_of_options='--',
**parameters)
if 'stat'in parameters['command']:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing space before in

self._stat_command_labels.add(label)


class PerfCollector(TraceCollector):
"""Perf is a Linux profiling tool based on performance counters.

Performance counters are typically CPU hardware registers (found in the
Performance Monitoring Unit) that count hardware events such as
instructions executed, cache-misses suffered, or branches mispredicted.
Because each ``event`` corresponds to a hardware counter, the maximum
number of events that can be tracked is imposed by the available hardware.

By extension, performance counters, in the context of ``perf``, also refer
to so-called "software counters" representing events that can be tracked by
the OS kernel (e.g. context switches). As these are software events, the
counters are kept in RAM and the hardware virtually imposes no limit on the
number that can be used.

This collector calls ``perf`` ``commands`` to capture a run of a workload.
The ``pre_commands`` and ``post_commands`` are provided to suit those
``perf`` commands that don't actually capture data (``list``, ``config``,
``report``, ...).

``pre_commands``, ``commands`` and ``post_commands`` are instances of
:class:`PerfCommandDict`.
"""
Perf is a Linux profiling with performance counters.

Performance counters are CPU hardware registers that count hardware events
such as instructions executed, cache-misses suffered, or branches
mispredicted. They form a basis for profiling applications to trace dynamic
control flow and identify hotspots.

pref accepts options and events. If no option is given the default '-a' is
used. For events, the default events are migrations and cs. They both can
be specified in the config file.

Events must be provided as a list that contains them and they will look like
this ::

perf_events = ['migrations', 'cs']

Events can be obtained by typing the following in the command line on the
device ::

perf list

Whereas options, they can be provided as a single string as following ::

perf_options = '-a -i'

Options can be obtained by running the following in the command line ::

man perf-stat
"""

def __init__(self, target,
events=None,
optionstring=None,
labels=None,
force_install=False):
def __init__(self, target, force_install=False, pre_commands=None,
commands=None, post_commands=None):
# pylint: disable=too-many-arguments
super(PerfCollector, self).__init__(target)
self.events = events if events else DEFAULT_EVENTS
self.force_install = force_install
self.labels = labels

# Validate parameters
if isinstance(optionstring, list):
self.optionstrings = optionstring
else:
self.optionstrings = [optionstring]
if self.events and isinstance(self.events, basestring):
self.events = [self.events]
if not self.labels:
self.labels = ['perf_{}'.format(i) for i in range(len(self.optionstrings))]
if len(self.labels) != len(self.optionstrings):
raise ValueError('The number of labels must match the number of optstrings provided for perf.')
self.pre_commands = pre_commands or PerfCommandDict({})
self.commands = commands or PerfCommandDict({})
self.post_commands = post_commands or PerfCommandDict({})

self.binary = self.target.get_installed('perf')
if self.force_install or not self.binary:
self.binary = self._deploy_perf()
if force_install or not self.binary:
host_binary = os.path.join(PACKAGE_BIN_DIRECTORY,
self.target.abi, 'perf')
self.binary = self.target.install(host_binary)

self.commands = self._build_commands()
self.kill_sleep = False

def reset(self):
super(PerfCollector, self).reset()
self.target.remove(self.working_directory())
self.target.killall('perf', as_root=self.target.is_rooted)
for label in self.labels:
filepath = self._get_target_outfile(label)
self.target.remove(filepath)

def start(self):
for command in self.commands:
self.target.kick_off(command)
super(PerfCollector, self).start()
for label, command in self.pre_commands.items():
self.execute(str(command), label)
for label, command in self.commands.items():
self.kick_off(str(command), label)
if 'sleep' in str(command):
self.kill_sleep = True

def stop(self):
super(PerfCollector, self).stop()
self.target.killall('perf', signal='SIGINT',
as_root=self.target.is_rooted)
# perf doesn't transmit the signal to its sleep call so handled here:
self.target.killall('sleep', as_root=self.target.is_rooted)
# NB: we hope that no other "important" sleep is on-going

# pylint: disable=arguments-differ
def get_trace(self, outdir):
for label in self.labels:
target_file = self._get_target_outfile(label)
host_relpath = os.path.basename(target_file)
host_file = _f(os.path.join(outdir, host_relpath))
self.target.pull(target_file, host_file)

def _deploy_perf(self):
host_executable = os.path.join(PACKAGE_BIN_DIRECTORY,
self.target.abi, 'perf')
return self.target.install(host_executable)

def _build_commands(self):
commands = []
for opts, label in zip(self.optionstrings, self.labels):
commands.append(self._build_perf_command(opts, self.events, label))
return commands

def _get_target_outfile(self, label):
return self.target.get_workpath('{}.out'.format(label))

def _build_perf_command(self, options, events, label):
event_string = ' '.join(['-e {}'.format(e) for e in events])
command = PERF_COMMAND_TEMPLATE.format(self.binary,
options or '',
event_string,
self._get_target_outfile(label))
return command
if self.kill_sleep:
self.target.killall('sleep', as_root=self.target.is_rooted)
for label, command in self.post_commands.items():
self.execute(str(command), label)

def _target_runnable_command(self, command, label=None):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Private" methods should go after the "normal" methods.

cmd = '{} {}'.format(self.binary, command)
if label is None:
return cmd
directory = quote(self.working_directory(label))
cwd = 'mkdir -p {0} && cd {0}'.format(directory)
return '{cwd} && {cmd}'.format(cwd=cwd, cmd=cmd)

def kick_off(self, command, label=None):
cmd = self._target_runnable_command(command, label)
return self.target.kick_off(cmd, as_root=self.target.is_rooted)

def execute(self, command, label=None):
cmd = self._target_runnable_command(command, label)
return self.target.execute(cmd, as_root=self.target.is_rooted)

def working_directory(self, label=None):
wdir = self.target.path.join(self.target.working_directory,
'instrument', 'perf')
return wdir if label is None else self.target.path.join(wdir, label)

def get_traces(self, host_outdir):
self.target.pull(self.working_directory(), host_outdir,
as_root=self.target.is_rooted)

def get_trace(self, outfile):
raise NotImplementedError
133 changes: 133 additions & 0 deletions devlib/utils/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Copyright 2019 ARM Limited
#
# 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 collections
import itertools
import shlex


class Command(dict): # inherit from dict for JSON serializability
"""Provides an abstraction for manipulating CLI commands

The expected format of the abstracted command is as follows::

<command> <flags> <kwflags> <options> <end_of_options> <args>

where

- `<command>` is the command name or path (used as-is);
- `<flags>` are space-separated flags with a leading `-` (single
character flag) or `--` (multiple characters);
- `<kwflags>` are space-separated key-value flag pairs with a leading
`-` (single character flag) or `--` (multiple characters), a
key-value separator (typically `=`) and, if required, a CLI-compliant
escaped value;
- `<options>` are space-separated options (used as-is);
- `<end_of_options>` is a character sequence understood by `<command>`
as meaning the end of the options (typically `--`);
- `<args>` are the arguments to the command and could potentially
themselves be a valid command (_e.g._ POSIX `time`);

If allowed by the CLI, redirecting the output streams of the command
(potentially between themselves) may be done through this abstraciton.
"""
# pylint: disable=too-many-instance-attributes
# pylint: disable=too-few-public-methods
def __init__(self, command, flags=None, kwflags=None, kwflags_sep=' ',
kwflags_join=',', options=None, end_of_options=None,
args=None, stdout=None, stderr=None):
"""
Parameters:
command command name or path
flags ``str`` or list of ``str`` without the leading `-`/`--`.
Flags that evaluate as falsy are ignored;
kwflags mapping giving the key-value pairs. The key and value of
the pair are separated by a `kwflags_sep`. If the value
is a list, it is joined with `kwflags_join`. If a value
evaluates as falsy, it is replaced by the empty string;
kwflags_sep Key-value separator for `kwflags`;
kwflags_join Separator for lists of values in `kwflags`;
options same as `flags` but nothing is prepended to the options;
args ``str`` or mapping holding keys which are valid
arguments to this constructor, for recursive
instantiation;
stdout file for redirection of ``stdout``. This is passed to
the CLI so non-file expressions may be used (*e.g.*
`&2`);
stderr file for redirection of ``stderr``. This is passed to
the CLI so non-file expressions may be used (*e.g.*
`&1`);
"""
# pylint: disable=too-many-arguments
# pylint: disable=super-init-not-called
self.command = ' '.join(shlex.split(command))
self.flags = map(str, filter(None, self._these(flags)))
self.kwflags_sep = kwflags_sep
self.kwflags_join = kwflags_join
self.kwflags = {}
if kwflags is not None:
for k in kwflags:
v = ['' if x is None else str(x)
for x in self._these(kwflags[k])]
self.kwflags[k] = v[0] if len(v) == 1 else v
self.options = [] if options is None else [
'' if x is None else str(x) for x in self._these(options)]
if end_of_options:
self.options.append(str(end_of_options).strip())
if isinstance(args, collections.Mapping):
self.args = Command(**args)
else:
self.args = None if args is None else str(args)
self.stdout = stdout
self.stderr = stderr

def __str__(self):
quoted = itertools.chain(
shlex.split(self.command),
map(self._flagged, self.flags),
('{}{}{}'.format(self._flagged(k),
self.kwflags_sep,
self.kwflags_join.join(self._these(v)))
for k, v in self.kwflags.items()),
self.options
)
words = [shlex.quote(word) for word in quoted]
if self.args:
words.append(str(self.args))
if self.stdout:
words.append('1>{}'.format(self._filepipe(self.stdout)))
if self.stderr:
words.append('2>{}'.format(self._filepipe(self.stderr)))
return ' '.join(words)

def __getitem__(self, key):
return self.__dict__[key]

@staticmethod
def _these(x):
if isinstance(x, str) or not isinstance(x, collections.abc.Iterable):
return [x]
return x

@staticmethod
def _filepipe(f):
if isinstance(f, str) and f.startswith('&'):
return f
return shlex.quote(f)

@classmethod
def _flagged(cls, flag):
flag = str(flag).strip()
return '{}{}'.format('--' if len(flag) > 1 else '-', flag)