1#!/usr/bin/env python3 2# 3# Copyright (C) 2021 The Android Open Source Project 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16# 17 18import argparse 19import bisect 20import jinja2 21import io 22import math 23import os 24import pandas as pd 25from pathlib import Path 26import re 27import sys 28 29from bokeh.embed import components 30from bokeh.io import output_file, show 31from bokeh.layouts import layout, Spacer 32from bokeh.models import ColumnDataSource, CustomJS, WheelZoomTool, HoverTool, FuncTickFormatter 33from bokeh.models.widgets import DataTable, DateFormatter, TableColumn 34from bokeh.models.ranges import FactorRange 35from bokeh.palettes import Category20b 36from bokeh.plotting import figure 37from bokeh.resources import INLINE 38from bokeh.transform import jitter 39from bokeh.util.browser import view 40from functools import cmp_to_key 41 42# fmt: off 43simpleperf_path = Path(__file__).absolute().parents[1] 44sys.path.insert(0, str(simpleperf_path)) 45import simpleperf_report_lib as sp 46# fmt: on 47 48 49def create_graph(args, source, data_range): 50 graph = figure( 51 sizing_mode='stretch_both', x_range=data_range, 52 tools=['pan', 'wheel_zoom', 'ywheel_zoom', 'xwheel_zoom', 'reset', 'tap', 'box_select'], 53 active_drag='box_select', active_scroll='wheel_zoom', 54 tooltips=[('thread', '@thread'), 55 ('callchain', '@callchain{safe}')], 56 title=args.title, name='graph') 57 58 # a crude way to avoid process name cluttering at some zoom levels. 59 # TODO: remove processes from the ticker base on the number of samples currently visualized. 60 # The process with most samples visualized should always be visible on the ticker 61 graph.xaxis.formatter = FuncTickFormatter(args={'range': data_range, 'graph': graph}, code=""" 62 var pixels_per_entry = graph.inner_height / (range.end - range.start) //Do not rond end and start here 63 var entries_to_skip = Math.ceil(12 / pixels_per_entry) // kind of 12 px per entry 64 var desc = tick.split(/:| /) 65 // desc[0] == desc[1] for main threads 66 var keep = (desc[0] == desc[1]) && 67 !(desc[2].includes('unknown') || 68 desc[2].includes('Binder') || 69 desc[2].includes('kworker')) 70 71 if (pixels_per_entry < 8 && !keep) { 72 //if (index + Math.round(range.start)) % entries_to_skip != 0) { 73 return "" 74 } 75 76 return tick """) 77 78 graph.xaxis.major_label_orientation = math.pi/6 79 80 graph.circle(y='time', 81 x='thread', 82 source=source, 83 color='color', 84 alpha=0.3, 85 selection_fill_color='White', 86 selection_line_color='Black', 87 selection_line_width=0.5, 88 selection_alpha=1.0) 89 90 graph.y_range.range_padding = 0 91 graph.xgrid.grid_line_color = None 92 return graph 93 94 95def create_table(graph): 96 # Empty dataframe, will be filled up in js land 97 empty_data = {'thread': [], 'count': []} 98 table_source = ColumnDataSource(pd.DataFrame( 99 empty_data, columns=['thread', 'count'], index=None)) 100 graph_source = graph.renderers[0].data_source 101 102 columns = [ 103 TableColumn(field='thread', title='Thread'), 104 TableColumn(field='count', title='Count') 105 ] 106 107 # start with a small table size (stretch doesn't reduce from the preferred size) 108 table = DataTable( 109 width=100, 110 height=100, 111 sizing_mode='stretch_both', 112 source=table_source, 113 columns=columns, 114 index_position=None, 115 name='table') 116 117 graph_selection_cb = CustomJS(code='update_selections()') 118 119 graph_source.selected.js_on_change('indices', graph_selection_cb) 120 table_source.selected.js_on_change('indices', CustomJS(args={}, code='update_flamegraph()')) 121 122 return table 123 124 125def generate_template(template_file='index.html.jinja2'): 126 loader = jinja2.FileSystemLoader( 127 searchpath=os.path.dirname(os.path.realpath(__file__)) + '/templates/') 128 129 env = jinja2.Environment(loader=loader) 130 return env.get_template(template_file) 131 132 133def generate_html(args, components_dict, title): 134 resources = INLINE.render() 135 script, div = components(components_dict) 136 return generate_template().render( 137 resources=resources, plot_script=script, plot_div=div, title=title) 138 139 140class ThreadDescriptor: 141 def __init__(self, pid, tid, name): 142 self.name = name 143 self.tid = tid 144 self.pid = pid 145 146 def __lt__(self, other): 147 return self.pid < other.pid or (self.pid == other.pid and self.tid < other.tid) 148 149 def __gt__(self, other): 150 return self.pid > other.pid or (self.pid == other.pid and self.tid > other.tid) 151 152 def __eq__(self, other): 153 return self.pid == other.pid and self.tid == other.tid and self.name == other.name 154 155 def __str__(self): 156 return str(self.pid) + ':' + str(self.tid) + ' ' + self.name 157 158 159def generate_datasource(args): 160 lib = sp.ReportLib() 161 lib.ShowIpForUnknownSymbol() 162 163 if args.usyms: 164 lib.SetSymfs(args.usyms) 165 166 if args.input_file: 167 lib.SetRecordFile(args.input_file) 168 169 if args.ksyms: 170 lib.SetKallsymsFile(args.ksyms) 171 172 if not args.not_art: 173 lib.ShowArtFrames(True) 174 175 for file_path in args.proguard_mapping_file or []: 176 lib.AddProguardMappingFile(file_path) 177 178 product = lib.MetaInfo().get('product_props') 179 180 if product: 181 manufacturer, model, name = product.split(':') 182 183 start_time = -1 184 end_time = -1 185 186 times = [] 187 threads = [] 188 thread_descs = [] 189 callchains = [] 190 191 while True: 192 sample = lib.GetNextSample() 193 194 if sample is None: 195 lib.Close() 196 break 197 198 symbol = lib.GetSymbolOfCurrentSample() 199 callchain = lib.GetCallChainOfCurrentSample() 200 201 if start_time == -1: 202 start_time = sample.time 203 204 sample_time = (sample.time - start_time) / 1e6 # convert to ms 205 206 times.append(sample_time) 207 208 if sample_time > end_time: 209 end_time = sample_time 210 211 thread_desc = ThreadDescriptor(sample.pid, sample.tid, sample.thread_comm) 212 213 threads.append(str(thread_desc)) 214 215 if thread_desc not in thread_descs: 216 bisect.insort(thread_descs, thread_desc) 217 218 callchain_str = '' 219 220 for i in range(callchain.nr): 221 symbol = callchain.entries[i].symbol # SymbolStruct 222 entry_line = '' 223 224 if args.include_dso_names: 225 entry_line += symbol._dso_name.decode('utf-8') + ':' 226 227 entry_line += symbol._symbol_name.decode('utf-8') 228 229 if args.include_symbols_addr: 230 entry_line += ':' + hex(symbol.symbol_addr) 231 232 if i < callchain.nr - 1: 233 callchain_str += entry_line + '<br>' 234 235 callchains.append(callchain_str) 236 237 # define colors per-process 238 palette = Category20b[20] 239 color_map = {} 240 241 last_pid = -1 242 palette_index = 0 243 244 for thread_desc in thread_descs: 245 if thread_desc.pid != last_pid: 246 last_pid = thread_desc.pid 247 palette_index += 1 248 palette_index %= len(palette) 249 250 color_map[str(thread_desc.pid)] = palette[palette_index] 251 252 colors = [] 253 for sample_thread in threads: 254 pid = str(sample_thread.split(':')[0]) 255 colors.append(color_map[pid]) 256 257 threads_range = [str(thread_desc) for thread_desc in thread_descs] 258 data_range = FactorRange(factors=threads_range, bounds='auto') 259 260 data = {'time': times, 261 'thread': threads, 262 'callchain': callchains, 263 'color': colors} 264 265 source = ColumnDataSource(data) 266 267 return source, data_range 268 269 270def main(): 271 parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) 272 parser.add_argument('-i', '--input_file', type=str, required=True, help='input file') 273 parser.add_argument('--title', '-t', type=str, help='document title') 274 parser.add_argument('--ksyms', '-k', type=str, help='path to kernel symbols (kallsyms)') 275 parser.add_argument('--usyms', '-u', type=str, help='path to tree with user space symbols') 276 parser.add_argument('--not_art', '-a', action='store_true', help='Don\'t show ART symbols') 277 parser.add_argument('--output', '-o', type=str, help='output file') 278 parser.add_argument('--dont_open', '-d', action='store_true', help='Don\'t open output file') 279 parser.add_argument('--include_dso_names', '-n', action='store_true', 280 help='Include dso names in backtraces') 281 parser.add_argument('--include_symbols_addr', '-s', action='store_true', 282 help='Include addresses of symbols in backtraces') 283 parser.add_argument( 284 '--proguard-mapping-file', nargs='+', 285 help='Add proguard mapping file to de-obfuscate symbols') 286 args = parser.parse_args() 287 288 # TODO test hierarchical ranges too 289 source, data_range = generate_datasource(args) 290 291 graph = create_graph(args, source, data_range) 292 table = create_table(graph) 293 294 output_filename = args.output 295 296 if not output_filename: 297 output_filename = os.path.splitext(os.path.basename(args.input_file))[0] + '.html' 298 299 title = os.path.splitext(os.path.basename(output_filename))[0] 300 301 html = generate_html(args, {'graph': graph, 'table': table}, title) 302 303 with io.open(output_filename, mode='w', encoding='utf-8') as fout: 304 fout.write(html) 305 306 if not args.dont_open: 307 view(output_filename) 308 309 310if __name__ == "__main__": 311 main() 312