#!/usr/bin/env python
# Copyright 2018 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
# This script parses result of build/android/adb_profile_chrome_startup and
# prints various information.
from __future__ import print_function
import argparse
import collections
import glob
import itertools
import json
import os
import re
def HumanSortingKey(string):
# From https://goo.gl/65xrva
def _ToInt(string):
return int(string) if string.isdigit() else string
return [_ToInt(c) for c in re.split('(\d+)', string)]
class LogIndenter(object):
_indentation = 0
def __init__(self, message=None, *arguments):
self._indented = False
if message is not None:
log(message, *arguments)
def __enter__(self):
self.indent()
return self
def __exit__(self, type, value, traceback):
self.unindent()
def indent(self):
if not self._indented:
LogIndenter._indentation += 1
self._indented = True
def unindent(self):
if self._indented:
LogIndenter._indentation -= 1
self._indented = False
@classmethod
def indentation(self):
return LogIndenter._indentation
def log(message, *arguments):
if not message:
print()
return
if arguments:
message = message.format(*arguments)
if LogIndenter.indentation() > 0:
message = ' ' * LogIndenter.indentation() + message
print(message)
def ParseTraceDatas(trace_file_path):
start_tag = re.compile(
'^\s*<script class="trace-data" type="application/text">$')
end_tag = re.compile('^(?P<line>.*?)\s*</script>$')
trace_datas = []
current_trace_lines = None
with open(trace_file_path) as trace_file:
for line in trace_file:
line = line.rstrip()
if current_trace_lines is None:
if start_tag.match(line):
current_trace_lines = []
else:
match = end_tag.match(line)
if match:
current_trace_lines.append(match.group('line'))
trace_datas.append('\n'.join(current_trace_lines))
current_trace_lines = None
else:
current_trace_lines.append(line)
return trace_datas
class Event(object):
PHASE_BEGIN = 'B'
PHASE_END = 'E'
PHASE_COMPLETE = 'X'
PHASE_ASYNC_BEGIN = 'S'
PHASE_ASYNC_END = 'F'
def __init__(self, node):
self._node = node
@property
def pid(self):
return int(self._node['pid'])
@property
def tid(self):
return int(self._node['tid'])
@property
def name(self):
return self._node.get('name')
@property
def phase(self):
return self._node['ph']
@property
def category(self):
return self._node['cat']
@property
def timestamp_us(self):
return long(self._node['ts'])
@property
def duration_us(self):
return long(self._node['dur'])
@property
def args(self):
return self._node['args']
class EventInterval(object):
def __init__(self, from_event=None, to_event=None):
self.from_event = from_event
self.to_event = to_event
def SetFromEventOnce(self, from_event):
if self.from_event is None:
self.from_event = from_event
def SetToEventOnce(self, to_event):
if self.to_event is None:
self.to_event = to_event
def FormatAsMilliseconds(self):
if not self.from_event:
time_string = "[missing the start of of the interval]"
elif not self.to_event:
time_string = "[missing the end of the interval]"
else:
interval_us = self.to_event.timestamp_us - self.from_event.timestamp_us
time_string = str(interval_us / 1000.0)
return time_string
def UpTo(self, other_iterval):
return EventInterval(self.from_event, other_iterval.to_event)
class Process(object):
BROWSER_NAME = 'Browser'
def __init__(self, pid):
self.pid = pid
self.name = None
self.events_by_name = collections.defaultdict(list)
self.time_ns_by_histogram = {}
self.malloc_counter_by_name = {}
# TODO: move these into Trace
self.startup_interval = EventInterval()
self.first_ui_interval = EventInterval()
class Trace(object):
def __init__(self, file_path):
self.file_path = file_path
self.process_by_pid = {}
self.startup_event = None
self.navigation_start_event = None
# TODO: convert these to properties over events
self.navigation_to_contentul_paint_interval = EventInterval()
self.navigation_to_meaningful_paint_interval = EventInterval()
self.navigation_to_commit_interval = None
@property
def startup_to_navigation_interval(self):
return EventInterval(self.startup_event, self.navigation_start_event)
def Finalize(self):
self.startup_event = self.FindFirstEvent(*Trace.STARTUP_EVENT_NAMES)
self.navigation_start_event = self.FindFirstEvent(
Trace.NAVIGATION_START_EVENT_NAME)
def _FindNavigationToCommitInterval():
events = self.FindAllEvents(Trace.NAVIGATION_COMMIT_EVENT_NAME)
interval = EventInterval()
for event in events:
if event.phase == Event.PHASE_ASYNC_BEGIN:
interval.SetFromEventOnce(event)
elif event.phase == Event.PHASE_ASYNC_END:
interval.SetToEventOnce(event)
return interval
self.navigation_to_commit_interval = _FindNavigationToCommitInterval()
def FindAllEvents(self, *names):
events = []
for process in self.process_by_pid.itervalues():
for name in names:
process_events = process.events_by_name.get(name)
if process_events:
events.extend(process_events)
events.sort(key=lambda e: e.timestamp_us)
return events
def FindFirstEvent(self, *names):
events = self.FindAllEvents(*names)
return events[0] if events else None
NAVIGATION_START_EVENT_NAME = 'NavigationTiming navigationStart'
NAVIGATION_COMMIT_EVENT_NAME = 'Navigation StartToCommit'
STARTUP_EVENT_NAMES = [
'Startup.BrowserMainEntryPoint', 'ChromeApplication.onCreate',
'ContentShellApplication.onCreate'
]
def ParseTrace(file_path):
trace_datas = ParseTraceDatas(file_path)
if not trace_datas:
raise Exception("The file doesn't have any trace-data elements.")
trace_json = None
for trace_data in trace_datas:
try:
trace_json = json.loads(trace_data)
except ValueError:
continue
if not trace_json:
raise Exception("Couldn't parse trace-data json.")
trace = Trace(file_path)
for event_node in trace_json['traceEvents']:
event = Event(event_node)
pid = event.pid
process = trace.process_by_pid.get(event.pid)
if not process:
process = Process(pid)
trace.process_by_pid[pid] = process
name = event.name
if not name:
continue
process.events_by_name[name].append(event)
phase = event.phase
category = event.category
if name == 'process_name':
process.name = event.args['name']
if (category == 'disabled-by-default-uma-addtime' and
name not in process.time_ns_by_histogram):
process.time_ns_by_histogram[name] = int(event.args['value_ns'])
if name in Trace.STARTUP_EVENT_NAMES:
process.startup_interval.SetFromEventOnce(event)
elif name == 'BenchmarkInstrumentation::ImplThreadRenderingStats':
process.startup_interval.SetToEventOnce(event)
if name == Trace.NAVIGATION_START_EVENT_NAME:
trace.navigation_to_contentul_paint_interval.SetFromEventOnce(event)
trace.navigation_to_meaningful_paint_interval.SetFromEventOnce(event)
elif name == 'firstContentfulPaint':
trace.navigation_to_contentul_paint_interval.SetToEventOnce(event)
elif name == 'firstMeaningfulPaint':
trace.navigation_to_meaningful_paint_interval.SetToEventOnce(event)
if (name == 'AsyncInitializationActivity.onCreate()' and
phase == Event.PHASE_END):
process.first_ui_interval.SetFromEventOnce(event)
elif name == 'ChromeBrowserInitializer.startChromeBrowserProcessesAsync':
process.first_ui_interval.SetToEventOnce(event)
if category == 'malloc' and name == 'malloc_counter':
counter_name, counter_value = next(event.args.iteritems())
process.malloc_counter_by_name[counter_name] = long(counter_value)
trace.Finalize()
return trace
EventSummary = collections.namedtuple('EventSummary', [
'trace',
'event',
'startup_to_event_ms',
'navigation_to_event_ms',
'duration_ms'
])
def SummarizeEvents(event_name_regex, trace, process):
summaries = []
def _AddSummary(event, start_us, duration_us):
startup_to_event_ms = (
None if trace.startup_event is None else
(start_us - trace.startup_event.timestamp_us) / 1000.0)
navigation_to_event_ms = (
None if trace.navigation_start_event is None else
(start_us - trace.navigation_start_event.timestamp_us) / 1000.0)
summaries.append(EventSummary(
trace, event, startup_to_event_ms, navigation_to_event_ms,
duration_us / 1000.0))
for name, events in process.events_by_name.iteritems():
if event_name_regex.search(name):
sorted_events = sorted(events,
key=lambda e: (e.tid, e.timestamp_us))
begin_event = None
for event in sorted_events:
if event.phase == Event.PHASE_COMPLETE:
_AddSummary(event, event.timestamp_us, event.duration_us)
elif (event.phase == Event.PHASE_BEGIN or
event.phase == Event.PHASE_ASYNC_BEGIN):
begin_event = event
elif (event.phase == Event.PHASE_END or
event.phase == Event.PHASE_ASYNC_END):
if begin_event is not None:
duration_us = event.timestamp_us - begin_event.timestamp_us
_AddSummary(event, begin_event.timestamp_us, duration_us)
begin_event = None
return summaries
def PrintReport(file_paths, options):
# TODO: don't accumulate traces, build report on the fly
traces = []
for file_path in file_paths:
log('Parsing {}...', file_path)
try:
traces.append(ParseTrace(file_path))
except Exception as e:
log('Oops: {}', e.message)
log('Parsed {} trace(s).', len(traces))
event_name_regex = None
event_summaries_by_name = collections.defaultdict(list)
if options.print_events:
event_name_regex = re.compile(options.print_events)
def _TraceSortingKey(trace):
return HumanSortingKey(os.path.basename(trace.file_path))
traces.sort(key=lambda t: _TraceSortingKey(t))
if options.csv:
separator = ','
gap = ''
else:
separator = '\t'
# Make it less likely for terminals to eat tabs when wrapping a line.
gap = ' '
table = [[
'File',
'Startup (ms)',
'StartupToNavigation (ms)',
'NavigationToCommit (ms)',
'NavigationToContentfulPaint (ms)',
'StartupToContentfulPaint (ms)',
'NavigationToMeaningfulPaint (ms)',
'StartupToMeaningfulPaint (ms)'
]]
for trace in traces:
browser_process = None
for process in trace.process_by_pid.itervalues():
if process.name == Process.BROWSER_NAME:
browser_process = process
break
if browser_process is None:
continue
table.append([
os.path.basename(trace.file_path),
browser_process.startup_interval.FormatAsMilliseconds(),
trace.startup_to_navigation_interval.FormatAsMilliseconds(),
trace.navigation_to_commit_interval.FormatAsMilliseconds(),
trace.navigation_to_contentul_paint_interval.FormatAsMilliseconds(),
browser_process.startup_interval.UpTo(trace.navigation_to_contentul_paint_interval).\
FormatAsMilliseconds(),
trace.navigation_to_meaningful_paint_interval.FormatAsMilliseconds(),
browser_process.startup_interval.UpTo(trace.navigation_to_meaningful_paint_interval).\
FormatAsMilliseconds()
])
if event_name_regex:
event_summaries = SummarizeEvents(
event_name_regex, trace, browser_process)
for summary in event_summaries:
event_summaries_by_name[summary.event.name].append(summary)
for name, event_summaries in event_summaries_by_name.iteritems():
table.append([])
summaries_by_trace = collections.defaultdict(list)
for summary in event_summaries:
summaries_by_trace[summary.trace].append(summary)
width = max(len(s) for s in summaries_by_trace.itervalues())
summary_headers = [
'StartupToEvent (ms)',
'NavigationToEvent (ms)',
'Duration (ms)'
]
table.append(
[name] +
([gap] * len(summary_headers)) +
list(itertools.chain.from_iterable(
['#{}'.format(i)] + [gap] * (len(summary_headers) - 1)
for i in range(1, width))))
table.append(
['File'] +
summary_headers * width)
trace_summaries = sorted(summaries_by_trace.iteritems(),
key=lambda t_s: _TraceSortingKey(t_s[0]))
for trace, summaries in trace_summaries:
row = [os.path.basename(trace.file_path)]
for summary in summaries:
row += [
(gap if summary.startup_to_event_ms is None
else summary.startup_to_event_ms),
(gap if summary.navigation_to_event_ms is None
else summary.navigation_to_event_ms),
summary.duration_ms
]
table.append(row)
print()
print('\n'.join(separator.join(str(v) for v in row) for row in table))
def PrintTrace(trace_file_path, options):
trace = ParseTrace(trace_file_path)
def _PrintInterval(name, interval):
log('{} (ms): {}', name, interval.FormatAsMilliseconds())
def _PrintHistogramTime(process, name):
time_ns = process.time_ns_by_histogram.get(name)
time_ms = None if time_ns is None else time_ns / 1e6
if time_ms is not None or options.print_none_histograms:
log('{} (ms): {}', name, time_ms)
histogram_names = [
'ChromeGeneratedCustomTab.IntentToFirstCommitNavigationTime3.ZoomedIn',
'CustomTabs.IntentToFirstCommitNavigationTime3.ZoomedIn',
'PageLoad.PaintTiming.NavigationToFirstPaint',
'PageLoad.PaintTiming.NavigationToFirstContentfulPaint',
'PageLoad.Experimental.PaintTiming.NavigationToFirstMeaningfulPaint',
'SessionRestore.ForegroundTabFirstPaint3',
]
processes = sorted(trace.process_by_pid.itervalues(), key=lambda p: p.name)
events_regex = None
if options.print_events:
events_regex = re.compile(options.print_events)
for process in processes:
log('{} timings:', process.name)
indenter = LogIndenter()
indenter.indent()
if process.name == Process.BROWSER_NAME:
_PrintInterval('Startup', process.startup_interval)
_PrintInterval('StartupToNavigation',
trace.startup_to_navigation_interval)
_PrintInterval('NavigationToCommit', trace.navigation_to_commit_interval)
_PrintInterval('NavigationToContentfulPaint',
trace.navigation_to_contentul_paint_interval)
_PrintInterval('StartupToContentfulPaint', process.startup_interval.UpTo(
trace.navigation_to_contentul_paint_interval))
_PrintInterval('NavigationToMeaningfulPaint',
trace.navigation_to_meaningful_paint_interval)
_PrintInterval('StartupToMeaningfulPaint', process.startup_interval.UpTo(
trace.navigation_to_meaningful_paint_interval))
if options.experimental:
_PrintInterval('First UI interval', process.first_ui_interval)
if process.malloc_counter_by_name:
def _PrintMallocCounter(title, value_name, factor):
value = process.malloc_counter_by_name.get(value_name)
if value is not None:
value /= factor
log('{}: {}', title, value)
log('Malloc counters:')
with LogIndenter():
_PrintMallocCounter('Total time (ms)', 'malloc_time_ns', 1000000)
_PrintMallocCounter('Total allocated (KiB)', 'allocated_bytes', 1024)
_PrintMallocCounter('Allocations', 'allocation_count', 1)
_PrintMallocCounter('Frees', 'free_count', 1)
for histogram_name in histogram_names:
_PrintHistogramTime(process, histogram_name)
if events_regex:
event_summaries = SummarizeEvents(events_regex, trace, process)
if event_summaries:
with LogIndenter('Events matching "{}":', events_regex.pattern):
for event_summary in event_summaries:
with LogIndenter('{}:', event_summary.event.name):
if event_summary.startup_to_event_ms is not None:
log('StartupToEvent (ms): {}',
event_summary.startup_to_event_ms)
if event_summary.navigation_to_event_ms is not None:
log('NavigationToEvent (ms): {}',
event_summary.navigation_to_event_ms)
log('Duration (ms): {}', event_summary.duration_ms)
indenter.unindent()
log('')
def main():
parser = argparse.ArgumentParser()
parser.add_argument('file_or_glob')
parser.add_argument('--print-none-histograms',
help='Print histograms with None values.',
default=False, action='store_true')
# TODO: introduce a variant that takes a list of event names, as escaping
# event names can be tedious.
# TODO: match regex against '<index>|<event name>' to allow selecting
# events by index (complicated for begin/end pairs).
parser.add_argument('--print-events',
help='Print events matching the specified regex.')
parser.add_argument('--experimental',
default=False, action='store_true',
help='Enable experimental stuff.')
parser.add_argument('--report',
default=False, action='store_true',
help='Present information as a tab-separated table.')
parser.add_argument('--csv',
default=False, action='store_true',
help=('Separate report values by commas (not tabs).'))
options = parser.parse_args()
globbed = False
if os.path.isfile(options.file_or_glob):
trace_file_paths = [options.file_or_glob]
else:
globbed = True
file_pattern = options.file_or_glob
trace_file_paths = glob.glob(file_pattern)
if not trace_file_paths:
file_pattern += '*html'
trace_file_paths = glob.glob(file_pattern)
if not trace_file_paths:
log("'{}' didn't match anything.", file_pattern)
return
log("'{}' matched {} file(s).", file_pattern, len(trace_file_paths))
log('')
if options.report:
PrintReport(trace_file_paths, options)
else:
for file_path in trace_file_paths:
if globbed:
log('_' * len(file_path))
log(file_path)
log('')
PrintTrace(file_path, options)
if __name__ == '__main__':
main()