#!/usr/bin/env python3
#
# Copyright 2023 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Replays a touch scenario in a loop for A/B comparisons.
Usage:
* Add to out/AndroidReleaseOfficial/args.gn:
use_jank_tracker_for_experiments = true
* Build the ReleaseOfficial flavour of Chrome
autoninja -C out/AndroidReleaseOfficial monochrome_64_bundle
* Install it on device
out/AndroidReleaseOfficial/bin/monochrome_64_bundle install
* Build the touch_replay
autoninja -C out/AndroidReleaseOfficial touch_replay
* Record the scenario
adb root
adb push out/AndroidReleaseOfficial/touch_replay /data/local/tmp
adb shell '/data/local/tmp/touch_replay record \
/data/local/tmp/touch_events.dump'
* Fetch the events from the device
adb pull '/data/local/tmp/touch_events.dump'
* Loop infinitely
tools/android/touch_replay_benchmark/run_benchmark.py \
--config=tools/android/touch_replay_benchmark/example.yaml \
--output=/tmp/output \
--events=touch_events.dump \
--replayer=out/AndroidReleaseOfficial/touch_replay
"""
import argparse
import copy
import json
import logging
import os
import random
import re
import signal
import subprocess
import sys
import time
_SRC_PATH = os.path.abspath(
os.path.join(os.path.dirname(__file__), '..', '..', '..'))
sys.path.append(os.path.join(_SRC_PATH, 'third_party'))
import pyyaml
sys.path.append(os.path.join(_SRC_PATH, 'third_party', 'catapult', 'devil'))
from devil.android import device_errors
from devil.android import device_utils
from devil.android import flag_changer
from devil.android.sdk import intent
sys.path.append(os.path.join(_SRC_PATH, 'build', 'android'))
import devil_chromium
from pylib import constants
sys.path.append(os.path.join(_SRC_PATH, 'tools', 'variations'))
import fieldtrial_util
_FIELDTRIAL_TESTING_CONFIG = os.path.join(_SRC_PATH, 'testing', 'variations',
'fieldtrial_testing_config.json')
_EVENTS_FILE_ON_DEVICE = '/data/local/tmp/touch_events.dump'
_REPLAY_EXECUTABLE_ON_DEVICE = '/data/local/tmp/touch_replay'
_NOTIFY_FILE_ON_DEVICE = '/data/local/tmp/inotify-jank-report'
_EMPTY_FILE_ON_DEVICE = '/data/local/tmp/empty_file'
def _CreateParser():
parser = argparse.ArgumentParser()
parser.add_argument('-d', '--device', help='Android device to use.')
parser.add_argument('-o',
'--output',
required=True,
help='Directory for results.')
parser.add_argument('-c',
'--config',
required=True,
help='YAML configuration file defining ' +
'combinations of parameters to be compared. ' +
'See example.yaml.')
parser.add_argument('-e',
'--events',
required=True,
help='Replay file to simulate touch gestures')
parser.add_argument('-r',
'--replayer',
required=True,
help='Path to the |touch_replay| binary')
parser.add_argument('-n',
'--n',
type=int,
default=-1,
help='Number of runs to make. ' +
'A negative number means infinite. ' +
'This is the default')
return parser
def _GetPreferredDevice(preferred_device_serial):
if preferred_device_serial:
devices = device_utils.DeviceUtils.HealthyDevices(
device_arg=preferred_device_serial)
else:
devices = device_utils.DeviceUtils.HealthyDevices()
if devices and devices[0].IsOnline():
return devices[0]
return None
def _PrepareNotification(device: device_utils.DeviceUtils):
device.RunShellCommand(['/system/bin/rm', '-rf', _NOTIFY_FILE_ON_DEVICE],
check_return=True)
device.RunShellCommand(['/system/bin/mkdir', '-p', _NOTIFY_FILE_ON_DEVICE],
check_return=True)
def _LogcatMessage(device, message: str):
device.RunShellCommand(['log', '-p', 'v', '-t', 'touch_replay', message],
check_return=True)
def _TouchNotificationFileOnDevice(device: device_utils.DeviceUtils):
_LogcatMessage(device, 'RequestJankTrackerCSV')
device.RunShellCommand(['/system/bin/touch', _EMPTY_FILE_ON_DEVICE],
check_return=True)
device.RunShellCommand(
['/system/bin/mv', '-f', _EMPTY_FILE_ON_DEVICE, _NOTIFY_FILE_ON_DEVICE],
check_return=True)
def _ReplayTouchEvents(device: device_utils.DeviceUtils):
time.sleep(1)
device.RunShellCommand(
[_REPLAY_EXECUTABLE_ON_DEVICE, 'replay', _EVENTS_FILE_ON_DEVICE],
as_root=True,
check_return=True)
time.sleep(5)
_TouchNotificationFileOnDevice(device)
def _ReplayTouchEventsWithCpuProfile(device: device_utils.DeviceUtils,
trace_dir: str, config):
os.makedirs(trace_dir)
command = [
config['cpu_profile'], '--config', config['chrometto_config'], '-o',
trace_dir
]
proc = subprocess.Popen(command)
# Replay.
_ReplayTouchEvents(device)
# Collect the trace.
proc_pid = proc.pid
os.kill(proc_pid, signal.SIGINT)
os.waitpid(proc_pid, 0)
def _ExtractJankCsv(logcat_monitor):
try:
result_line_re = re.compile(r'JankyDurationTrackerCSV:(.*)$')
match = logcat_monitor.WaitFor(result_line_re, timeout=60)
occurrences = sum(
1 for _ in logcat_monitor.FindAll(r'.*JankyDurationTrackerCSV:.*'))
if occurrences != 1:
logging.error(
'JankyDurationTrackerCSV occurrences: {}, skipping result'.format(
occurrences))
else:
return match[1]
except device_errors.CommandTimeoutError as _:
logging.warning('Timeout waiting for the result line')
return None
def _FlattenParsedConfig(config: dict):
expanded_keys = ['args', 'url', 'enable_chrometto_tracing']
list_keys = [k for k in expanded_keys if isinstance(config[k], list)]
if len(list_keys) == 0:
return [config]
list_length = len(config[list_keys[0]])
assert all(len(config[k]) == list_length for k in list_keys)
result = []
for i in range(list_length):
result.append(copy.deepcopy(config))
for key in list_keys:
result[-1][key] = result[-1][key][i]
return result
def _FlagsForConfig(config):
fieldtrial_args = fieldtrial_util.GenerateArgs(_FIELDTRIAL_TESTING_CONFIG,
'android')
flags = fieldtrial_args + [
'--disable-fre',
'--watch-dir-for-scroll-jank-report={}'.format(_NOTIFY_FILE_ON_DEVICE)
]
config_args = config['args']
if 'named_args' in config and config_args in config['named_args']:
config_args = config['named_args'][config_args]
flags.extend(config_args.split())
if config.get('enable_chrometto_tracing', False):
flags.append('--enable-features=EnablePerfettoSystemTracing')
return flags
def _Run(device: device_utils.DeviceUtils, parsed_config, output_dir_name,
runs_to_perform):
configs = _FlattenParsedConfig(parsed_config)
# On the device create the directory for Chrome to watch.
_PrepareNotification(device)
csv_file = os.path.join(output_dir_name, 'result.csv')
logcat_dir = os.path.join(output_dir_name, 'logcats')
os.makedirs(logcat_dir, exist_ok=True)
runs_done = 0
while runs_done != runs_to_perform:
config = configs[random.randrange(len(configs))]
logcat_file = os.path.join(logcat_dir,
'{:04d}_logcat.txt'.format(runs_done))
package_info = constants.PACKAGE_INFO['chrome']
with flag_changer.CustomCommandLineFlags(device, package_info.cmdline_file,
_FlagsForConfig(config)):
url = config['url']
if 'named_urls' in config and url in config['named_urls']:
url = config['named_urls'][url]
logcat_monitor = device.GetLogcatMonitor(clear=True,
output_file=logcat_file)
logcat_monitor.Start()
try:
device.StartActivity(intent.Intent(package=package_info.package,
activity=package_info.activity,
data=url),
blocking=True,
force_stop=True)
# Wait some for the webpage to load.
time.sleep(7)
enable_tracing = config.get('enable_chrometto_tracing', False)
if not enable_tracing:
_ReplayTouchEvents(device)
else:
trace_dir = os.path.join(output_dir_name, 'traces',
'{:04d}_trace'.format(runs_done))
_ReplayTouchEventsWithCpuProfile(device, trace_dir, config)
# Save the measurements.
jank_csv = _ExtractJankCsv(logcat_monitor)
if jank_csv:
with open(csv_file, 'a') as writer:
writer.write(f'{runs_done},{url},{enable_tracing},{jank_csv}\n')
finally:
logcat_monitor.Stop()
logcat_monitor.Close()
logging.info('Logcat saved at: {}'.format(logcat_file))
runs_done += 1
def _EnsureOutputDirectoryIsSet(d: str):
if os.path.exists(d):
if not os.path.isdir(d):
raise Exception('Exists and not a directory: {}'.format(d))
elif len(os.listdir(d)) != 0:
raise Exception('Output directory exists and not empty: {}'.format(d))
os.makedirs(d, exist_ok=True)
def main():
logging.basicConfig(level=logging.INFO)
parser = _CreateParser()
args = parser.parse_args()
_EnsureOutputDirectoryIsSet(args.output)
if not os.path.isfile(args.config):
raise Exception('Could not find config file')
with open(args.config) as f:
parsed_config = pyyaml.safe_load(f)
print(json.dumps(parsed_config, indent=4))
devil_chromium.Initialize()
device = _GetPreferredDevice(args.device)
if not device:
raise Exception('Device not found or not healthy')
logging.info('Using device: {}'.format(device.serial))
device.PushChangedFiles(
[(os.path.abspath(args.events), _EVENTS_FILE_ON_DEVICE),
(os.path.abspath(args.replayer), _REPLAY_EXECUTABLE_ON_DEVICE)],
delete_device_stale=True)
if not device.FileExists(_REPLAY_EXECUTABLE_ON_DEVICE):
raise Exception('Executable not found on device: {}'.format(
_REPLAY_EXECUTABLE_ON_DEVICE))
if args.n == 0:
raise Exception('Requested 0 runs')
_Run(device, parsed_config, args.output, args.n)
if __name__ == '__main__':
main()