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 46from simpleperf_utils import BaseArgumentParser 47# fmt: on 48 49 50def create_graph(args, source, data_range): 51 graph = figure( 52 sizing_mode='stretch_both', x_range=data_range, 53 tools=['pan', 'wheel_zoom', 'ywheel_zoom', 'xwheel_zoom', 'reset', 'tap', 'box_select'], 54 active_drag='box_select', active_scroll='wheel_zoom', 55 tooltips=[('thread', '@thread'), 56 ('callchain', '@callchain{safe}')], 57 title=args.title, name='graph') 58 59 # a crude way to avoid process name cluttering at some zoom levels. 60 # TODO: remove processes from the ticker base on the number of samples currently visualized. 61 # The process with most samples visualized should always be visible on the ticker 62 graph.xaxis.formatter = FuncTickFormatter(args={'range': data_range, 'graph': graph}, code=""" 63 var pixels_per_entry = graph.inner_height / (range.end - range.start) //Do not rond end and start here 64 var entries_to_skip = Math.ceil(12 / pixels_per_entry) // kind of 12 px per entry 65 var desc = tick.split(/:| /) 66 // desc[0] == desc[1] for main threads 67 var keep = (desc[0] == desc[1]) && 68 !(desc[2].includes('unknown') || 69 desc[2].includes('Binder') || 70 desc[2].includes('kworker')) 71 72 if (pixels_per_entry < 8 && !keep) { 73 //if (index + Math.round(range.start)) % entries_to_skip != 0) { 74 return "" 75 } 76 77 return tick """) 78 79 graph.xaxis.major_label_orientation = math.pi/6 80 81 graph.circle(y='time', 82 x='thread', 83 source=source, 84 color='color', 85 alpha=0.3, 86 selection_fill_color='White', 87 selection_line_color='Black', 88 selection_line_width=0.5, 89 selection_alpha=1.0) 90 91 graph.y_range.range_padding = 0 92 graph.xgrid.grid_line_color = None 93 return graph 94 95 96def create_table(graph): 97 # Empty dataframe, will be filled up in js land 98 empty_data = {'thread': [], 'count': []} 99 table_source = ColumnDataSource(pd.DataFrame( 100 empty_data, columns=['thread', 'count'], index=None)) 101 graph_source = graph.renderers[0].data_source 102 103 columns = [ 104 TableColumn(field='thread', title='Thread'), 105 TableColumn(field='count', title='Count') 106 ] 107 108 # start with a small table size (stretch doesn't reduce from the preferred size) 109 table = DataTable( 110 width=100, 111 height=100, 112 sizing_mode='stretch_both', 113 source=table_source, 114 columns=columns, 115 index_position=None, 116 name='table') 117 118 graph_selection_cb = CustomJS(code='update_selections()') 119 120 graph_source.selected.js_on_change('indices', graph_selection_cb) 121 table_source.selected.js_on_change('indices', CustomJS(args={}, code='update_flamegraph()')) 122 123 return table 124 125 126def generate_template(template_file='index.html.jinja2'): 127 loader = jinja2.FileSystemLoader( 128 searchpath=os.path.dirname(os.path.realpath(__file__)) + '/templates/') 129 130 env = jinja2.Environment(loader=loader) 131 return env.get_template(template_file) 132 133 134def generate_html(args, components_dict, title): 135 resources = INLINE.render() 136 script, div = components(components_dict) 137 return generate_template().render( 138 resources=resources, plot_script=script, plot_div=div, title=title) 139 140 141class ThreadDescriptor: 142 def __init__(self, pid, tid, name): 143 self.name = name 144 self.tid = tid 145 self.pid = pid 146 147 def __lt__(self, other): 148 return self.pid < other.pid or (self.pid == other.pid and self.tid < other.tid) 149 150 def __gt__(self, other): 151 return self.pid > other.pid or (self.pid == other.pid and self.tid > other.tid) 152 153 def __eq__(self, other): 154 return self.pid == other.pid and self.tid == other.tid and self.name == other.name 155 156 def __str__(self): 157 return str(self.pid) + ':' + str(self.tid) + ' ' + self.name 158 159 160def generate_datasource(args): 161 lib = sp.ReportLib() 162 lib.ShowIpForUnknownSymbol() 163 164 if args.usyms: 165 lib.SetSymfs(args.usyms) 166 167 if args.input_file: 168 lib.SetRecordFile(args.input_file) 169 170 if args.ksyms: 171 lib.SetKallsymsFile(args.ksyms) 172 173 lib.SetReportOptions(args.report_lib_options) 174 175 product = lib.MetaInfo().get('product_props') 176 177 if product: 178 manufacturer, model, name = product.split(':') 179 180 start_time = -1 181 end_time = -1 182 183 times = [] 184 threads = [] 185 thread_descs = [] 186 callchains = [] 187 188 while True: 189 sample = lib.GetNextSample() 190 191 if sample is None: 192 lib.Close() 193 break 194 195 symbol = lib.GetSymbolOfCurrentSample() 196 callchain = lib.GetCallChainOfCurrentSample() 197 198 if start_time == -1: 199 start_time = sample.time 200 201 sample_time = (sample.time - start_time) / 1e6 # convert to ms 202 203 times.append(sample_time) 204 205 if sample_time > end_time: 206 end_time = sample_time 207 208 thread_desc = ThreadDescriptor(sample.pid, sample.tid, sample.thread_comm) 209 210 threads.append(str(thread_desc)) 211 212 if thread_desc not in thread_descs: 213 bisect.insort(thread_descs, thread_desc) 214 215 callchain_str = '' 216 217 for i in range(callchain.nr): 218 symbol = callchain.entries[i].symbol # SymbolStruct 219 entry_line = '' 220 221 if args.include_dso_names: 222 entry_line += symbol._dso_name.decode('utf-8') + ':' 223 224 entry_line += symbol._symbol_name.decode('utf-8') 225 226 if args.include_symbols_addr: 227 entry_line += ':' + hex(symbol.symbol_addr) 228 229 if i < callchain.nr - 1: 230 callchain_str += entry_line + '<br>' 231 232 callchains.append(callchain_str) 233 234 # define colors per-process 235 palette = Category20b[20] 236 color_map = {} 237 238 last_pid = -1 239 palette_index = 0 240 241 for thread_desc in thread_descs: 242 if thread_desc.pid != last_pid: 243 last_pid = thread_desc.pid 244 palette_index += 1 245 palette_index %= len(palette) 246 247 color_map[str(thread_desc.pid)] = palette[palette_index] 248 249 colors = [] 250 for sample_thread in threads: 251 pid = str(sample_thread.split(':')[0]) 252 colors.append(color_map[pid]) 253 254 threads_range = [str(thread_desc) for thread_desc in thread_descs] 255 data_range = FactorRange(factors=threads_range, bounds='auto') 256 257 data = {'time': times, 258 'thread': threads, 259 'callchain': callchains, 260 'color': colors} 261 262 source = ColumnDataSource(data) 263 264 return source, data_range 265 266 267def main(): 268 parser = BaseArgumentParser() 269 parser.add_argument('-i', '--input_file', type=str, required=True, help='input file') 270 parser.add_argument('--title', '-t', type=str, help='document title') 271 parser.add_argument('--ksyms', '-k', type=str, help='path to kernel symbols (kallsyms)') 272 parser.add_argument('--usyms', '-u', type=str, help='path to tree with user space symbols') 273 parser.add_argument('--output', '-o', type=str, help='output file') 274 parser.add_argument('--dont_open', '-d', action='store_true', help='Don\'t open output file') 275 parser.add_argument('--include_dso_names', '-n', action='store_true', 276 help='Include dso names in backtraces') 277 parser.add_argument('--include_symbols_addr', '-s', action='store_true', 278 help='Include addresses of symbols in backtraces') 279 parser.add_report_lib_options(default_show_art_frames=True) 280 281 args = parser.parse_args() 282 283 # TODO test hierarchical ranges too 284 source, data_range = generate_datasource(args) 285 286 graph = create_graph(args, source, data_range) 287 table = create_table(graph) 288 289 output_filename = args.output 290 291 if not output_filename: 292 output_filename = os.path.splitext(os.path.basename(args.input_file))[0] + '.html' 293 294 title = os.path.splitext(os.path.basename(output_filename))[0] 295 296 html = generate_html(args, {'graph': graph, 'table': table}, title) 297 298 with io.open(output_filename, mode='w', encoding='utf-8') as fout: 299 fout.write(html) 300 301 if not args.dont_open: 302 view(output_filename) 303 304 305if __name__ == "__main__": 306 main() 307