blob: c114a0e0d825a060330ea924ceecd133b5fff9c6 [file] [log] [blame]
#!/usr/bin/env vpython3
# Copyright 2017 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Lists all the reached symbols from an instrumentation dump."""
import argparse
import collections
import logging
import operator
import os
import sys
import json
_SRC_PATH = os.path.abspath(os.path.join(
os.path.dirname(__file__), os.pardir, os.pardir))
path = os.path.join(_SRC_PATH, 'tools', 'cygprofile')
sys.path.append(path)
import symbol_extractor
def _Median(items):
if not items:
return None
sorted_items = sorted(items)
if len(sorted_items) & 1:
return sorted_items[len(sorted_items) // 2]
return (sorted_items[len(sorted_items) // 2 - 1] +
sorted_items[len(sorted_items) // 2]) // 2
class SymbolOffsetProcessor:
"""Utility for processing symbols in binaries.
This class is used to translate between general offsets into a binary and the
starting offset of symbols in the binary. Because later phases in orderfile
generation have complicated strategies for resolving multiple symbols that map
to the same binary offset, this class is concerned with locating a symbol
containing a binary offset. If such a symbol exists, the start offset will be
unique, even when there are multiple symbol names at the same location in the
binary.
In the function names below, "dump" is used to refer to arbitrary offsets in a
binary (eg, from a profiling run), while "offset" refers to a symbol
offset. The dump offsets are relative to the start of text, as produced by
orderfile_instrumentation.cc.
This class manages expensive operations like extracting symbols, so that
higher-level operations can be done in different orders without the caller
managing all the state.
"""
def __init__(self, binary_filename):
self._binary_filename = binary_filename
self._symbol_infos = None
self._name_to_symbol = None
self._offset_to_primary = None
self._offset_to_symbols = None
self._offset_to_symbol_info = None
# |_whitelist| will contain symbols whose size is 0.
self._whitelist = None
def SymbolInfos(self):
"""The symbols associated with this processor's binary.
The symbols are ordered by offset.
Returns:
[symbol_extractor.SymbolInfo]
"""
if self._symbol_infos is None:
self._symbol_infos = symbol_extractor.SymbolInfosFromBinary(
self._binary_filename)
self._symbol_infos.sort(key=lambda s: s.offset)
logging.info('%d symbols from %s',
len(self._symbol_infos), self._binary_filename)
return self._symbol_infos
def NameToSymbolMap(self):
"""Map symbol names to their full information.
Returns:
{symbol name (str): symbol_extractor.SymbolInfo}
"""
if self._name_to_symbol is None:
self._name_to_symbol = {s.name: s for s in self.SymbolInfos()}
return self._name_to_symbol
def OffsetToPrimaryMap(self):
"""The map of a symbol offset in this binary to its primary symbol.
Several symbols can be aliased to the same address, through ICF. This
returns the first one. The order is consistent for a given binary, as it's
derived from the file layout. We assert that all aliased symbols are the
same size.
Returns:
{offset (int): primary (symbol_extractor.SymbolInfo)}
"""
if self._offset_to_primary is None:
self._offset_to_primary = {}
for s in self.SymbolInfos():
if s.offset not in self._offset_to_primary:
self._offset_to_primary[s.offset] = s
else:
curr = self._offset_to_primary[s.offset]
if curr.size != s.size:
assert curr.size == 0 or s.size == 0, (
'Nonzero size mismatch between {} and {}'.format(
curr.name, s.name))
# Upgrade to a symbol with nonzero size, otherwise don't change
# anything so that we use the earliest nonzero-size symbol.
if curr.size == 0 and s.size != 0:
self._offset_to_primary[s.offset] = s
return self._offset_to_primary
def OffsetToSymbolsMap(self):
"""Map offsets to the set of matching symbols.
Unlike OffsetToPrimaryMap, this is a 1-to-many mapping.
Returns;
{offset (int): [symbol_extractor.SymbolInfo]}
"""
if self._offset_to_symbols is None:
self._offset_to_symbols = symbol_extractor.GroupSymbolInfosByOffset(
self.SymbolInfos())
return self._offset_to_symbols
def GetOrderedSymbols(self, offsets):
"""Maps a list of offsets to symbol names, retaining ordering.
The symbol name is the primary symbol. This also deals with thumb
instruction (which have odd offsets).
Args::
offsets (int iterable) a set of offsets.
Returns
[str] list of symbol names.
"""
symbols = []
not_found = 0
for o in offsets:
if o in self.OffsetToPrimaryMap():
symbols.append(self.OffsetToPrimaryMap()[o].name)
elif o % 2 and (o - 1) in self.OffsetToPrimaryMap():
symbols.append(self.OffsetToPrimaryMap()[o - 1].name)
else:
not_found += 1
if not_found:
logging.warning('%d offsets do not have matching symbol', not_found)
return symbols
def SymbolsSize(self, symbols):
"""Computes the total size of a set of symbol names.
Args:
offsets (str iterable) a set of symbols.
Returns
int The sum of the primary size of the offsets.
"""
name_map = self.NameToSymbolMap()
return sum(name_map[sym].size for sym in symbols)
def GetReachedOffsetsFromDump(self, dump):
"""Find the symbol offsets from a list of binary offsets.
The dump is a list offsets into a .text section. This finds the symbols
which contain the dump offsets, and returns their offsets. Note that while
usually a symbol offset corresponds to a single symbol, in some cases
several symbols will map to the same offset. For that reason this function
returns only the offset list. See cyglog_to_orderfile.py for computing more
information about symbols.
Args:
dump: (int iterable) Dump offsets, for example as returned by MergeDumps().
Returns:
[int] Reached symbol offsets.
"""
reached_offsets = []
already_seen = set()
def update(_, symbol_offset):
if symbol_offset is None or symbol_offset in already_seen:
return
reached_offsets.append(symbol_offset)
already_seen.add(symbol_offset)
self._TranslateReachedOffsetsFromDump(dump, lambda x: x, update)
return reached_offsets
def MatchSymbolNames(self, symbol_names):
"""Find the symbols in this binary which match a list of symbols.
Args:
symbol_names (str iterable) List of symbol names.
Returns:
[symbol_extractor.SymbolInfo] Symbols in this binary matching the names.
"""
our_symbol_names = set(s.name for s in self.SymbolInfos())
matched_names = our_symbol_names.intersection(set(symbol_names))
return sorted([self.NameToSymbolMap()[n] for n in matched_names])
def TranslateAnnotatedSymbolOffsets(self, annotated_offsets):
"""Merges offsets across run groups and translates to symbol offsets.
Like GetReachedOffsetsFromDump, but works with AnnotatedOffsets.
Args:
annotated_offsets (AnnotatedOffset iterable) List of annotated offsets,
eg from ProfileManager.GetAnnotatedOffsets(). This will be mutated to
translate raw offsets to symbol offsets.
"""
self._TranslateReachedOffsetsFromDump(
annotated_offsets,
lambda o: o.Offset(),
lambda o, symbol_offset: o.SetOffset(symbol_offset))
def _TranslateReachedOffsetsFromDump(self, items, get, update):
"""Translate raw binary offsets to symbol offsets.
See GetReachedOffsetsFromDump for details. This version calls
|get(i)| on each element |i| of |items|, then calls
|update(i, symbol_offset)| with the updated offset. If the offset is not
found, update will be called with None.
Args:
items: (iterable) Items containing offsets.
get: (lambda item) As described above.
update: (lambda item, int) As described above.
"""
dump_offset_to_symbol_info = self.GetDumpOffsetToSymbolInfo()
for i in items:
dump_offset = get(i)
idx = dump_offset // 2
assert dump_offset >= 0 and idx < len(dump_offset_to_symbol_info), (
'Dump offset out of binary range')
symbol_info = dump_offset_to_symbol_info[idx]
assert symbol_info, ('A return address (offset = 0x{:08x}) does not map '
'to any symbol'.format(dump_offset))
update(i, symbol_info.offset)
def GetWhitelistSymbols(self):
"""Returns list(string) containing names of the symbols whose size is zero.
"""
if self._whitelist is None:
self.GetDumpOffsetToSymboInfolIncludingWhitelist()
return self._whitelist
def GetDumpOffsetToSymboInfolIncludingWhitelist(self):
"""Computes an array mapping each word in .text to a symbol.
This list includes symbols with size 0. It considers all offsets till the
next symbol to map to the symbol of size 0.
Returns:
[symbol_extractor.SymbolInfo or None] For every 4 bytes of the .text
section, maps it to a symbol, or None.
"""
if self._whitelist is None:
self._whitelist = set()
symbols = self.SymbolInfos()
start_syms = [s for s in symbols
if s.name == symbol_extractor.START_OF_TEXT_SYMBOL]
assert len(start_syms) == 1, 'Can\'t find unique start of text symbol'
start_of_text = start_syms[0].offset
self.GetDumpOffsetToSymbolInfo()
max_idx = len(self._offset_to_symbol_info)
for sym in symbols:
if sym.size != 0 or sym.offset == start_of_text:
continue
self._whitelist.add(sym.name)
idx = (sym.offset - start_of_text) // 2
assert self._offset_to_symbol_info[idx] == sym, (
'Unexpected unset offset')
idx += 1
while idx < max_idx and self._offset_to_symbol_info[idx] is None:
self._offset_to_symbol_info[idx] = sym
idx += 1
return self._offset_to_symbol_info
def GetDumpOffsetToSymbolInfo(self):
"""Computes an array mapping each word in .text to a symbol.
Returns:
[symbol_extractor.SymbolInfo or None] For every 4 bytes of the .text
section, maps it to a symbol, or None.
"""
if self._offset_to_symbol_info is None:
start_syms = [s for s in self.SymbolInfos()
if s.name == symbol_extractor.START_OF_TEXT_SYMBOL]
assert len(start_syms) == 1, 'Can\'t find unique start of text symbol'
start_of_text = start_syms[0].offset
max_offset = max(s.offset + s.size for s in self.SymbolInfos())
text_length_halfwords = (max_offset - start_of_text) // 2
self._offset_to_symbol_info = [None] * text_length_halfwords
for sym in self.SymbolInfos():
offset = sym.offset - start_of_text
assert offset >= 0, ('Unexpected symbol before the start of text. '
'Has the linker script broken?')
# The low bit of offset may be set to indicate a thumb instruction. The
# actual offset is still halfword aligned and so the low bit may be
# safely ignored in the division by two below.
for i in range(offset // 2, (offset + sym.size) // 2):
assert i < text_length_halfwords
other_symbol = self._offset_to_symbol_info[i]
# There may be overlapping symbols, for example fancy
# implementations for __ltsf2 and __gtsf2 (merging common tail
# code). In this case, keep the one that started first.
if other_symbol is None or other_symbol.offset > sym.offset:
self._offset_to_symbol_info[i] = sym
if sym.name != symbol_extractor.START_OF_TEXT_SYMBOL and sym.size == 0:
idx = offset // 2
assert (self._offset_to_symbol_info[idx] is None or
self._offset_to_symbol_info[idx].size == 0), (
'Unexpected symbols overlapping')
self._offset_to_symbol_info[idx] = sym
return self._offset_to_symbol_info
class ProfileManager:
"""Manipulates sets of profiles.
A "profile set" refers to a set of data from an instrumented version of chrome
that will be processed together, usually to produce a single orderfile. A
"run" refers to a session of chrome, visiting several pages and thus
comprising a browser process and at least one renderer process. A "dump"
refers to the instrumentation in chrome writing out offsets of instrumented
functions. There may be several dumps per run, for example one describing
chrome startup and a second describing steady-state page interaction. Each
process in a run produces one file per dump.
These dump files have a timestamp of the dump time. Each process produces its
own timestamp, but the dumps from each process occur very near in time to each
other (< 1 second). If there are several dumps per run, each set of dumps is
marked by a "phase" in the filename which is consistent across processes. For
example the dump for the startup could be phase 0 and then the steady-state
would be labeled phase 1.
We assume the files are named like
profile-hitmap-PROCESS-PID-TIMESTAMP.SUFFIX_PHASE, where PROCESS is a possibly
empty string, PID is the process id, TIMESTAMP is in nanoseconds, SUFFIX is
string without dashes, PHASE is an integer numbering the phases as 0, 1, 2...,
and the only dot is the one between TIMESTAMP and SUFFIX.
This manager supports several configurations of dumps.
* A single dump from a single run. These files are merged together to produce
a single dump without regard for browser versus renderer methods.
* Several phases of dumps from a single run. Files are grouped by phase as
described above.
* Several phases of dumps from multiple runs from a set of telemetry
benchmarks. The timestamp is used to distinguish each run because each
benchmark takes < 10 seconds to run but there are > 50 seconds of setup
time. This files can be grouped into run sets that are within 30 seconds of
each other. Each run set is then grouped into phases as before.
"""
class AnnotatedOffset:
"""Describes an offset with how it appeared in a profile set.
Each offset is annotated with the phase and process that it appeared in, and
can report how often it occurred in a specific phase and process.
"""
def __init__(self, offset):
self._offset = offset
self._count = {}
def __str__(self):
return '{}: {}'.format(self._offset, self._count)
def __eq__(self, other):
if other is None:
return False
return (self._offset == other._offset and
self._count == other._count)
def Increment(self, phase, process):
key = (phase, process)
self._count[key] = self._count.setdefault(key, 0) + 1
def Count(self, phase, process):
return self._count.get((phase, process), 0)
def Processes(self):
return set(key[1] for key in self._count)
def Phases(self):
return set(key[0] for key in self._count)
def Offset(self):
return self._offset
def SetOffset(self, o):
self._offset = o
class _RunGroup:
RUN_GROUP_THRESHOLD_NS = 30e9
def __init__(self):
self._filenames = []
def Filenames(self, phase=None):
if phase is None:
return self._filenames
return [f for f in self._filenames
if ProfileManager._Phase(f) == phase]
def Add(self, filename):
self._filenames.append(filename)
def IsCloseTo(self, filename):
run_group_ts = _Median(
[ProfileManager._Timestamp(f) for f in self._filenames])
return abs(ProfileManager._Timestamp(filename) -
run_group_ts) < self.RUN_GROUP_THRESHOLD_NS
def __init__(self, filenames):
"""Initialize a ProfileManager.
Args:
filenames ([str]): List of filenames describe the profile set.
"""
self._filenames = sorted(filenames, key=self._Timestamp)
self._run_groups = None
def GetPhases(self):
"""Return the set of phases of all orderfiles.
Returns:
set(int)
"""
return set(self._Phase(f) for f in self._filenames)
def GetMergedOffsets(self, phase=None):
"""Merges files, as if from a single dump.
Args:
phase (int, optional) If present, restrict to this phase.
Returns:
[int] Ordered list of reached offsets. Each offset only appears
once in the output, in the order of the first dump that contains it.
"""
if phase is None:
return self._GetOffsetsForGroup(self._filenames)
return self._GetOffsetsForGroup(f for f in self._filenames
if self._Phase(f) == phase)
def GetAnnotatedOffsets(self):
"""Merges offsets across run groups and annotates each one.
Returns:
[AnnotatedOffset]
"""
offset_map = {} # offset int -> AnnotatedOffset
for g in self._GetRunGroups():
for f in g:
phase = self._Phase(f)
process = self._ProcessName(f)
for offset in self._ReadOffsets(f):
offset_map.setdefault(offset, self.AnnotatedOffset(offset)).Increment(
phase, process)
return offset_map.values()
def GetProcessOffsetLists(self):
"""Returns all symbol offsets lists, grouped by process."""
offsets_by_process = collections.defaultdict(list)
for f in self._filenames:
offsets_by_process[self._ProcessName(f)].append(self._ReadOffsets(f))
return offsets_by_process
def _SanityCheckAllCallsCapturedByTheInstrumentation(self, process_info):
total_calls_count = int(process_info['total_calls_count'])
call_graph = process_info['call_graph']
count = 0
for el in call_graph:
for bucket in el['caller_and_count']:
count += int(bucket['count'])
# This is a sanity check to ensure the number of race-related
# inconsistencies is small.
if total_calls_count != count:
logging.warn('Instrumentation missed calls! %u != %u', total_calls_count,
count)
assert abs(total_calls_count - count) < 3, (
'Instrumentation call count differs by too much.')
def GetProcessOffsetGraph(self):
"""Returns a dict that maps each process type to a list of processes's
call graph data.
Typical process type keys are 'gpu-process', 'renderer', 'browser'.
"""
graph_by_process = collections.defaultdict(list)
for f in self._filenames:
process_info = self._ReadJSON(f)
assert ('total_calls_count' in process_info
and 'call_graph' in process_info), ('Unexpected JSON format for '
'%s.' % f)
self._SanityCheckAllCallsCapturedByTheInstrumentation(process_info)
graph_by_process[self._ProcessName(f)].append(process_info['call_graph'])
return graph_by_process
def GetRunGroupOffsets(self, phase=None):
"""Merges files from each run group and returns offset list for each.
Args:
phase (int, optional) If present, restrict to this phase.
Returns:
[ [int] ] List of offsets lists, each as from GetMergedOffsets.
"""
return [self._GetOffsetsForGroup(g) for g in self._GetRunGroups(phase)]
def _GetOffsetsForGroup(self, filenames):
dumps = [self._ReadOffsets(f) for f in filenames]
seen_offsets = set()
result = []
for dump in dumps:
for offset in dump:
if offset not in seen_offsets:
result.append(offset)
seen_offsets.add(offset)
return result
def _GetRunGroups(self, phase=None):
if self._run_groups is None:
self._ComputeRunGroups()
return [g.Filenames(phase) for g in self._run_groups]
@classmethod
def _ProcessName(cls, filename):
# The filename starts with 'profile-hitmap-' and ends with
# '-PID-TIMESTAMP.txt_X'. Anything in between is the process name. The
# browser has an empty process name, which is inserted here.
process_name_parts = os.path.basename(filename).split('-')[2:-2]
if not process_name_parts:
return 'browser'
return '-'.join(process_name_parts)
@classmethod
def _Timestamp(cls, filename):
dash_index = filename.rindex('-')
dot_index = filename.rindex('.')
return int(filename[dash_index+1:dot_index])
@classmethod
def _Phase(cls, filename):
return int(filename.split('_')[-1])
def _ReadOffsets(self, filename):
return [int(x.strip()) for x in open(filename)]
def _ReadJSON(self, filename):
with open(filename) as f:
file_content = json.load(f)
return file_content
def _ComputeRunGroups(self):
self._run_groups = []
for f in self._filenames:
for g in self._run_groups:
if g.IsCloseTo(f):
g.Add(f)
break
else:
g = self._RunGroup()
g.Add(f)
self._run_groups.append(g)
# Some sanity checks on the run groups.
assert self._run_groups
if len(self._run_groups) < 5:
return # Small runs have too much variance for testing.
sizes = list(map(lambda g: len(g.Filenames()), self._run_groups))
avg_size = sum(sizes) // len(self._run_groups)
num_outliers = len([s for s in sizes
if s > 1.5 * avg_size or s < 0.75 * avg_size])
expected_outliers = 0.1 * len(self._run_groups)
assert num_outliers < expected_outliers, (
'Saw {} outliers instead of at most {} for average of {}'.format(
num_outliers, expected_outliers, avg_size))
def GetReachedOffsetsFromDumpFiles(dump_filenames, library_filename):
"""Produces a list of symbol offsets reached by the dumps.
Args:
dump_filenames (str iterable) A list of dump filenames.
library_filename (str) The library file which the dumps refer to.
Returns:
[int] A list of symbol offsets. This order of symbol offsets produced is
given by the deduplicated order of offsets found in dump_filenames (see
also MergeDumps().
"""
dump = ProfileManager(dump_filenames).GetMergedOffsets()
if not dump:
logging.error('Empty dump, cannot continue: %s', '\n'.join(dump_filenames))
return None
logging.info('Reached offsets = %d', len(dump))
processor = SymbolOffsetProcessor(library_filename)
return processor.GetReachedOffsetsFromDump(dump)
def CreateArgumentParser():
"""Returns an ArgumentParser."""
parser = argparse.ArgumentParser(description='Outputs reached symbols')
parser.add_argument('--instrumented-build-dir', type=str,
help='Path to the instrumented build', required=True)
parser.add_argument('--build-dir', type=str, help='Path to the build dir',
required=True)
parser.add_argument('--dumps', type=str, help='A comma-separated list of '
'files with instrumentation dumps', required=True)
parser.add_argument('--output', type=str, help='Output filename',
required=True)
parser.add_argument('--offsets-output', type=str,
help='Output filename for the symbol offsets',
required=False, default=None)
parser.add_argument('--library-name', default='libchrome.so',
help=('Chrome shared library name (usually libchrome.so '
'or libmonochrome.so'))
return parser
def main():
logging.basicConfig(level=logging.INFO)
parser = CreateArgumentParser()
args = parser.parse_args()
logging.info('Merging dumps')
dump_files = args.dumps.split(',')
profile_manager = ProfileManager(dump_files)
dumps = profile_manager.GetMergedOffsets()
instrumented_native_lib = os.path.join(args.instrumented_build_dir,
'lib.unstripped', args.library_name)
regular_native_lib = os.path.join(args.build_dir,
'lib.unstripped', args.library_name)
instrumented_processor = SymbolOffsetProcessor(instrumented_native_lib)
reached_offsets = instrumented_processor.GetReachedOffsetsFromDump(dumps)
if args.offsets_output:
with open(args.offsets_output, 'w') as f:
f.write('\n'.join(map(str, reached_offsets)))
logging.info('Reached Offsets = %d', len(reached_offsets))
primary_map = instrumented_processor.OffsetToPrimaryMap()
reached_primary_symbols = set(
primary_map[offset] for offset in reached_offsets)
logging.info('Reached symbol names = %d', len(reached_primary_symbols))
regular_processor = SymbolOffsetProcessor(regular_native_lib)
matched_in_regular_build = regular_processor.MatchSymbolNames(
s.name for s in reached_primary_symbols)
logging.info('Matched symbols = %d', len(matched_in_regular_build))
total_size = sum(s.size for s in matched_in_regular_build)
logging.info('Total reached size = %d', total_size)
with open(args.output, 'w') as f:
for s in matched_in_regular_build:
f.write(s.name + '\n')
if __name__ == '__main__':
main()