From d3c40e701e2638596bf8bd2d4f8da746e005e144 Mon Sep 17 00:00:00 2001 From: Yabin Cui Date: Mon, 14 Aug 2017 16:35:10 -0700 Subject: [PATCH] simpleperf: fix inferno and test content of report.html. Inferno uses sample count to decide the width of each method, but this is not accurate. Fix it by using event count instead. Besides that, refactor inferno.py a little, get record cmd and device info from perf.data, and test content of report.html generated by inferno. Also fix a flakey test TestExamplePureJava.test_app_profiler_with_ctrl_c. Bug: http://b/64035530 Test: run test.py. Change-Id: Ia57fcbd28b4242b4251b063bd38a58da7b93eba0 --- simpleperf/scripts/inferno/data_types.py | 96 ++++++++-------- simpleperf/scripts/inferno/inferno.py | 106 +++++++++--------- simpleperf/scripts/inferno/script.js | 2 +- simpleperf/scripts/inferno/svg_renderer.py | 173 +++++++++++++---------------- simpleperf/scripts/test.py | 55 ++++++++- 5 files changed, 233 insertions(+), 199 deletions(-) diff --git a/simpleperf/scripts/inferno/data_types.py b/simpleperf/scripts/inferno/data_types.py index 80e633f4..4f07ba73 100644 --- a/simpleperf/scripts/inferno/data_types.py +++ b/simpleperf/scripts/inferno/data_types.py @@ -25,15 +25,20 @@ class CallSite: class Thread: - def __init__(self, tid): + def __init__(self, tid, pid): self.tid = tid + self.pid = pid + self.name = "" self.samples = [] - self.flamegraph = {} + self.flamegraph = FlameGraphCallSite("root", "", 0) self.num_samples = 0 + self.event_count = 0 def add_callchain(self, callchain, symbol, sample): - chain = [] + self.name = sample.thread_comm self.num_samples += 1 + self.event_count += sample.period + chain = [] for j in range(callchain.nr): entry = callchain.entries[callchain.nr - j - 1] if entry.ip == 0: @@ -41,20 +46,7 @@ class Thread: chain.append(CallSite(entry.ip, entry.symbol.symbol_name, entry.symbol.dso_name)) chain.append(CallSite(sample.ip, symbol.symbol_name, symbol.dso_name)) - self.samples.append(chain) - - def collapse_flamegraph(self): - flamegraph = FlameGraphCallSite("root", "") - flamegraph.id = 0 # This is used for wasd navigation, 0 = not a valid target. - self.flamegraph = flamegraph - for sample in self.samples: - flamegraph = self.flamegraph - for callsite in sample: - flamegraph = flamegraph.get_callsite(callsite.method, callsite.dso) - - # Populate root note. - for node in self.flamegraph.callsites: - self.flamegraph.num_samples += node.num_samples + self.flamegraph.add_callchain(chain, sample.period) class Process: @@ -65,51 +57,57 @@ class Process: self.threads = {} self.cmd = "" self.props = {} - self.args = None self.num_samples = 0 - def get_thread(self, tid): - if (tid not in self.threads.keys()): - self.threads[tid] = Thread(tid) + def get_thread(self, tid, pid): + if tid not in self.threads.keys(): + self.threads[tid] = Thread(tid, pid) return self.threads[tid] -CALLSITE_COUNTER = 0 - - -def get_callsite_id(): - global CALLSITE_COUNTER - CALLSITE_COUNTER += 1 - toReturn = CALLSITE_COUNTER - return toReturn - class FlameGraphCallSite: - def __init__(self, method, dso): - self.callsites = [] + callsite_counter = 0 + @classmethod + def _get_next_callsite_id(cls): + cls.callsite_counter += 1 + return cls.callsite_counter + + def __init__(self, method, dso, id): + self.children = [] self.method = method self.dso = dso - self.num_samples = 0 + self.event_count = 0 self.offset = 0 # Offset allows position nodes in different branches. - self.id = get_callsite_id() + self.id = id + + def weight(self): + return float(self.event_count) + + def add_callchain(self, chain, event_count): + self.event_count += event_count + current = self + for callsite in chain: + current = current._get_child(callsite) + current.event_count += event_count - def get_callsite(self, name, dso): - for c in self.callsites: - if c.equivalent(name, dso): - c.num_samples += 1 + def _get_child(self, callsite): + for c in self.children: + if c._equivalent(callsite.method, callsite.dso): return c - callsite = FlameGraphCallSite(name, dso) - callsite.num_samples = 1 - self.callsites.append(callsite) - return callsite + new_child = FlameGraphCallSite(callsite.method, callsite.dso, self._get_next_callsite_id()) + self.children.append(new_child) + return new_child - def equivalent(self, method, dso): + def _equivalent(self, method, dso): return self.method == method and self.dso == dso def get_max_depth(self): - max = 0 - for c in self.callsites: - depth = c.get_max_depth() - if depth > max: - max = depth - return max + 1 + return max([c.get_max_depth() for c in self.children]) + 1 if self.children else 1 + + def generate_offset(self, start_offset): + self.offset = start_offset + child_offset = start_offset + for child in self.children: + child_offset = child.generate_offset(child_offset) + return self.offset + self.event_count diff --git a/simpleperf/scripts/inferno/inferno.py b/simpleperf/scripts/inferno/inferno.py index 0b592965..15d7776a 100644 --- a/simpleperf/scripts/inferno/inferno.py +++ b/simpleperf/scripts/inferno/inferno.py @@ -47,7 +47,7 @@ from data_types import * from svg_renderer import * -def collect_data(args, process): +def collect_data(args): app_profiler_args = [sys.executable, "app_profiler.py", "-nb"] if args.app: app_profiler_args += ["-p", args.app] @@ -77,7 +77,6 @@ def collect_data(args, process): record_arg_str += "-f %d " % args.sample_frequency log_info("Using frequency sampling (-f %d)." % args.sample_frequency) record_arg_str += "--duration %d " % args.capture_duration - process.cmd = " ".join(app_profiler_args) + ' -r "%s"' % record_arg_str app_profiler_args += ["-r", record_arg_str] returncode = subprocess.call(app_profiler_args) return returncode == 0 @@ -93,12 +92,19 @@ def parse_samples(process, args): lib = ReportLib() lib.ShowIpForUnknownSymbol() - if symfs_dir is not None: + if symfs_dir: lib.SetSymfs(symfs_dir) - if record_file is not None: + if record_file: lib.SetRecordFile(record_file) - if kallsyms_file is not None: + if kallsyms_file: lib.SetKallsymsFile(kallsyms_file) + process.cmd = lib.GetRecordCmd() + product_props = lib.MetaInfo().get("product_props") + if product_props: + tuple = product_props.split(':') + process.props['ro.product.manufacturer'] = tuple[0] + process.props['ro.product.model'] = tuple[1] + process.props['ro.product.name'] = tuple[2] while True: sample = lib.GetNextSample() @@ -107,20 +113,16 @@ def parse_samples(process, args): break symbol = lib.GetSymbolOfCurrentSample() callchain = lib.GetCallChainOfCurrentSample() - process.get_thread(sample.tid).add_callchain(callchain, symbol, sample) + process.get_thread(sample.tid, sample.pid).add_callchain(callchain, symbol, sample) process.num_samples += 1 - log_info("Parsed %s callchains." % process.num_samples) - + if process.pid == 0: + main_threads = [thread for thread in process.threads.values() if thread.tid == thread.pid] + if main_threads: + process.name = main_threads[0].name + process.pid = main_threads[0].pid -def collapse_callgraphs(process): - """ - For each thread, collapse all callgraph into one flamegraph. - :param process: Process object - :return: None - """ - for _, thread in process.threads.items(): - thread.collapse_flamegraph() + log_info("Parsed %s callchains." % process.num_samples) def get_local_asset_content(local_path): @@ -129,13 +131,11 @@ def get_local_asset_content(local_path): :param local_path: str, filename of local asset :return: str, the content of local_path """ - f = open(os.path.join(os.path.dirname(__file__), local_path), 'r') - content = f.read() - f.close() - return content + with open(os.path.join(os.path.dirname(__file__), local_path), 'r') as f: + return f.read() -def output_report(process): +def output_report(process, args): """ Generates a HTML report representing the result of simpleperf sampling as flamegraph :param process: Process object @@ -151,19 +151,23 @@ def output_report(process): f.write('Embedded Image') + process_entry = ("Process : %s (%d)
" % (process.name, process.pid)) if process.pid else "" + # TODO: collect capture duration info from perf.data. + duration_entry = ("Duration: %s seconds
" % args.capture_duration + ) if args.capture_duration else "" f.write("""
Inferno Flamegraph Report

