| #!/usr/bin/env python3 |
| # |
| # Copyright (C) 2021 The Android Open Source Project |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| # |
| |
| import argparse |
| import bisect |
| import jinja2 |
| import io |
| import math |
| import os |
| import pandas as pd |
| from pathlib import Path |
| import re |
| import sys |
| |
| from bokeh.embed import components |
| from bokeh.io import output_file, show |
| from bokeh.layouts import layout, Spacer |
| from bokeh.models import ColumnDataSource, CustomJS, WheelZoomTool, HoverTool, CustomJSTickFormatter |
| from bokeh.models.widgets import DataTable, DateFormatter, TableColumn |
| from bokeh.models.ranges import FactorRange |
| from bokeh.palettes import Category20b |
| from bokeh.plotting import figure |
| from bokeh.resources import INLINE |
| from bokeh.transform import jitter |
| from bokeh.util.browser import view |
| from functools import cmp_to_key |
| |
| # fmt: off |
| simpleperf_path = Path(__file__).absolute().parents[1] |
| sys.path.insert(0, str(simpleperf_path)) |
| import simpleperf_report_lib as sp |
| from simpleperf_utils import BaseArgumentParser |
| # fmt: on |
| |
| |
| def create_graph(args, source, data_range): |
| graph = figure( |
| sizing_mode='stretch_both', x_range=data_range, |
| tools=['pan', 'wheel_zoom', 'ywheel_zoom', 'xwheel_zoom', 'reset', 'tap', 'box_select'], |
| active_drag='box_select', active_scroll='wheel_zoom', |
| tooltips=[('thread', '@thread'), |
| ('callchain', '@callchain{safe}')], |
| title=args.title, name='graph') |
| |
| # a crude way to avoid process name cluttering at some zoom levels. |
| # TODO: remove processes from the ticker base on the number of samples currently visualized. |
| # The process with most samples visualized should always be visible on the ticker |
| graph.xaxis.formatter = CustomJSTickFormatter(args={'range': data_range, 'graph': graph}, code=""" |
| var pixels_per_entry = graph.inner_height / (range.end - range.start) //Do not rond end and start here |
| var entries_to_skip = Math.ceil(12 / pixels_per_entry) // kind of 12 px per entry |
| var desc = tick.split(/:| /) |
| // desc[0] == desc[1] for main threads |
| var keep = (desc[0] == desc[1]) && |
| !(desc[2].includes('unknown') || |
| desc[2].includes('Binder') || |
| desc[2].includes('kworker')) |
| |
| if (pixels_per_entry < 8 && !keep) { |
| //if (index + Math.round(range.start)) % entries_to_skip != 0) { |
| return "" |
| } |
| |
| return tick """) |
| |
| graph.xaxis.major_label_orientation = math.pi/6 |
| |
| graph.circle(y='time', |
| x='thread', |
| source=source, |
| color='color', |
| alpha=0.3, |
| selection_fill_color='White', |
| selection_line_color='Black', |
| selection_line_width=0.5, |
| selection_alpha=1.0) |
| |
| graph.y_range.range_padding = 0 |
| graph.xgrid.grid_line_color = None |
| return graph |
| |
| |
| def create_table(graph): |
| # Empty dataframe, will be filled up in js land |
| empty_data = {'thread': [], 'count': []} |
| table_source = ColumnDataSource(pd.DataFrame( |
| empty_data, columns=['thread', 'count'], index=None)) |
| graph_source = graph.renderers[0].data_source |
| |
| columns = [ |
| TableColumn(field='thread', title='Thread'), |
| TableColumn(field='count', title='Count') |
| ] |
| |
| # start with a small table size (stretch doesn't reduce from the preferred size) |
| table = DataTable( |
| width=100, |
| height=100, |
| sizing_mode='stretch_both', |
| source=table_source, |
| columns=columns, |
| index_position=None, |
| name='table') |
| |
| graph_selection_cb = CustomJS(code='update_selections()') |
| |
| graph_source.selected.js_on_change('indices', graph_selection_cb) |
| table_source.selected.js_on_change('indices', CustomJS(args={}, code='update_flamegraph()')) |
| |
| return table |
| |
| |
| def generate_template(template_file='index.html.jinja2'): |
| loader = jinja2.FileSystemLoader( |
| searchpath=os.path.dirname(os.path.realpath(__file__)) + '/templates/') |
| |
| env = jinja2.Environment(loader=loader) |
| return env.get_template(template_file) |
| |
| |
| def generate_html(args, components_dict, title): |
| resources = INLINE.render() |
| script, div = components(components_dict) |
| return generate_template().render( |
| resources=resources, plot_script=script, plot_div=div, title=title) |
| |
| |
| class ThreadDescriptor: |
| def __init__(self, pid, tid, name): |
| self.name = name |
| self.tid = tid |
| self.pid = pid |
| |
| def __lt__(self, other): |
| return self.pid < other.pid or (self.pid == other.pid and self.tid < other.tid) |
| |
| def __gt__(self, other): |
| return self.pid > other.pid or (self.pid == other.pid and self.tid > other.tid) |
| |
| def __eq__(self, other): |
| return self.pid == other.pid and self.tid == other.tid and self.name == other.name |
| |
| def __str__(self): |
| return str(self.pid) + ':' + str(self.tid) + ' ' + self.name |
| |
| |
| def generate_datasource(args): |
| lib = sp.ReportLib() |
| lib.ShowIpForUnknownSymbol() |
| |
| if args.usyms: |
| lib.SetSymfs(args.usyms) |
| |
| if args.input_file: |
| lib.SetRecordFile(args.input_file) |
| |
| if args.ksyms: |
| lib.SetKallsymsFile(args.ksyms) |
| |
| lib.SetReportOptions(args.report_lib_options) |
| |
| product = lib.MetaInfo().get('product_props') |
| |
| if product: |
| manufacturer, model, name = product.split(':') |
| |
| start_time = -1 |
| end_time = -1 |
| |
| times = [] |
| threads = [] |
| thread_descs = [] |
| callchains = [] |
| |
| while True: |
| sample = lib.GetNextSample() |
| |
| if sample is None: |
| lib.Close() |
| break |
| |
| symbol = lib.GetSymbolOfCurrentSample() |
| callchain = lib.GetCallChainOfCurrentSample() |
| |
| if start_time == -1: |
| start_time = sample.time |
| |
| sample_time = (sample.time - start_time) / 1e6 # convert to ms |
| |
| times.append(sample_time) |
| |
| if sample_time > end_time: |
| end_time = sample_time |
| |
| thread_desc = ThreadDescriptor(sample.pid, sample.tid, sample.thread_comm) |
| |
| threads.append(str(thread_desc)) |
| |
| if thread_desc not in thread_descs: |
| bisect.insort(thread_descs, thread_desc) |
| |
| callchain_str = '' |
| |
| for i in range(callchain.nr): |
| symbol = callchain.entries[i].symbol # SymbolStruct |
| entry_line = '' |
| |
| if args.include_dso_names: |
| entry_line += symbol._dso_name.decode('utf-8') + ':' |
| |
| entry_line += symbol._symbol_name.decode('utf-8') |
| |
| if args.include_symbols_addr: |
| entry_line += ':' + hex(symbol.symbol_addr) |
| |
| if i < callchain.nr - 1: |
| callchain_str += entry_line + '<br>' |
| |
| callchains.append(callchain_str) |
| |
| # define colors per-process |
| palette = Category20b[20] |
| color_map = {} |
| |
| last_pid = -1 |
| palette_index = 0 |
| |
| for thread_desc in thread_descs: |
| if thread_desc.pid != last_pid: |
| last_pid = thread_desc.pid |
| palette_index += 1 |
| palette_index %= len(palette) |
| |
| color_map[str(thread_desc.pid)] = palette[palette_index] |
| |
| colors = [] |
| for sample_thread in threads: |
| pid = str(sample_thread.split(':')[0]) |
| colors.append(color_map[pid]) |
| |
| threads_range = [str(thread_desc) for thread_desc in thread_descs] |
| data_range = FactorRange(factors=threads_range, bounds='auto') |
| |
| data = {'time': times, |
| 'thread': threads, |
| 'callchain': callchains, |
| 'color': colors} |
| |
| source = ColumnDataSource(data) |
| |
| return source, data_range |
| |
| |
| def main(): |
| parser = BaseArgumentParser() |
| parser.add_argument('-i', '--input_file', type=str, required=True, help='input file') |
| parser.add_argument('--title', '-t', type=str, help='document title') |
| parser.add_argument('--ksyms', '-k', type=str, help='path to kernel symbols (kallsyms)') |
| parser.add_argument('--usyms', '-u', type=str, help='path to tree with user space symbols') |
| parser.add_argument('--output', '-o', type=str, help='output file') |
| parser.add_argument('--dont_open', '-d', action='store_true', help='Don\'t open output file') |
| parser.add_argument('--include_dso_names', '-n', action='store_true', |
| help='Include dso names in backtraces') |
| parser.add_argument('--include_symbols_addr', '-s', action='store_true', |
| help='Include addresses of symbols in backtraces') |
| parser.add_report_lib_options(default_show_art_frames=True) |
| |
| args = parser.parse_args() |
| |
| # TODO test hierarchical ranges too |
| source, data_range = generate_datasource(args) |
| |
| graph = create_graph(args, source, data_range) |
| table = create_table(graph) |
| |
| output_filename = args.output |
| |
| if not output_filename: |
| output_filename = os.path.splitext(os.path.basename(args.input_file))[0] + '.html' |
| |
| title = os.path.splitext(os.path.basename(output_filename))[0] |
| |
| html = generate_html(args, {'graph': graph, 'table': table}, title) |
| |
| with io.open(output_filename, mode='w', encoding='utf-8') as fout: |
| fout.write(html) |
| |
| if not args.dont_open: |
| view(output_filename) |
| |
| |
| if __name__ == "__main__": |
| main() |