Scripts for getting data from app startup

Change-Id: I7433b2dbf6cc18625fa600df17bfeffe817952db
diff --git a/.gitignore b/.gitignore
index 7ad6f32..df1b68c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -273,4 +273,5 @@
 tools/*/dalvik.tar.gz
 tools/*/dx
 tools/*/dx.tar.gz
+tools/startup/__pycache__
 venv/
diff --git a/tools/startup/adb_utils.py b/tools/startup/adb_utils.py
new file mode 100644
index 0000000..cbcd724
--- /dev/null
+++ b/tools/startup/adb_utils.py
@@ -0,0 +1,161 @@
+#!/usr/bin/env python3
+# Copyright (c) 2022, the R8 project authors. Please see the AUTHORS file
+# for details. All rights reserved. Use of this source code is governed by a
+# BSD-style license that can be found in the LICENSE file.
+
+from enum import Enum
+import subprocess
+import time
+
+DEVNULL=subprocess.DEVNULL
+
+class ScreenState(Enum):
+  OFF_LOCKED = 1,
+  OFF_UNLOCKED = 2
+  ON_LOCKED = 3
+  ON_UNLOCKED = 4
+
+  def is_off(self):
+    return self == ScreenState.OFF_LOCKED or self == ScreenState.OFF_UNLOCKED
+
+  def is_on(self):
+    return self == ScreenState.ON_LOCKED or self == ScreenState.ON_UNLOCKED
+
+  def is_on_and_locked(self):
+    return self == ScreenState.ON_LOCKED
+
+  def is_on_and_unlocked(self):
+    return self == ScreenState.ON_UNLOCKED
+
+def create_adb_cmd(arguments, device_id=None):
+  assert isinstance(arguments, list) or isinstance(arguments, str)
+  cmd = ['adb']
+  if device_id is not None:
+    cmd.append('-s')
+    cmd.append(device_id)
+  cmd.extend(arguments if isinstance(arguments, list) else arguments.split(' '))
+  return cmd
+
+def clear_profile_data(app_id, device_id=None):
+  cmd = create_adb_cmd(
+      'shell cmd package compile --reset %s' % app_id, device_id)
+  subprocess.check_call(cmd, stdout=DEVNULL, stderr=DEVNULL)
+
+def drop_caches(device_id=None):
+  cmd = create_adb_cmd(
+      ['shell', 'echo 3 > /proc/sys/vm/drop_caches'], device_id)
+  subprocess.check_call(cmd, stdout=DEVNULL, stderr=DEVNULL)
+
+def force_compilation(app_id, device_id=None):
+  cmd = create_adb_cmd(
+      'shell cmd package compile -m speed -f %s' % app_id, device_id)
+  subprocess.check_call(cmd, stdout=DEVNULL, stderr=DEVNULL)
+
+def force_profile_compilation(app_id, device_id=None):
+  cmd = create_adb_cmd(
+      'shell cmd package compile -m speed-profile -f %s' % app_id, device_id)
+  subprocess.check_call(cmd, stdout=DEVNULL, stderr=DEVNULL)
+
+def get_minor_major_page_faults(app_id, device_id=None):
+  pid = get_pid(app_id, device_id)
+  cmd = create_adb_cmd('shell ps -p %i -o MINFL,MAJFL' % pid)
+  stdout = subprocess.check_output(cmd).decode('utf-8')
+  lines_it = iter(stdout.splitlines())
+  first_line = next(lines_it)
+  assert first_line == ' MINFL  MAJFL'
+  second_line = next(lines_it)
+  minfl, majfl = second_line.split()
+  assert minfl.isdigit()
+  assert majfl.isdigit()
+  return (int(minfl), int(majfl))
+
+def get_pid(app_id, device_id=None):
+  cmd = create_adb_cmd('shell pidof %s' % app_id, device_id)
+  stdout = subprocess.check_output(cmd).decode('utf-8').strip()
+  assert stdout.isdigit()
+  pid = int(stdout)
+  return pid
+
+def get_screen_state(device_id=None):
+  cmd = create_adb_cmd('shell dumpsys nfc', device_id)
+  stdout = subprocess.check_output(cmd).decode('utf-8').strip()
+  screen_state_value = None
+  for line in stdout.splitlines():
+    if line.startswith('mScreenState='):
+      value_start_index = len('mScreenState=')
+      screen_state_value=line[value_start_index:]
+  if screen_state_value is None:
+    raise ValueError('Expected to find mScreenState in: adb shell dumpsys nfc')
+  if not hasattr(ScreenState, screen_state_value):
+    raise ValueError(
+        'Expected mScreenState to be a value of ScreenState, was: %s'
+            % screen_state_value)
+  return ScreenState[screen_state_value]
+
+def get_screen_off_timeout(device_id=None):
+  cmd = create_adb_cmd(
+      'shell settings get system screen_off_timeout', device_id)
+  stdout = subprocess.check_output(cmd).decode('utf-8').strip()
+  assert stdout.isdigit()
+  screen_off_timeout = int(stdout)
+  return screen_off_timeout
+
+def install(apk, device_id=None):
+  cmd = create_adb_cmd('install %s' % apk, device_id)
+  stdout = subprocess.check_output(cmd).decode('utf-8')
+  assert 'Success' in stdout
+
+def issue_key_event(key_event, device_id=None, sleep_in_seconds=1):
+  cmd = create_adb_cmd('shell input keyevent %s' % key_event, device_id)
+  stdout = subprocess.check_output(cmd).decode('utf-8').strip()
+  assert len(stdout) == 0
+  time.sleep(sleep_in_seconds)
+
+def launch_activity(app_id, activity, device_id=None):
+  cmd = create_adb_cmd(
+    'shell am start -n %s/%s' % (app_id, activity), device_id)
+  stdout = subprocess.check_output(cmd).decode('utf-8').strip()
+  expected_stdout = (
+      'Starting: Intent { cmp=%s/.%s }' % (app_id, activity[len(app_id)+1:]))
+  assert stdout == expected_stdout, 'was %s' % stdout
+
+def root(device_id=None):
+  cmd = create_adb_cmd('root', device_id)
+  subprocess.check_call(cmd, stdout=DEVNULL, stderr=DEVNULL)
+
+def set_screen_off_timeout(screen_off_timeout_in_millis, device_id=None):
+  cmd = create_adb_cmd(
+      'shell settings put system screen_off_timeout %i'
+          % screen_off_timeout_in_millis,
+      device_id)
+  stdout = subprocess.check_output(cmd).decode('utf-8').strip()
+  assert len(stdout) == 0
+
+def stop_app(app_id, device_id=None):
+  cmd = create_adb_cmd('shell am force-stop %s' % apk, device_id)
+  subprocess.check_call(cmd, stdout=DEVNULL, stderr=DEVNULL)
+
+def uninstall(app_id, device_id=None):
+  cmd = create_adb_cmd('uninstall %s' % app_id, device_id)
+  process_result = subprocess.run(cmd, capture_output=True)
+  stdout = process_result.stdout.decode('utf-8')
+  stderr = process_result.stderr.decode('utf-8')
+  if process_result.returncode == 0:
+    assert 'Success' in stdout
+  else:
+    expected_error = (
+        'java.lang.IllegalArgumentException: Unknown package: %s' % app_id)
+    assert expected_error in stderr
+
+def unlock(device_id=None, device_pin=None):
+  screen_state = get_screen_state(device_id)
+  if screen_state.is_off():
+    issue_key_event('KEYCODE_POWER', device_id)
+    screen_state = get_screen_state(device_id)
+  assert screen_state.is_on(), 'was %s' % screen_state
+  if screen_state.is_on_and_locked():
+    if device_pin is not None:
+      raise NotImplementedError('Device unlocking with pin not implemented')
+    issue_key_event('KEYCODE_MENU', device_id)
+    screen_state = get_screen_state(device_id)
+  assert screen_state.is_on_and_unlocked(), 'was %s' % screen_state
diff --git a/tools/startup/config.pbtx b/tools/startup/config.pbtx
new file mode 100644
index 0000000..bc34ac5
--- /dev/null
+++ b/tools/startup/config.pbtx
@@ -0,0 +1,38 @@
+buffers: {
+    size_kb: 63488
+    fill_policy: DISCARD
+}
+buffers: {
+    size_kb: 2048
+    fill_policy: DISCARD
+}
+data_sources: {
+    config {
+        name: "linux.ftrace"
+        ftrace_config {
+            # ftrace_events: "ftrace/print"
+            # ftrace_events: "mm_event"
+            # ftrace_events: "power/suspend_resume"
+            # ftrace_events: "sched/sched_switch"
+            # ftrace_events: "sched/sched_process_exit"
+            # ftrace_events: "sched/sched_process_free"
+
+            ftrace_events: "task/task_newtask"
+            ftrace_events: "task/task_rename"
+
+            # atrace_categories: "gfx"
+            # atrace_categories: "webview"
+            # atrace_categories: "camera"
+            # atrace_categories: "power"
+
+            atrace_categories: "dalvik"
+            atrace_categories: "view"
+            atrace_categories: "am"
+            atrace_categories: "wm"
+
+            # Enables events for a specific app.
+            atrace_apps: "app.tivi"
+        }
+    }
+}
+duration_ms: 15000
diff --git a/tools/startup/perfetto_utils.py b/tools/startup/perfetto_utils.py
new file mode 100644
index 0000000..d85f53e
--- /dev/null
+++ b/tools/startup/perfetto_utils.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+# Copyright (c) 2022, the R8 project authors. Please see the AUTHORS file
+# for details. All rights reserved. Use of this source code is governed by a
+# BSD-style license that can be found in the LICENSE file.
+
+import os
+import subprocess
+import sys
+
+try:
+  from perfetto.trace_processor import TraceProcessor
+except ImportError:
+  sys.exit(
+      'Unable to analyze perfetto trace without the perfetto library. '
+      'Install instructions:\n'
+      '    sudo apt install python3-pip\n'
+      '    pip3 install perfetto')
+
+def ensure_record_android_trace(tmp_dir):
+  record_android_trace_path = os.path.join(tmp_dir, 'record_android_trace')
+  if not os.path.exists(record_android_trace_path):
+    cmd = [
+        'curl',
+        '--output',
+        record_android_trace_path,
+        '--silent',
+        'https://raw.githubusercontent.com/google/perfetto/master/tools/'
+            'record_android_trace'
+    ]
+    subprocess.check_call(cmd)
+    assert os.path.exists(record_android_trace_path)
+  return record_android_trace_path
+
+def record_android_trace(out_dir, tmp_dir):
+  record_android_trace_path = ensure_record_android_trace(tmp_dir)
+  config_path = os.path.join(os.path.dirname(__file__), 'config.pbtx')
+  perfetto_trace_path = os.path.join(out_dir, 'trace.perfetto-trace')
+  cmd = [
+      sys.executable,
+      record_android_trace_path,
+      '--config',
+      config_path,
+      '--out',
+      perfetto_trace_path,
+      '--no-open']
+  perfetto_process = subprocess.Popen(
+      cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+  lines = []
+  for line in perfetto_process.stdout:
+    line = line.decode('utf-8')
+    lines.append(line)
+    if 'enabled ftrace' in line.strip():
+      return perfetto_process, perfetto_trace_path
+  raise ValueError(
+      'Expected to find line containing: enabled ftrace, got: %s' % lines)
+
+def stop_record_android_trace(perfetto_process, out_dir):
+  if perfetto_process.poll() is not None:
+    raise ValueError('Expected perfetto process to be running')
+  # perfetto should terminate in at most 15 seconds, 
+  perfetto_config_duration=15
+  stdout, stderr = perfetto_process.communicate(
+      timeout=perfetto_config_duration*2)
+  stdout = stdout.decode('utf-8')
+  stderr = stderr.decode('utf-8')
+  assert perfetto_process.returncode == 0
+  assert os.path.exists(os.path.join(out_dir, 'trace.perfetto-trace'))
+
+# https://perfetto.dev/docs/analysis/sql-tables
+def find_slices_by_name(slice_name, options, trace_processor):
+  return trace_processor.query(
+      'SELECT slice.dur, slice.ts FROM slice'
+          ' INNER JOIN thread_track ON (slice.track_id = thread_track.id)'
+          ' INNER JOIN thread using (utid)'
+          ' INNER JOIN process using (upid)'
+          ' WHERE slice.name = "%s"'
+          ' AND process.name = "%s"'
+          ' ORDER BY slice.ts ASC'
+          % (slice_name, options.app_id))
+
+def find_unique_slice_by_name(slice_name, options, trace_processor):
+  query_it = find_slices_by_name(slice_name, options, trace_processor)
+  assert len(query_it) == 1
+  return next(query_it)
+
+def get_slice_end_since_start(slice, initial_slice):
+  return (slice.ts + slice.dur - initial_slice.ts) / 1000000
diff --git a/tools/startup/trace_generator.py b/tools/startup/trace_generator.py
new file mode 100755
index 0000000..0b36b64
--- /dev/null
+++ b/tools/startup/trace_generator.py
@@ -0,0 +1,211 @@
+#!/usr/bin/env python3
+# Copyright (c) 2022, the R8 project authors. Please see the AUTHORS file
+# for details. All rights reserved. Use of this source code is governed by a
+# BSD-style license that can be found in the LICENSE file.
+
+import argparse
+import os
+import sys
+import time
+
+try:
+  from perfetto.trace_processor import TraceProcessor
+except ImportError:
+  sys.exit(
+      'Unable to analyze perfetto trace without the perfetto library. '
+      'Install instructions:\n'
+      '    sudo apt install python3-pip\n'
+      '    pip3 install perfetto')
+
+sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import adb_utils
+import perfetto_utils
+import utils
+
+def setup(options):
+  # Increase screen off timeout to avoid device screen turns off.
+  twenty_four_hours_in_millis = 24 * 60 * 60 * 1000
+  previous_screen_off_timeout = adb_utils.get_screen_off_timeout(
+      options.device_id)
+  adb_utils.set_screen_off_timeout(
+      twenty_four_hours_in_millis, options.device_id)
+
+  # Unlock device.
+  adb_utils.unlock(options.device_id, options.device_pin)
+
+  tear_down_options = {
+    'previous_screen_off_timeout': previous_screen_off_timeout
+  }
+  return tear_down_options
+
+def tear_down(options, tear_down_options):
+  # Reset screen off timeout.
+  adb_utils.set_screen_off_timeout(
+      tear_down_options['previous_screen_off_timeout'],
+      options.device_id)
+
+def run_all(options, tmp_dir):
+  # Launch app while collecting information.
+  data_avg = {}
+  for iteration in range(options.iterations):
+    print('Starting iteration %i' % iteration)
+    out_dir = os.path.join(options.out_dir, str(iteration))
+    prepare_for_run(out_dir, options)
+    data = run(out_dir, options, tmp_dir)
+    add_data(data_avg, data)
+    print("Result:")
+    print(data)
+    print("Done")
+  for key, value in data_avg.items():
+    if isinstance(value, int):
+      data_avg[key] = value / options.iterations
+  print("Average result:")
+  print(data_avg)
+  write_data(options.out_dir, data_avg)
+
+def prepare_for_run(out_dir, options):
+  adb_utils.root(options.device_id)
+  adb_utils.uninstall(options.app_id, options.device_id)
+  adb_utils.install(options.apk, options.device_id)
+  adb_utils.clear_profile_data(options.app_id, options.device_id)
+  if options.aot:
+    adb_utils.force_compilation(options.app_id, options.device_id)
+  elif options.aot_profile:
+    adb_utils.launch_activity(
+        options.app_id, options.main_activity, options.device_id)
+    time.sleep(options.aot_profile_sleep)
+    adb_utils.stop_app(options.app_id, options.device_id)
+    adb_utils.force_profile_compilation(options.app_id, options.device_id)
+
+  adb_utils.drop_caches(options.device_id)
+  os.makedirs(out_dir, exist_ok=True)
+
+def run(out_dir, options, tmp_dir):
+  assert adb_utils.get_screen_state().is_on_and_unlocked()
+
+  # Start perfetto trace collector.
+  perfetto_process, perfetto_trace_path = perfetto_utils.record_android_trace(
+      out_dir, tmp_dir)
+
+  # Launch main activity.
+  adb_utils.launch_activity(
+      options.app_id, options.main_activity, options.device_id)
+
+  # Wait for perfetto trace collector to stop.
+  perfetto_utils.stop_record_android_trace(perfetto_process, out_dir)
+
+  # Get minor and major page faults from app process.
+  data = compute_data(perfetto_trace_path, options)
+  write_data(out_dir, data)
+  return data
+
+def add_data(sum_data, data):
+  for key, value in data.items():
+    if key == 'time':
+      continue
+    if hasattr(sum_data, key):
+      if key == 'app_id':
+        assert sum_data[key] == value
+      else:
+        existing_value = sum_data[key]
+        assert isinstance(value, int)
+        assert isinstance(existing_value, int)
+        sum_data[key] = existing_value + value
+    else:
+      sum_data[key] = value
+
+def compute_data(perfetto_trace_path, options):
+  minfl, majfl = adb_utils.get_minor_major_page_faults(
+      options.app_id, options.device_id)
+  data = {
+    'app_id': options.app_id,
+    'time': time.ctime(time.time()),
+    'minfl': minfl,
+    'majfl': majfl
+  }
+  startup_data = compute_startup_data(perfetto_trace_path, options)
+  return data | startup_data
+
+def compute_startup_data(perfetto_trace_path, options):
+  trace_processor = TraceProcessor(file_path=perfetto_trace_path)
+
+  # Compute time to first frame according to the builtin android_startup metric.
+  startup_metric = trace_processor.metric(['android_startup'])
+  time_to_first_frame_ms = \
+      startup_metric.android_startup.startup[0].to_first_frame.dur_ms
+
+  # Compute time to first and last doFrame event.
+  bind_application_slice = perfetto_utils.find_unique_slice_by_name(
+      'bindApplication', options, trace_processor)
+  activity_start_slice = perfetto_utils.find_unique_slice_by_name(
+      'activityStart', options, trace_processor)
+  do_frame_slices = perfetto_utils.find_slices_by_name(
+      'Choreographer#doFrame', options, trace_processor)
+  first_do_frame_slice = next(do_frame_slices)
+  *_, last_do_frame_slice = do_frame_slices
+
+  return {
+    'time_to_first_frame_ms': time_to_first_frame_ms,
+    'time_to_first_choreographer_do_frame_ms':
+        perfetto_utils.get_slice_end_since_start(
+            first_do_frame_slice, bind_application_slice),
+    'time_to_last_choreographer_do_frame_ms':
+        perfetto_utils.get_slice_end_since_start(
+            last_do_frame_slice, bind_application_slice)
+  }
+
+def write_data(out_dir, data):
+  data_path = os.path.join(out_dir, 'data.txt')
+  with open(data_path, 'w') as f:
+    for key, value in data.items():
+      f.write('%s=%s\n' % (key, str(value)))
+
+def parse_options(argv):
+  result = argparse.ArgumentParser(
+      description='Generate a perfetto trace file.')
+  result.add_argument('--app-id',
+                      help='The application ID of interest',
+                      required=True)
+  result.add_argument('--aot',
+                      help='Enable force compilation',
+                      default=False,
+                      action='store_true')
+  result.add_argument('--aot-profile',
+                      help='Enable force compilation using profiles',
+                      default=False,
+                      action='store_true')
+  result.add_argument('--aot-profile-sleep',
+                      help='Duration in seconds before forcing compilation',
+                      default=15,
+                      type=int)
+  result.add_argument('--apk',
+                      help='Path to the APK',
+                      required=True)
+  result.add_argument('--device-id',
+                      help='Device id (e.g., emulator-5554).')
+  result.add_argument('--device-pin',
+                      help='Device pin code (e.g., 1234)')
+  result.add_argument('--iterations',
+                      help='Number of traces to generate',
+                      default=1,
+                      type=int)
+  result.add_argument('--main-activity',
+                      help='Main activity class name',
+                      required=True)
+  result.add_argument('--out-dir',
+                      help='Directory to store trace files in',
+                      required=True)
+  options, args = result.parse_known_args(argv)
+  assert (not options.aot) or (not options.aot_profile)
+  return options, args
+
+def main(argv):
+  (options, args) = parse_options(argv)
+  with utils.TempDir() as tmp_dir:
+    tear_down_options = setup(options)
+    run_all(options, tmp_dir)
+    tear_down(options, tear_down_options)
+
+if __name__ == '__main__':
+  sys.exit(main(sys.argv[1:]))
\ No newline at end of file