- Process : %s (%d)
+ %s Date    : %s
Threads : %d
Samples : %d
- Duration: %s seconds
""" % ( - process.name, process.pid, + %s""" % ( + process_entry, datetime.datetime.now().strftime("%Y-%m-%d (%A) %H:%M:%S"), len(process.threads), process.num_samples, - process.args.capture_duration)) + duration_entry)) if 'ro.product.model' in process.props: f.write( "Machine : %s (%s) by %s
" % @@ -178,17 +182,17 @@ def output_report(process): f.write("" % get_local_asset_content("script.js")) # Output tid == pid Thread first. - main_thread = [x for _, x in process.threads.items() if x.tid == process.pid] + main_thread = [x for x in process.threads.values() if x.tid == process.pid] for thread in main_thread: - f.write("

Main Thread %d (%d samples):
\n\n\n\n" % ( - thread.tid, thread.num_samples)) - renderSVG(thread.flamegraph, f, process.args.color, process.args.svg_width) + f.write("

Main Thread %d (%s) (%d samples):
\n\n\n\n" % ( + thread.tid, thread.name, thread.num_samples)) + renderSVG(thread.flamegraph, f, args.color, args.svg_width) - other_threads = [x for _, x in process.threads.items() if x.tid != process.pid] + other_threads = [x for x in process.threads.values() if x.tid != process.pid] for thread in other_threads: - f.write("

Thread %d (%d samples):
\n\n\n\n" % ( - thread.tid, thread.num_samples)) - renderSVG(thread.flamegraph, f, process.args.color, process.args.svg_width) + f.write("

Thread %d (%s) (%d samples):
\n\n\n\n" % ( + thread.tid, thread.name, thread.num_samples)) + renderSVG(thread.flamegraph, f, args.color, args.svg_width) f.write("") f.write("") @@ -196,17 +200,9 @@ def output_report(process): return "file://" + filepath -def generate_flamegraph_offsets(flamegraph): - rover = flamegraph.offset - for callsite in flamegraph.callsites: - callsite.offset = rover - rover += callsite.num_samples - generate_flamegraph_offsets(callsite) - - def generate_threads_offsets(process): - for _, thread in process.threads.items(): - generate_flamegraph_offsets(thread.flamegraph) + for thread in process.threads.values(): + thread.flamegraph.generate_offset(0) def collect_machine_info(process): @@ -266,26 +262,26 @@ def main(): parser.add_argument('--disable_adb_root', action='store_true', help="""Force adb to run in non root mode.""") args = parser.parse_args() - process_name = args.app or args.native_program - process = Process(process_name, 0) - process.args = args + process = Process("", 0) if not args.skip_collection: - log_info("Starting data collection stage for process '%s'." % process_name) - if not collect_data(args, process): + process.name = args.app or args.native_program + log_info("Starting data collection stage for process '%s'." % process.name) + if not collect_data(args): log_exit("Unable to collect data.") - try: - result, output = AdbHelper().run_and_return_output(['shell', 'pidof', process_name]) - if result: + result, output = AdbHelper().run_and_return_output(['shell', 'pidof', process.name]) + if result: + try: process.pid = int(output) - except: - raise + except: + process.pid = 0 collect_machine_info(process) + else: + args.capture_duration = 0 parse_samples(process, args) - collapse_callgraphs(process) generate_threads_offsets(process) - report_path = output_report(process) + report_path = output_report(process, args) open_report_in_browser(report_path) log_info("Report generated at '%s'." % report_path) diff --git a/simpleperf/scripts/inferno/script.js b/simpleperf/scripts/inferno/script.js index 3288d8be..078100d6 100644 --- a/simpleperf/scripts/inferno/script.js +++ b/simpleperf/scripts/inferno/script.js @@ -30,7 +30,7 @@ function adjust_node_text_size(x) { let width = parseFloat(rect.attributes['width'].value); // Don't even bother trying to find a best fit. The area is too small. - if (width < 25) { + if (width < 28) { text.textContent = ''; return; } diff --git a/simpleperf/scripts/inferno/svg_renderer.py b/simpleperf/scripts/inferno/svg_renderer.py index 68559499..b84e058c 100644 --- a/simpleperf/scripts/inferno/svg_renderer.py +++ b/simpleperf/scripts/inferno/svg_renderer.py @@ -39,20 +39,20 @@ def getDSOColor(method): return (r, g, b) -def getHeatColor(callsite, num_samples): - r = 245 + 10 * (1 - float(callsite.num_samples) / num_samples) - g = 110 + 105 * (1 - float(callsite.num_samples) / num_samples) +def getHeatColor(callsite, total_weight): + r = 245 + 10 * (1 - callsite.weight() / total_weight) + g = 110 + 105 * (1 - callsite.weight() / total_weight) b = 100 return (r, g, b) -def createSVGNode(callsite, depth, f, num_samples, height, color_scheme, nav): - x = float(callsite.offset) / float(num_samples) * SVG_CANVAS_WIDTH - y = height - (depth * SVG_NODE_HEIGHT) - SVG_NODE_HEIGHT - width = float(callsite.num_samples) / float(num_samples) * SVG_CANVAS_WIDTH +def createSVGNode(callsite, depth, f, total_weight, height, color_scheme, nav): + x = float(callsite.offset) / total_weight * SVG_CANVAS_WIDTH + y = height - (depth + 1) * SVG_NODE_HEIGHT + width = callsite.weight() / total_weight * SVG_CANVAS_WIDTH method = callsite.method.replace(">", ">").replace("<", "<") - if (width <= 0): + if width <= 0: return if color_scheme == "dso": @@ -60,119 +60,103 @@ def createSVGNode(callsite, depth, f, num_samples, height, color_scheme, nav): elif color_scheme == "legacy": r, g, b = getLegacyColor(method) else: - r, g, b = getHeatColor(callsite, num_samples) + r, g, b = getHeatColor(callsite, total_weight) - r_border = (r - 50) - if r_border < 0: - r_border = 0 - - g_border = (g - 50) - if g_border < 0: - g_border = 0 - - b_border = (b - 50) - if (b_border < 0): - b_border = 0 + r_border, g_border, b_border = [max(0, color - 50) for color in [r, g, b]] f.write( - ' \n\ - %s | %s (%d samples: %3.2f%%)\n \ - \n \ - \n \ - \n' % + """ + %s | %s (%.0f events: %3.2f%%) + + + """ % (callsite.id, - ','.join( - str(x) for x in nav), - method, - callsite.dso, - callsite.num_samples, - callsite.num_samples / - float(num_samples) * - 100, - x, - y, - x, - y, - width, - width, - r, - g, - b, - r, - g, - b, - r_border, - g_border, - b_border, - x + - 2, - y + - 12, - FONT_SIZE)) - - -def renderSVGNodes(flamegraph, depth, f, num_samples, height, color_scheme): - for i, callsite in enumerate(flamegraph.callsites): + ','.join(str(x) for x in nav), + method, + callsite.dso, + callsite.weight(), + callsite.weight() / total_weight * 100, + x, + y, + x, + y, + width, + width, + r, + g, + b, + r, + g, + b, + r_border, + g_border, + b_border, + x + 2, + y + 12, + FONT_SIZE)) + + +def renderSVGNodes(flamegraph, depth, f, total_weight, height, color_scheme): + for i, child in enumerate(flamegraph.children): # Prebuild navigation target for wasd if i == 0: left_index = 0 else: - left_index = flamegraph.callsites[i - 1].id + left_index = flamegraph.children[i - 1].id - if i == len(flamegraph.callsites) - 1: + if i == len(flamegraph.children) - 1: right_index = 0 else: - right_index = flamegraph.callsites[i + 1].id + right_index = flamegraph.children[i + 1].id - up_index = 0 - max_up = 0 - for upcallsite in callsite.callsites: - if upcallsite.num_samples > max_up: - max_up = upcallsite.num_samples - up_index = upcallsite.id + up_index = max(child.children, key=lambda x: x.weight()).id if child.children else 0 # up, left, down, right nav = [up_index, left_index, flamegraph.id, right_index] - createSVGNode(callsite, depth, f, num_samples, height, color_scheme, nav) + createSVGNode(child, depth, f, total_weight, height, color_scheme, nav) # Recurse down - renderSVGNodes(callsite, depth + 1, f, num_samples, height, color_scheme) + renderSVGNodes(child, depth + 1, f, total_weight, height, color_scheme) def renderSearchNode(f): f.write( - ' \ - Search\n' % - (SVG_CANVAS_WIDTH - 95, SVG_CANVAS_WIDTH - 80)) + """ + Search + """ % (SVG_CANVAS_WIDTH - 95, SVG_CANVAS_WIDTH - 80)) def renderUnzoomNode(f): f.write( - '