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