#!/usr/bin/env python # Copyright (c) 2012 The Chromium Authors. All rights reserved. # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. """The deep heap profiler script for Chrome.""" from datetime import datetime import json import optparse import os import re import shutil import subprocess import sys import tempfile FIND_RUNTIME_SYMBOLS_PATH = os.path.join( os.path.dirname(os.path.abspath(__file__)), os.pardir, 'find_runtime_symbols') sys.path.append(FIND_RUNTIME_SYMBOLS_PATH) from find_runtime_symbols import find_runtime_symbols_list from find_runtime_symbols import find_runtime_typeinfo_symbols_list from find_runtime_symbols import RuntimeSymbolsInProcess from prepare_symbol_info import prepare_symbol_info BUCKET_ID = 5 VIRTUAL = 0 COMMITTED = 1 ALLOC_COUNT = 2 FREE_COUNT = 3 NULL_REGEX = re.compile('') POLICIES_JSON_PATH = os.path.join( os.path.dirname(os.path.abspath(__file__)), 'policies.json') # Heap Profile Dump versions # DUMP_DEEP_1 is OBSOLETE. # DUMP_DEEP_1 DOES NOT distinct mmap regions and malloc chunks. # Their stacktraces DO contain mmap* or tc-* at their tops. # They should be processed by POLICY_DEEP_1. DUMP_DEEP_1 = 'DUMP_DEEP_1' # DUMP_DEEP_2 is OBSOLETE. # DUMP_DEEP_2 DOES distinct mmap regions and malloc chunks. # Their stacktraces still DO contain mmap* or tc-*. # They should be processed by POLICY_DEEP_1. DUMP_DEEP_2 = 'DUMP_DEEP_2' # DUMP_DEEP_3 is OBSOLETE. # DUMP_DEEP_3 DOES distinct mmap regions and malloc chunks. # Their stacktraces DO NOT contain mmap* or tc-*. # They should be processed by POLICY_DEEP_2. DUMP_DEEP_3 = 'DUMP_DEEP_3' # DUMP_DEEP_4 is OBSOLETE. # DUMP_DEEP_4 adds some features to DUMP_DEEP_3: # 1. Support comments starting with '#' # 2. Support additional global stats: e.g. nonprofiled-*. DUMP_DEEP_4 = 'DUMP_DEEP_4' # DUMP_DEEP_5 doesn't separate sections for malloc and mmap. # malloc and mmap are identified in bucket files. DUMP_DEEP_5 = 'DUMP_DEEP_5' DUMP_DEEP_OBSOLETE = (DUMP_DEEP_1, DUMP_DEEP_2, DUMP_DEEP_3, DUMP_DEEP_4) # Heap Profile Policy versions # POLICY_DEEP_1 DOES NOT include allocation_type columns. # mmap regions are distincted w/ mmap frames in the pattern column. POLICY_DEEP_1 = 'POLICY_DEEP_1' # POLICY_DEEP_2 DOES include allocation_type columns. # mmap regions are distincted w/ the allocation_type column. POLICY_DEEP_2 = 'POLICY_DEEP_2' # POLICY_DEEP_3 is in JSON format. POLICY_DEEP_3 = 'POLICY_DEEP_3' # POLICY_DEEP_3 contains typeinfo. POLICY_DEEP_4 = 'POLICY_DEEP_4' class EmptyDumpException(Exception): def __init__(self, value): self.value = value def __str__(self): return repr(self.value) class ParsingException(Exception): def __init__(self, value): self.value = value def __str__(self): return repr(self.value) class InvalidDumpException(ParsingException): def __init__(self, value): self.value = value def __str__(self): return "invalid heap profile dump: %s" % repr(self.value) class ObsoleteDumpVersionException(ParsingException): def __init__(self, value): self.value = value def __str__(self): return "obsolete heap profile dump version: %s" % repr(self.value) class DelayedStaticSymbols(object): """Represents static symbol information loaded lazily.""" def __init__(self, prefix, keep=False): self.maps_path = prefix + '.maps' self.keep = keep if keep: self.prepared_data_dir = prefix + '.pre' self.loaded_static_symbols = None self.loaded_symbols_in_process = None def get(self): if not self.loaded_symbols_in_process: if not self.keep: self.prepared_data_dir = tempfile.mkdtemp() try: prepare_symbol_info(self.maps_path, self.prepared_data_dir) self.loaded_symbols_in_process = RuntimeSymbolsInProcess.load( self.prepared_data_dir) finally: if not self.keep: shutil.rmtree(self.prepared_data_dir) return self.loaded_symbols_in_process class Rule(object): """Represents one matching rule in a policy file.""" def __init__(self, name, mmap, stacktrace_pattern, typeinfo_pattern=None): self.name = name self.mmap = mmap self.stacktrace_pattern = re.compile(stacktrace_pattern + r'\Z') if typeinfo_pattern: self.typeinfo_pattern = re.compile(typeinfo_pattern + r'\Z') else: self.typeinfo_pattern = None class Policy(object): """Represents a policy, a content of a policy file.""" def __init__(self, rules, version, components): self.rules = rules self.version = version self.components = components def append_rule(self, rule): self.rules.append(rule) def get_component(rule_list, bucket, symbols): """Returns a component name which a given bucket belongs to. Args: rule_list: A list of Rule objects. bucket: A Bucket object to be searched for. symbols: A dict mapping runtime addresses to symbol names. Returns: A string representing a component name. """ if not bucket: return 'no-bucket' if bucket.component_cache: return bucket.component_cache stacktrace = ''.join(symbols[a] + ' ' for a in bucket.stacktrace).strip() typeinfo = bucket.typeinfo_symbol if typeinfo.startswith('0x'): typeinfo = bucket.typename for rule in rule_list: if (bucket.mmap == rule.mmap and rule.stacktrace_pattern.match(stacktrace) and (not rule.typeinfo_pattern or rule.typeinfo_pattern.match(typeinfo))): bucket.component_cache = rule.name return rule.name assert False class Bucket(object): """Represents a bucket, which is a unit of memory classification.""" def __init__(self, stacktrace, mmap, typeinfo, typename): self.stacktrace = stacktrace self.mmap = mmap self.typeinfo = typeinfo self.typeinfo_symbol = typename self.typename = typename self.component_cache = '' def clear_component_cache(self): self.component_cache = '' class Dump(object): """Represents one heap profile dump.""" def __init__(self, dump_path): self.dump_path = dump_path self.dump_lines = [ l for l in open(self.dump_path, 'r') if l and not l.startswith('#')] self.dump_version = '' self.stacktrace_lines = [] self.counters = {} self.dump_time = os.stat(self.dump_path).st_mtime def print_stacktrace(self, buckets, symbols): """Prints a given stacktrace. Args: buckets: A dict mapping bucket ids to Bucket objects. symbols: A dict mapping runtime addresses to symbol names. """ for line in self.stacktrace_lines: words = line.split() bucket = buckets.get(int(words[BUCKET_ID])) if not bucket: continue for i in range(0, BUCKET_ID - 1): sys.stdout.write(words[i] + ' ') for address in bucket.stacktrace: sys.stdout.write((symbols.get(address) or ('0x%016x' % address)) + ' ') sys.stdout.write('\n') @staticmethod def accumulate_size_for_pprof(stacktrace_lines, rule_list, buckets, component_name, symbols): """Accumulates size of committed chunks and the number of allocated chunks. Args: stacktrace_lines: A list of strings which are valid as stacktraces. rule_list: A list of Rule objects. buckets: A dict mapping bucket ids to Bucket objects. component_name: A name of component for filtering. symbols: A dict mapping runtime addresses to symbol names. Returns: Two integers which are the accumulated size of committed regions and the number of allocated chunks, respectively. """ com_committed = 0 com_allocs = 0 for line in stacktrace_lines: words = line.split() bucket = buckets.get(int(words[BUCKET_ID])) if (not bucket or (component_name and component_name != get_component(rule_list, bucket, symbols))): continue com_committed += int(words[COMMITTED]) com_allocs += int(words[ALLOC_COUNT]) - int(words[FREE_COUNT]) return com_committed, com_allocs @staticmethod def print_stacktrace_lines_for_pprof(stacktrace_lines, rule_list, buckets, component_name, symbols): """Prints information of stacktrace lines for pprof. Args: stacktrace_lines: A list of strings which are valid as stacktraces. rule_list: A list of Rule objects. buckets: A dict mapping bucket ids to Bucket objects. component_name: A name of component for filtering. symbols: A dict mapping runtime addresses to symbol names. """ for line in stacktrace_lines: words = line.split() bucket = buckets.get(int(words[BUCKET_ID])) if (not bucket or (component_name and component_name != get_component(rule_list, bucket, symbols))): continue sys.stdout.write('%6d: %8s [%6d: %8s] @' % ( int(words[ALLOC_COUNT]) - int(words[FREE_COUNT]), words[COMMITTED], int(words[ALLOC_COUNT]) - int(words[FREE_COUNT]), words[COMMITTED])) for address in bucket.stacktrace: sys.stdout.write(' 0x%016x' % address) sys.stdout.write('\n') def print_for_pprof( self, rule_list, buckets, maps_lines, component_name, symbols): """Converts the heap profile dump so it can be processed by pprof. Args: rule_list: A list of Rule objects. buckets: A dict mapping bucket ids to Bucket objects. maps_lines: A list of strings containing /proc/.../maps. component_name: A name of component for filtering. symbols: A dict mapping runtime addresses to symbol names. """ sys.stdout.write('heap profile: ') com_committed, com_allocs = self.accumulate_size_for_pprof( self.stacktrace_lines, rule_list, buckets, component_name, symbols) sys.stdout.write('%6d: %8s [%6d: %8s] @ heapprofile\n' % ( com_allocs, com_committed, com_allocs, com_committed)) self.print_stacktrace_lines_for_pprof( self.stacktrace_lines, rule_list, buckets, component_name, symbols) sys.stdout.write('MAPPED_LIBRARIES:\n') for line in maps_lines: sys.stdout.write(line) @staticmethod def check_stacktrace_line(stacktrace_line, buckets, appeared_addresses): """Checks if a given stacktrace_line is valid as stacktrace. Args: stacktrace_line: A string to be checked. buckets: A dict mapping bucket ids to Bucket objects. appeared_addresses: A list where appeared addresses will be stored. Returns: True if the given stacktrace_line is valid. """ words = stacktrace_line.split() if len(words) < BUCKET_ID + 1: return False if words[BUCKET_ID - 1] != '@': return False bucket = buckets.get(int(words[BUCKET_ID])) if bucket: for address in bucket.stacktrace: appeared_addresses.add(address) return True @staticmethod def skip_lines_while(line_number, max_line_number, skipping_condition): """Increments line_number until skipping_condition(line_number) is false. Returns: A pair of an integer indicating a line number after skipped, and a boolean value which is True if found a line which skipping_condition is False for. """ while skipping_condition(line_number): line_number += 1 if line_number >= max_line_number: return line_number, False return line_number, True def parse_stacktraces_while_valid( self, buckets, dump_lines, line_number, appeared_addresses): """Parses stacktrace lines while the lines are valid. Args: buckets: A dict mapping bucket ids to Bucket objects. dump_lines: A list of lines to be parsed. line_number: A line number to start parsing in dump_lines. appeared_addresses: A list where appeared addresses will be stored. Returns: A pair of a list of valid lines and an integer representing the last line number in dump_lines. """ (line_number, _) = self.skip_lines_while( line_number, len(dump_lines), lambda n: not dump_lines[n].split()[0].isdigit()) stacktrace_lines_start = line_number (line_number, _) = self.skip_lines_while( line_number, len(dump_lines), lambda n: self.check_stacktrace_line( dump_lines[n], buckets, appeared_addresses)) return (dump_lines[stacktrace_lines_start:line_number], line_number) def parse_stacktraces(self, buckets, line_number, appeared_addresses): """Parses lines in self.dump_lines as stacktrace. Valid stacktrace lines are stored into self.stacktrace_lines. Args: buckets: A dict mapping bucket ids to Bucket objects. line_number: A line number to start parsing in dump_lines. appeared_addresses: A list where appeared addresses will be stored. Raises: ParsingException for invalid dump versions. """ if self.dump_version == DUMP_DEEP_5: (self.stacktrace_lines, line_number) = ( self.parse_stacktraces_while_valid( buckets, self.dump_lines, line_number, appeared_addresses)) elif self.dump_version in DUMP_DEEP_OBSOLETE: raise ObsoleteDumpVersionException(self.dump_version) else: raise InvalidDumpException('Invalid version: %s' % self.dump_version) def parse_global_stats(self): """Parses lines in self.dump_lines as global stats.""" (ln, _) = self.skip_lines_while( 0, len(self.dump_lines), lambda n: self.dump_lines[n] != 'GLOBAL_STATS:\n') global_stat_names = [ 'total', 'file-exec', 'file-nonexec', 'anonymous', 'stack', 'other', 'nonprofiled-absent', 'nonprofiled-anonymous', 'nonprofiled-file-exec', 'nonprofiled-file-nonexec', 'nonprofiled-stack', 'nonprofiled-other', 'profiled-mmap', 'profiled-malloc'] for prefix in global_stat_names: (ln, _) = self.skip_lines_while( ln, len(self.dump_lines), lambda n: self.dump_lines[n].split()[0] != prefix) words = self.dump_lines[ln].split() self.counters[prefix + '_virtual'] = int(words[-2]) self.counters[prefix + '_committed'] = int(words[-1]) def parse_version(self): """Parses a version string in self.dump_lines. Returns: A pair of (a string representing a version of the stacktrace dump, and an integer indicating a line number next to the version string). Raises: ParsingException for invalid dump versions. """ version = '' # Skip until an identifiable line. headers = ('STACKTRACES:\n', 'MMAP_STACKTRACES:\n', 'heap profile: ') if not self.dump_lines: raise EmptyDumpException('Empty heap dump file.') (ln, found) = self.skip_lines_while( 0, len(self.dump_lines), lambda n: not self.dump_lines[n].startswith(headers)) if not found: raise InvalidDumpException('No version header.') # Identify a version. if self.dump_lines[ln].startswith('heap profile: '): version = self.dump_lines[ln][13:].strip() if version == DUMP_DEEP_5: (ln, _) = self.skip_lines_while( ln, len(self.dump_lines), lambda n: self.dump_lines[n] != 'STACKTRACES:\n') elif version in DUMP_DEEP_OBSOLETE: raise ObsoleteDumpVersionException(version) else: raise InvalidDumpException('Invalid version: %s' % version) elif self.dump_lines[ln] == 'STACKTRACES:\n': raise ObsoleteDumpVersionException(DUMP_DEEP_1) elif self.dump_lines[ln] == 'MMAP_STACKTRACES:\n': raise ObsoleteDumpVersionException(DUMP_DEEP_2) return (version, ln) def parse_dump(self, buckets, appeared_addresses): self.dump_version, ln = self.parse_version() self.parse_global_stats() self.parse_stacktraces(buckets, ln, appeared_addresses) @staticmethod def accumulate_size_for_policy(stacktrace_lines, rule_list, buckets, sizes, symbols): for line in stacktrace_lines: words = line.split() bucket = buckets.get(int(words[BUCKET_ID])) component_match = get_component(rule_list, bucket, symbols) sizes[component_match] += int(words[COMMITTED]) if component_match.startswith('tc-'): sizes['tc-total-log'] += int(words[COMMITTED]) elif component_match.startswith('mmap-'): sizes['mmap-total-log'] += int(words[COMMITTED]) else: sizes['other-total-log'] += int(words[COMMITTED]) def apply_policy( self, rule_list, buckets, first_dump_time, components, symbols): """Aggregates the total memory size of each component. Iterate through all stacktraces and attribute them to one of the components based on the policy. It is important to apply policy in right order. Args: rule_list: A list of Rule objects. buckets: A dict mapping bucket ids to Bucket objects. first_dump_time: An integer representing time when the first dump is dumped. components: A list of strings of component names. symbols: A dict mapping runtime addresses to symbol names. Returns: A dict mapping components and their corresponding sizes. """ sys.stderr.write('Applying policy: "%s".\n' % self.dump_path) sizes = dict((c, 0) for c in components) self.accumulate_size_for_policy(self.stacktrace_lines, rule_list, buckets, sizes, symbols) mmap_prefix = 'profiled-mmap' malloc_prefix = 'profiled-malloc' sizes['mmap-no-log'] = ( self.counters['%s_committed' % mmap_prefix] - sizes['mmap-total-log']) sizes['mmap-total-record'] = self.counters['%s_committed' % mmap_prefix] sizes['mmap-total-record-vm'] = self.counters['%s_virtual' % mmap_prefix] sizes['tc-no-log'] = ( self.counters['%s_committed' % malloc_prefix] - sizes['tc-total-log']) sizes['tc-total-record'] = self.counters['%s_committed' % malloc_prefix] sizes['tc-unused'] = ( sizes['mmap-tcmalloc'] - self.counters['%s_committed' % malloc_prefix]) sizes['tc-total'] = sizes['mmap-tcmalloc'] for key, value in { 'total': 'total_committed', 'filemapped': 'file_committed', 'file-exec': 'file-exec_committed', 'file-nonexec': 'file-nonexec_committed', 'anonymous': 'anonymous_committed', 'stack': 'stack_committed', 'other': 'other_committed', 'nonprofiled-absent': 'nonprofiled-absent_committed', 'nonprofiled-anonymous': 'nonprofiled-anonymous_committed', 'nonprofiled-file-exec': 'nonprofiled-file-exec_committed', 'nonprofiled-file-nonexec': 'nonprofiled-file-nonexec_committed', 'nonprofiled-stack': 'nonprofiled-stack_committed', 'nonprofiled-other': 'nonprofiled-other_committed', 'total-vm': 'total_virtual', 'filemapped-vm': 'file_virtual', 'anonymous-vm': 'anonymous_virtual', 'other-vm': 'other_virtual' }.iteritems(): if key in sizes: sizes[key] = self.counters[value] if 'mustbezero' in sizes: removed = ( '%s_committed' % mmap_prefix, 'nonprofiled-absent_committed', 'nonprofiled-anonymous_committed', 'nonprofiled-file-exec_committed', 'nonprofiled-file-nonexec_committed', 'nonprofiled-stack_committed', 'nonprofiled-other_committed') sizes['mustbezero'] = ( self.counters['total_committed'] - sum(self.counters[i] for i in removed)) if 'total-exclude-profiler' in sizes: sizes['total-exclude-profiler'] = ( self.counters['total_committed'] - (sizes['mmap-profiler'] + sizes['mmap-type-profiler'])) if 'hour' in sizes: sizes['hour'] = (self.dump_time - first_dump_time) / 60.0 / 60.0 if 'minute' in sizes: sizes['minute'] = (self.dump_time - first_dump_time) / 60.0 if 'second' in sizes: sizes['second'] = self.dump_time - first_dump_time return sizes @staticmethod def accumulate_size_for_expand(stacktrace_lines, rule_list, buckets, component_name, depth, sizes, symbols, typeinfo_symbols): for line in stacktrace_lines: words = line.split() bucket = buckets.get(int(words[BUCKET_ID])) component_match = get_component(rule_list, bucket, symbols) if component_match == component_name: stacktrace_sequence = '' if bucket.typeinfo: stacktrace_sequence += '(type=%s)' % typeinfo_symbols[bucket.typeinfo] stacktrace_sequence += ' (type.name=%s) ' % bucket.typename for address in bucket.stacktrace[0 : min(len(bucket.stacktrace), 1 + depth)]: stacktrace_sequence += symbols[address] + ' ' if not stacktrace_sequence in sizes: sizes[stacktrace_sequence] = 0 sizes[stacktrace_sequence] += int(words[COMMITTED]) def expand( self, rule_list, buckets, component_name, depth, symbols, typeinfo_symbols): """Prints all stacktraces in a given component of given depth. Args: rule_list: A list of Rule objects. buckets: A dict mapping bucket ids to Bucket objects. component_name: A name of component for filtering. depth: An integer representing depth to be printed. symbols: A dict mapping runtime addresses to symbol names. """ sizes = {} self.accumulate_size_for_expand( self.stacktrace_lines, rule_list, buckets, component_name, depth, sizes, symbols, typeinfo_symbols) sorted_sizes_list = sorted( sizes.iteritems(), key=(lambda x: x[1]), reverse=True) total = 0 for size_pair in sorted_sizes_list: sys.stdout.write('%10d %s\n' % (size_pair[1], size_pair[0])) total += size_pair[1] sys.stderr.write('total: %d\n' % (total)) def update_symbols( symbol_path, delayed_static_symbols, appeared_addresses, parameter_find_runtime_symbols_list, symbols): """Updates address/symbol mapping on memory and in a .symbol cache file. It reads cached address/symbol mapping from a .symbol file if it exists. Then, it resolves unresolved addresses from a Chrome binary with pprof. Both mappings on memory and in a .symbol cache file are updated. Symbol files are formatted as follows: