2021-03-10 01:04:46 +00:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
|
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
|
|
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
|
|
|
|
|
import argparse
|
2021-05-19 16:45:33 +00:00
|
|
|
import datetime
|
2021-03-10 01:04:46 +00:00
|
|
|
import os
|
2021-05-19 16:45:33 +00:00
|
|
|
import re
|
2021-03-10 01:04:46 +00:00
|
|
|
import subprocess
|
|
|
|
import time
|
|
|
|
|
2021-05-26 02:15:10 +00:00
|
|
|
DESC = """Measures start up durations using multiple methodologies.
|
2021-03-10 01:04:46 +00:00
|
|
|
|
2021-05-26 02:15:10 +00:00
|
|
|
IMPORTANT: each methodology provides a different picture of start up. If you're
|
|
|
|
not sure which one to use, please ask the perf team!
|
|
|
|
|
|
|
|
IMPORTANT: some tests require manual test set up! Read the output carefully.
|
|
|
|
|
|
|
|
This system is temporary until mozperftest is ready (e.g. less noisy). As such,
|
|
|
|
we try to keep this simple and avoid implementing all the things they do.
|
2021-03-10 01:04:46 +00:00
|
|
|
"""
|
|
|
|
|
|
|
|
DEFAULT_ITER_COUNT = 25
|
|
|
|
|
2021-07-21 23:54:50 +00:00
|
|
|
FOCUS_CHANNEL_TO_PKG = {
|
|
|
|
'nightly': 'org.mozilla.focus', # it seems problematic that this is the same as release.
|
|
|
|
'beta': 'org.mozilla.focus.beta', # only present since post-fenix update.
|
|
|
|
'release': 'org.mozilla.focus',
|
|
|
|
'debug': 'org.mozilla.focus.debug'
|
|
|
|
}
|
|
|
|
|
|
|
|
FENIX_CHANNEL_TO_PKG = {
|
2021-03-10 01:04:46 +00:00
|
|
|
'nightly': 'org.mozilla.fenix',
|
|
|
|
'beta': 'org.mozilla.firefox.beta',
|
|
|
|
'release': 'org.mozilla.firefox',
|
|
|
|
'debug': 'org.mozilla.fenix.debug'
|
|
|
|
}
|
|
|
|
|
2021-05-19 16:45:33 +00:00
|
|
|
TEST_COLD_MAIN_FF = 'cold_main_first_frame'
|
2021-06-14 22:57:14 +00:00
|
|
|
TEST_COLD_MAIN_RESTORE = 'cold_main_session_restore'
|
2021-05-19 16:45:33 +00:00
|
|
|
TEST_COLD_VIEW_FF = 'cold_view_first_frame'
|
|
|
|
TEST_COLD_VIEW_NAV_START = 'cold_view_nav_start'
|
2021-06-14 22:57:14 +00:00
|
|
|
TESTS = [TEST_COLD_MAIN_FF, TEST_COLD_MAIN_RESTORE, TEST_COLD_VIEW_FF, TEST_COLD_VIEW_NAV_START]
|
2021-05-19 16:45:33 +00:00
|
|
|
|
2021-07-21 18:23:57 +00:00
|
|
|
TEST_URI = 'https://example.com'
|
|
|
|
|
2021-07-21 23:54:50 +00:00
|
|
|
PROD_FENIX = 'fenix'
|
|
|
|
PROD_FOCUS = 'focus'
|
|
|
|
PRODUCTS = [PROD_FENIX, PROD_FOCUS]
|
|
|
|
|
2021-03-10 01:04:46 +00:00
|
|
|
|
|
|
|
def parse_args():
|
2021-05-26 02:15:10 +00:00
|
|
|
parser = argparse.ArgumentParser(description=DESC, formatter_class=argparse.RawTextHelpFormatter)
|
2021-07-21 23:54:50 +00:00
|
|
|
|
|
|
|
assert FENIX_CHANNEL_TO_PKG.keys() == FOCUS_CHANNEL_TO_PKG.keys(), 'should be equal to use one for choices= below'
|
2021-03-10 01:04:46 +00:00
|
|
|
parser.add_argument(
|
2021-07-21 23:54:50 +00:00
|
|
|
"release_channel", choices=FENIX_CHANNEL_TO_PKG.keys(), help="the release channel to measure"
|
2021-03-10 01:04:46 +00:00
|
|
|
)
|
|
|
|
parser.add_argument(
|
2021-05-26 02:15:10 +00:00
|
|
|
"test_name", choices=TESTS, help="""the measurement methodology to use. Options:
|
|
|
|
- {cold_main_ff}: click the app icon & get duration to first frame from 'am start -W'
|
2021-06-14 22:57:14 +00:00
|
|
|
- {cold_main_restore}: click the app icon & get duration from logcat: START proc to PageStart
|
2021-05-26 02:15:10 +00:00
|
|
|
- {cold_view_ff}: send a VIEW intent & get duration to first frame from 'am start -W'
|
|
|
|
- {cold_view_nav_start}: send a VIEW intent & get duration from logcat: START proc to PageStart
|
|
|
|
|
|
|
|
Measurements to first frame are a reimplementation of
|
|
|
|
https://medium.com/androiddevelopers/testing-app-startup-performance-36169c27ee55
|
|
|
|
|
|
|
|
See https://wiki.mozilla.org/Performance/Fenix#Terminology for descriptions of cold/warm/hot and main/view""".format(
|
2021-07-21 18:56:54 +00:00
|
|
|
cold_main_ff=TEST_COLD_MAIN_FF, cold_main_restore=TEST_COLD_MAIN_RESTORE,
|
|
|
|
cold_view_ff=TEST_COLD_VIEW_FF, cold_view_nav_start=TEST_COLD_VIEW_NAV_START,
|
|
|
|
))
|
2021-05-19 16:45:33 +00:00
|
|
|
parser.add_argument("path", help="the path to save the measurement results; will abort if file exists")
|
2021-03-10 01:04:46 +00:00
|
|
|
|
2021-07-21 23:54:50 +00:00
|
|
|
# We ordinarily wouldn't specify a default because it may cause the user to get results
|
|
|
|
# from a product they didn't intend but this script lives in the fenix repo so having fenix
|
|
|
|
# as a default would be less confusing for users.
|
|
|
|
parser.add_argument("-p", "--product", default=PROD_FENIX, choices=PRODUCTS,
|
|
|
|
help="which product to get measurements from")
|
|
|
|
|
2021-03-10 01:04:46 +00:00
|
|
|
parser.add_argument("-c", "--iter-count", default=DEFAULT_ITER_COUNT, type=int,
|
|
|
|
help="the number of iterations to run. defaults to {}".format(DEFAULT_ITER_COUNT))
|
2021-07-21 18:56:54 +00:00
|
|
|
parser.add_argument("-f", "--force", action="store_true",
|
|
|
|
help="overwrite the given path rather than stopping on file existence")
|
2021-05-19 16:45:33 +00:00
|
|
|
|
2021-03-10 01:04:46 +00:00
|
|
|
return parser.parse_args()
|
|
|
|
|
|
|
|
|
|
|
|
def validate_args(args):
|
|
|
|
# This helps prevent us from accidentally overwriting previous measurements.
|
|
|
|
if not args.force:
|
|
|
|
if os.path.exists(args.path):
|
|
|
|
raise Exception("Given `path` unexpectedly exists: pick a new path or use --force to overwrite.")
|
|
|
|
|
|
|
|
|
2021-07-21 23:54:50 +00:00
|
|
|
def product_channel_to_pkg_id(product, channel):
|
|
|
|
if product == PROD_FENIX:
|
|
|
|
pkg_to_channel_map = FENIX_CHANNEL_TO_PKG
|
|
|
|
elif product == PROD_FOCUS:
|
|
|
|
pkg_to_channel_map = FOCUS_CHANNEL_TO_PKG
|
|
|
|
return pkg_to_channel_map[channel]
|
|
|
|
|
|
|
|
|
2021-07-21 18:23:57 +00:00
|
|
|
def get_adb_shell_args():
|
|
|
|
return ['adb', 'shell']
|
|
|
|
|
|
|
|
|
2021-03-10 01:04:46 +00:00
|
|
|
def get_activity_manager_args():
|
2021-07-21 18:23:57 +00:00
|
|
|
return get_adb_shell_args() + ['am']
|
2021-03-10 01:04:46 +00:00
|
|
|
|
|
|
|
|
|
|
|
def force_stop(pkg_id):
|
|
|
|
args = get_activity_manager_args() + ['force-stop', pkg_id]
|
|
|
|
subprocess.run(args, check=True)
|
|
|
|
|
|
|
|
|
|
|
|
def disable_startup_profiling():
|
|
|
|
# Startup profiling sets the app to the "debug-app" which executes extra code to
|
|
|
|
# read a config file off disk that triggers the profiling. Removing the app as a
|
|
|
|
# debug app should address that issue but isn't a perfect clean up.
|
|
|
|
args = get_activity_manager_args() + ['clear-debug-app']
|
|
|
|
subprocess.run(args, check=True)
|
|
|
|
|
|
|
|
|
2021-07-21 18:23:57 +00:00
|
|
|
def get_component_name_for_intent(pkg_id, intent):
|
|
|
|
resolve_component_args = (get_adb_shell_args()
|
|
|
|
+ ['cmd', 'package', 'resolve-activity', '--brief']
|
|
|
|
+ intent + [pkg_id])
|
|
|
|
proc = subprocess.run(resolve_component_args, capture_output=True)
|
|
|
|
stdout = proc.stdout.splitlines()
|
|
|
|
assert len(stdout) == 2, 'expected 2 lines. Got: {}'.format(stdout)
|
|
|
|
return stdout[1]
|
|
|
|
|
|
|
|
|
2021-05-19 16:45:33 +00:00
|
|
|
def get_start_cmd(test_name, pkg_id):
|
2021-07-21 18:23:57 +00:00
|
|
|
intent_action_prefix = 'android.intent.action.{}'
|
2021-06-14 22:57:14 +00:00
|
|
|
if test_name in [TEST_COLD_MAIN_FF, TEST_COLD_MAIN_RESTORE]:
|
2021-07-21 18:23:57 +00:00
|
|
|
intent = [
|
|
|
|
'-a', intent_action_prefix.format('MAIN'),
|
|
|
|
'-c', 'android.intent.category.LAUNCHER',
|
|
|
|
]
|
2021-06-14 22:51:54 +00:00
|
|
|
elif test_name in [TEST_COLD_VIEW_FF, TEST_COLD_VIEW_NAV_START]:
|
2021-07-21 18:23:57 +00:00
|
|
|
intent = [
|
|
|
|
'-a', intent_action_prefix.format('VIEW'),
|
|
|
|
'-d', TEST_URI
|
2021-03-10 01:04:46 +00:00
|
|
|
]
|
2021-07-21 18:23:57 +00:00
|
|
|
|
|
|
|
# You can't launch an app without an pkg_id/activity pair. Instead of
|
|
|
|
# hard-coding the activity, which could break on app updates, we ask the
|
|
|
|
# system to resolve it for us.
|
|
|
|
component_name = get_component_name_for_intent(pkg_id, intent)
|
|
|
|
cmd = get_activity_manager_args() + [
|
|
|
|
'start-activity', # this would change to `start` on older API levels like GS5.
|
|
|
|
'-W', # wait for app launch to complete before returning
|
|
|
|
'-n', component_name
|
|
|
|
] + intent
|
2021-03-10 01:04:46 +00:00
|
|
|
return cmd
|
|
|
|
|
|
|
|
|
2021-07-21 23:54:50 +00:00
|
|
|
def measure(test_name, product, pkg_id, start_cmd_args, iter_count):
|
2021-03-10 01:04:46 +00:00
|
|
|
# Startup profiling may accidentally be left enabled and throw off the results.
|
|
|
|
# To prevent this, we disable it.
|
|
|
|
disable_startup_profiling()
|
|
|
|
|
|
|
|
# After an (re)installation, we've observed the app starts up more slowly than subsequent runs.
|
|
|
|
# As such, we start it once beforehand to let it settle.
|
|
|
|
force_stop(pkg_id)
|
2021-07-21 18:56:54 +00:00
|
|
|
subprocess.run(start_cmd_args, check=True, capture_output=True) # capture_output so no print to stdout.
|
2021-03-10 01:04:46 +00:00
|
|
|
time.sleep(5) # To hopefully reach visual completeness.
|
|
|
|
|
|
|
|
measurements = []
|
2021-05-19 16:45:33 +00:00
|
|
|
for _ in range(0, iter_count):
|
2021-03-10 01:04:46 +00:00
|
|
|
force_stop(pkg_id)
|
|
|
|
time.sleep(1)
|
2021-05-19 16:45:33 +00:00
|
|
|
|
|
|
|
# This is only necessary for nav start tests (to ensure logcat only contains the result from the current run).
|
|
|
|
# However, it's not known to be disruptive to other tests (to first frame) so we leave it in.
|
|
|
|
subprocess.run(['adb', 'logcat', '-c'], check=True)
|
|
|
|
|
2021-03-10 01:04:46 +00:00
|
|
|
proc = subprocess.run(start_cmd_args, check=True, capture_output=True) # expected to wait for app to start.
|
2021-07-21 23:54:50 +00:00
|
|
|
measurements.append(get_measurement(test_name, product, pkg_id, proc.stdout))
|
2021-05-19 16:45:33 +00:00
|
|
|
|
2021-03-10 01:04:46 +00:00
|
|
|
return measurements
|
|
|
|
|
|
|
|
|
2021-07-21 23:54:50 +00:00
|
|
|
def get_measurement(test_name, product, pkg_id, stdout):
|
2021-06-14 22:51:54 +00:00
|
|
|
if test_name in [TEST_COLD_MAIN_FF, TEST_COLD_VIEW_FF]:
|
2021-05-19 16:45:33 +00:00
|
|
|
measurement = get_measurement_from_am_start_log(stdout)
|
2021-06-14 22:57:14 +00:00
|
|
|
elif test_name in [TEST_COLD_VIEW_NAV_START, TEST_COLD_MAIN_RESTORE]:
|
2021-06-17 19:25:34 +00:00
|
|
|
time.sleep(4) # We must sleep until the navigation start event occurs.
|
2021-05-19 16:45:33 +00:00
|
|
|
proc = subprocess.run(['adb', 'logcat', '-d'], check=True, capture_output=True)
|
2021-07-21 23:54:50 +00:00
|
|
|
measurement = get_measurement_from_nav_start_logcat(product, pkg_id, proc.stdout)
|
2021-05-19 16:45:33 +00:00
|
|
|
return measurement
|
|
|
|
|
|
|
|
|
|
|
|
def get_measurement_from_am_start_log(stdout):
|
|
|
|
# Sample output:
|
2021-03-10 01:04:46 +00:00
|
|
|
# Starting: Intent { cmp=org.mozilla.fenix/.App }
|
|
|
|
# Status: ok
|
|
|
|
# Activity: org.mozilla.fenix/.App
|
|
|
|
# ThisTime: 5662
|
|
|
|
# TotalTime: 5662
|
|
|
|
# WaitTime: 5680
|
|
|
|
# Complete
|
|
|
|
total_time_prefix = b'TotalTime: '
|
|
|
|
lines = stdout.split(b'\n')
|
|
|
|
matching_lines = [line for line in lines if line.startswith(total_time_prefix)]
|
|
|
|
if len(matching_lines) != 1:
|
|
|
|
raise Exception('Each run should only have one {} but this unexpectedly had more: '.format(total_time_prefix) +
|
|
|
|
matching_lines)
|
|
|
|
duration = int(matching_lines[0][len(total_time_prefix):])
|
|
|
|
return duration
|
|
|
|
|
|
|
|
|
2021-07-21 23:54:50 +00:00
|
|
|
def get_measurement_from_nav_start_logcat(product, pkg_id, logcat_bytes):
|
2021-05-19 16:45:33 +00:00
|
|
|
# Relevant lines:
|
2021-07-21 18:56:54 +00:00
|
|
|
# 05-18 14:32:47.366 1759 6003 I ActivityManager: START u0 {act=android.intent.action.VIEW dat=https://example.com/... typ=text/html flg=0x10000000 cmp=org.mozilla.fenix/.IntentReceiverActivity} from uid 2000 # noqa
|
|
|
|
# 05-18 14:32:47.402 1759 6003 I ActivityManager: Start proc 9007:org.mozilla.fenix/u0a170 for activity org.mozilla.fenix/.IntentReceiverActivity # noqa
|
2021-05-19 16:45:33 +00:00
|
|
|
# 05-18 14:32:50.809 9007 9007 I GeckoSession: handleMessage GeckoView:PageStart uri=
|
|
|
|
# 05-18 14:32:50.821 9007 9007 I GeckoSession: handleMessage GeckoView:PageStop uri=null
|
|
|
|
def line_to_datetime(line):
|
|
|
|
date_str = ' '.join(line.split(' ')[:2]) # e.g. "05-18 14:32:47.366"
|
|
|
|
# strptime needs microseconds. logcat outputs millis so we append zeroes
|
|
|
|
date_str_with_micros = date_str + '000'
|
|
|
|
return datetime.datetime.strptime(date_str_with_micros, '%m-%d %H:%M:%S.%f')
|
|
|
|
|
|
|
|
def get_proc_start_datetime():
|
|
|
|
# This regex may not work on older versions of Android: we don't care
|
|
|
|
# yet because supporting older versions isn't in our requirements.
|
2021-07-21 18:56:54 +00:00
|
|
|
proc_start_re = re.compile(r'ActivityManager: Start proc \d+:{}/'.format(pkg_id))
|
2021-05-19 16:45:33 +00:00
|
|
|
proc_start_lines = [line for line in lines if proc_start_re.search(line)]
|
|
|
|
assert len(proc_start_lines) == 1
|
|
|
|
return line_to_datetime(proc_start_lines[0])
|
|
|
|
|
|
|
|
def get_page_start_datetime():
|
|
|
|
page_start_re = re.compile('GeckoSession: handleMessage GeckoView:PageStart uri=')
|
|
|
|
page_start_lines = [line for line in lines if page_start_re.search(line)]
|
2021-07-21 23:54:50 +00:00
|
|
|
page_start_line_count = len(page_start_lines)
|
|
|
|
page_start_assert_msg = 'found len=' + str(page_start_line_count)
|
|
|
|
|
|
|
|
# In focus versions <= v8.8.2, it logs 3 PageStart lines and these include actual uris.
|
|
|
|
# We need to handle our assertion differently due to the different line count.
|
|
|
|
#
|
|
|
|
# In focus versions >= v8.8.3, this measurement is broken because the logcat were removed.
|
|
|
|
is_old_version_of_focus = 'about:blank' in page_start_lines[0] and product == PROD_FOCUS
|
|
|
|
if is_old_version_of_focus:
|
|
|
|
assert page_start_line_count == 3, page_start_assert_msg # Lines: about:blank, target URL, target URL.
|
|
|
|
else:
|
|
|
|
assert page_start_line_count == 2, page_start_assert_msg # Lines: about:blank, target URL.
|
2021-05-19 16:45:33 +00:00
|
|
|
return line_to_datetime(page_start_lines[1]) # 2nd PageStart is for target URL.
|
|
|
|
|
|
|
|
logcat = logcat_bytes.decode('UTF-8') # Easier to work with and must for strptime.
|
|
|
|
lines = logcat.split('\n')
|
|
|
|
|
|
|
|
# We measure the time from process start, rather than the earlier START
|
|
|
|
# activity line, because I assume we have no control over the duration
|
|
|
|
# before our process starts. If we wanted to put in more time, we could
|
|
|
|
# double-check this assumption by seeing what values `am start -W` returns
|
|
|
|
# compared to the time stamps.
|
2021-07-21 18:56:54 +00:00
|
|
|
#
|
|
|
|
# For total_seconds(), values < 1s are expressed in decimal (e.g. .001 is 1ms).
|
|
|
|
elapsed_seconds = (get_page_start_datetime() - get_proc_start_datetime()).total_seconds()
|
2021-05-19 16:45:33 +00:00
|
|
|
elapsed_millis = round(elapsed_seconds * 1000)
|
|
|
|
return elapsed_millis
|
|
|
|
|
|
|
|
|
2021-03-10 01:04:46 +00:00
|
|
|
def save_measurements(path, measurements):
|
|
|
|
with open(path, 'w') as f:
|
|
|
|
for measurement in measurements:
|
|
|
|
f.write(str(measurement) + '\n')
|
|
|
|
|
|
|
|
|
2021-05-26 02:15:10 +00:00
|
|
|
def print_preface_text(test_name):
|
|
|
|
print("To analyze the results, use this script (we recommend using the median):" +
|
|
|
|
"\nhttps://github.com/mozilla-mobile/perf-tools/blob/master/analyze_durations.py")
|
2021-06-14 22:51:54 +00:00
|
|
|
if test_name in [TEST_COLD_MAIN_FF]:
|
2021-05-26 02:15:10 +00:00
|
|
|
print("\nWARNING: you may wish to clear the onboarding experience manually.")
|
2021-06-14 22:51:54 +00:00
|
|
|
elif test_name in [TEST_COLD_VIEW_FF, TEST_COLD_VIEW_NAV_START]:
|
2021-05-26 02:15:10 +00:00
|
|
|
print("\nWARNING: you may wish to reduce the number of open tabs when starting this test")
|
|
|
|
print("as this test may leave many additional tabs open which could impact the results.")
|
2021-06-14 22:57:14 +00:00
|
|
|
elif test_name in [TEST_COLD_MAIN_RESTORE]:
|
|
|
|
print("\nWARNING: ensure at least one tab is opened when starting this test.")
|
2021-05-26 02:15:10 +00:00
|
|
|
|
|
|
|
|
2021-03-10 01:04:46 +00:00
|
|
|
def main():
|
|
|
|
args = parse_args()
|
|
|
|
validate_args(args)
|
|
|
|
|
2021-07-21 23:54:50 +00:00
|
|
|
pkg_id = product_channel_to_pkg_id(args.product, args.release_channel)
|
2021-05-19 16:45:33 +00:00
|
|
|
start_cmd = get_start_cmd(args.test_name, pkg_id)
|
2021-05-26 02:15:10 +00:00
|
|
|
print_preface_text(args.test_name)
|
2021-07-21 23:54:50 +00:00
|
|
|
measurements = measure(args.test_name, args.product, pkg_id, start_cmd, args.iter_count)
|
2021-03-10 01:04:46 +00:00
|
|
|
save_measurements(args.path, measurements)
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
main()
|