Add run_on_as_app_dump.py for running dumps for apps

Bug: 152155164
Bug: 171955352
Change-Id: I816909e2d38876ff822958c6e0ac3eca4321e2c1
diff --git a/tools/compiledump.py b/tools/compiledump.py
index fa9518c..6d7359b 100755
--- a/tools/compiledump.py
+++ b/tools/compiledump.py
@@ -61,6 +61,11 @@
     default=False,
     action='store_true')
   parser.add_argument(
+    '--classfile',
+    help='Run with classfile output',
+    default=False,
+    action='store_true')
+  parser.add_argument(
       '--debug-agent',
       help='Enable Java debug agent and suspend compilation (default disabled)',
       default=False,
@@ -189,6 +194,18 @@
 def determine_feature_output(feature_jar, temp):
   return os.path.join(temp, os.path.basename(feature_jar)[:-4] + ".out.jar")
 
+def determine_program_jar(args, dump):
+  if hasattr(args, 'program_jar') and args.program_jar:
+    return args.program_jar
+  return dump.program_jar()
+
+def determine_class_file(args, build_properties):
+  if args.classfile:
+    return args.classfile
+  if 'classfile' in build_properties:
+    return True
+  return None
+
 def download_distribution(args, version, temp):
   if version == 'master':
     return utils.R8_JAR if args.nolib else utils.R8LIB_JAR
@@ -229,6 +246,7 @@
     compiler = determine_compiler(args, dump)
     out = determine_output(args, temp)
     min_api = determine_min_api(args, build_properties)
+    classfile = determine_class_file(args, build_properties)
     jar = args.r8_jar if args.r8_jar else download_distribution(args, version, temp)
     wrapper_dir = prepare_wrapper(jar, temp)
     cmd = [jdk.GetJavaExecutable()]
@@ -250,7 +268,8 @@
       cmd.append('com.android.tools.r8.utils.CompileDumpCompatR8')
     if compiler == 'r8':
       cmd.append('--compat')
-    cmd.append(dump.program_jar())
+    # For recompilation of dumps run_on_app_dumps pass in a program jar.
+    cmd.append(determine_program_jar(args, dump))
     cmd.extend(['--output', out])
     for feature_jar in dump.feature_jars():
       cmd.extend(['--feature-jar', feature_jar,
@@ -267,6 +286,8 @@
       cmd.extend(['--pg-map-output', '%s.map' % out])
     if min_api:
       cmd.extend(['--min-api', min_api])
+    if classfile:
+      cmd.extend(['--classfile'])
     if args.threads:
       cmd.extend(['--threads', args.threads])
     cmd.extend(otherargs)
diff --git a/tools/run_on_app_dump.py b/tools/run_on_app_dump.py
new file mode 100755
index 0000000..04ca397
--- /dev/null
+++ b/tools/run_on_app_dump.py
@@ -0,0 +1,484 @@
+#!/usr/bin/env python
+# Copyright (c) 2020, 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 compiledump
+import gradle
+import jdk
+import optparse
+import os
+import shutil
+import sys
+import time
+import utils
+import zipfile
+
+from datetime import datetime
+
+SHRINKERS = ['r8', 'r8-full', 'r8-nolib', 'r8-nolib-full']
+
+class AttrDict(dict):
+  def __getattr__(self, name):
+    return self.get(name, None)
+
+
+# To generate the files for a new app, navigate to the app source folder and
+# run:
+# ./gradlew clean :app:assembleRelease -Dcom.android.tools.r8.dumpinputtodirectory=<path>
+# and store the dump and the apk.
+# If the app has instrumented tests, adding `testBuildType "release"` and
+# running:
+# ./gradlew assembleAndroidTest -Dcom.android.tools.r8.dumpinputtodirectory=<path>
+# will also generate dumps and apk for tests.
+
+class App(object):
+  def __init__(self, fields):
+    defaults = {
+      'id': None,
+      'name': None,
+      'dump_app': None,
+      'skip': False,
+      'url': None,  # url is not used but nice to have for updating apps
+      'revision': None,
+      'folder': None,
+      'skip_recompilation': False,
+    }
+    # This below does not work in python3
+    defaults.update(fields.items())
+    self.__dict__ = defaults
+
+
+APPS = [
+  App({
+    'id': 'com.example.applymapping',
+    'name': 'applymapping',
+    'dump_app': 'dump_app.zip',
+    'url': 'https://github.com/mkj-gram/applymapping',
+    'revision': 'e3ae14b8c16fa4718e5dea8f7ad00937701b3c48',
+    'folder': 'applymapping',
+    'skip_recompilation': True,
+  })
+]
+
+
+def download_app(app_sha):
+  utils.DownloadFromGoogleCloudStorage(app_sha)
+
+
+def is_minified_r8(shrinker):
+  return '-nolib' not in shrinker
+
+
+def is_full_r8(shrinker):
+  return '-full' not in shrinker
+
+
+def compute_size_of_dex_files_in_package(path):
+  dex_size = 0
+  z = zipfile.ZipFile(path, 'r')
+  for filename in z.namelist():
+    if filename.endswith('.dex'):
+      dex_size += z.getinfo(filename).file_size
+  return dex_size
+
+
+def dump_for_app(app_dir, app):
+  return os.path.join(app_dir, app.dump_app)
+
+
+def get_results_for_app(app, options, temp_dir):
+  app_folder = app.folder if app.folder else app.name + "_" + app.revision
+  app_dir = os.path.join(utils.OPENSOURCE_DUMPS_DIR, app_folder)
+
+  if not os.path.exists(app_dir) and not options.golem:
+    # Download the app from google storage.
+    download_app(app_dir + ".tar.gz.sha1")
+
+  # Ensure that the dumps are in place
+  assert os.path.isfile(dump_for_app(app_dir, app)), "Could not find dump " \
+                                                     "for app " + app.name
+
+  result = {}
+  result['status'] = 'success'
+  result_per_shrinker = build_app_with_shrinkers(
+    app, options, temp_dir, app_dir)
+  for shrinker, shrinker_result in result_per_shrinker.iteritems():
+    result[shrinker] = shrinker_result
+  return result
+
+
+def build_app_with_shrinkers(app, options, temp_dir, app_dir):
+  result_per_shrinker = {}
+  for shrinker in options.shrinker:
+    results = []
+    build_app_and_run_with_shrinker(
+      app, options, temp_dir, app_dir, shrinker, results)
+    result_per_shrinker[shrinker] = results
+  if len(options.apps) > 1:
+    print('')
+    log_results_for_app(app, result_per_shrinker, options)
+    print('')
+
+  return result_per_shrinker
+
+
+def is_last_build(index, compilation_steps):
+  return index == compilation_steps - 1
+
+
+def build_app_and_run_with_shrinker(app, options, temp_dir, app_dir, shrinker,
+                                    results):
+  print('[{}] Building {} with {}'.format(
+    datetime.now().strftime("%H:%M:%S"),
+    app.name,
+    shrinker))
+  print('To compile locally: '
+        'tools/run_on_as_app.py --shrinker {} --r8-compilation-steps {} '
+        '--app {}'.format(
+    shrinker,
+    options.r8_compilation_steps,
+    app.name))
+  print('HINT: use --shrinker r8-nolib --no-build if you have a local R8.jar')
+  recomp_jar = None
+  status = 'success'
+  compilation_steps = 1 if app.skip_recompilation else options.r8_compilation_steps;
+  for compilation_step in range(0, compilation_steps):
+    if status != 'success':
+      break
+    print('Compiling {} of {}'.format(compilation_step, compilation_steps))
+    result = {}
+    try:
+      start = time.time()
+      (app_jar, new_recomp_jar) = \
+        build_app_with_shrinker(
+          app, options, temp_dir, app_dir, shrinker, compilation_step,
+          compilation_steps, recomp_jar)
+      end = time.time()
+      dex_size = compute_size_of_dex_files_in_package(app_jar)
+      result['build_status'] = 'success'
+      result['recompilation_status'] = 'success'
+      result['output_jar'] = app_jar
+      result['dex_size'] = dex_size
+      result['duration'] = int((end - start) * 1000)  # Wall time
+      if (new_recomp_jar is None
+          and not is_last_build(compilation_step, compilation_steps)):
+        result['recompilation_status'] = 'failed'
+        warn('Failed to build {} with {}'.format(app.name, shrinker))
+        results.append(result)
+        break
+      recomp_jar = new_recomp_jar
+    except Exception as e:
+      warn('Failed to build {} with {}'.format(app.name, shrinker))
+      if e:
+        print('Error: ' + str(e))
+      result['build_status'] = 'failed'
+      status = 'failed'
+
+    results.append(result)
+
+
+def build_app_with_shrinker(app, options, temp_dir, app_dir, shrinker,
+                            compilation_step_index, compilation_steps,
+                            prev_recomp_jar):
+  r8jar = os.path.join(
+    temp_dir, 'r8lib.jar' if is_minified_r8(shrinker) else 'r8.jar')
+
+  args = AttrDict({
+    'dump': dump_for_app(app_dir, app),
+    'r8_jar': r8jar,
+    'ea': False if options.disable_assertions else True,
+    'version': 'master',
+    'compiler': 'r8full' if is_full_r8(shrinker) else 'r8',
+    'debug_agent': options.debug_agent,
+    'program_jar': prev_recomp_jar,
+    'nolib': not is_minified_r8(shrinker)
+  })
+
+  out_jar = os.path.join(temp_dir, "out.jar")
+  compile_result = compiledump.run1(temp_dir, args, [])
+  app_jar = os.path.join(
+    temp_dir, '{}_{}_{}_dex_out.jar'.format(
+      app.name, shrinker, compilation_step_index))
+
+  if compile_result != 0 or not os.path.isfile(out_jar):
+    assert False, "Compilation of app_jar failed"
+  shutil.move(out_jar, app_jar)
+
+  recomp_jar = None
+  if compilation_step_index < compilation_steps - 1:
+    args['classfile'] = True
+    args['min_api'] = "10000"
+    compile_result = compiledump.run1(temp_dir, args, [])
+    if compile_result == 0:
+      recomp_jar = os.path.join(
+        temp_dir, '{}_{}_{}_cf_out.jar'.format(
+          app.name, shrinker, compilation_step_index))
+      shutil.move(out_jar, recomp_jar)
+
+  return (app_jar, recomp_jar)
+
+
+def log_results_for_apps(result_per_shrinker_per_app, options):
+  print('')
+  app_errors = 0
+  for (app, result_per_shrinker) in result_per_shrinker_per_app:
+    app_errors += (1 if log_results_for_app(app, result_per_shrinker, options)
+                   else 0)
+  return app_errors
+
+
+def log_results_for_app(app, result_per_shrinker, options):
+  if options.print_dexsegments:
+    log_segments_for_app(app, result_per_shrinker, options)
+    return False
+  else:
+    return log_comparison_results_for_app(app, result_per_shrinker, options)
+
+
+def log_segments_for_app(app, result_per_shrinker, options):
+  for shrinker in SHRINKERS:
+    if shrinker not in result_per_shrinker:
+      continue
+    for result in result_per_shrinker.get(shrinker):
+      benchmark_name = '{}-{}'.format(options.print_dexsegments, app.name)
+      utils.print_dexsegments(benchmark_name, [result.get('output_jar')])
+      duration = result.get('duration')
+      print('%s-Total(RunTimeRaw): %s ms' % (benchmark_name, duration))
+      print('%s-Total(CodeSize): %s' % (benchmark_name, result.get('dex_size')))
+
+
+def percentage_diff_as_string(before, after):
+  if after < before:
+    return '-' + str(round((1.0 - after / before) * 100)) + '%'
+  else:
+    return '+' + str(round((after - before) / before * 100)) + '%'
+
+
+def log_comparison_results_for_app(app, result_per_shrinker, options):
+  print(app.name + ':')
+  app_error = False
+  if result_per_shrinker.get('status', 'success') != 'success':
+    error_message = result_per_shrinker.get('error_message')
+    print('  skipped ({})'.format(error_message))
+    return
+
+  proguard_result = result_per_shrinker.get('pg', {})
+  proguard_dex_size = float(proguard_result.get('dex_size', -1))
+
+  for shrinker in SHRINKERS:
+    if shrinker not in result_per_shrinker:
+      continue
+    compilation_index = 1
+    for result in result_per_shrinker.get(shrinker):
+      build_status = result.get('build_status')
+      if build_status != 'success' and build_status is not None:
+        app_error = True
+        warn('  {}-#{}: {}'.format(shrinker, compilation_index, build_status))
+        continue
+
+      print('  {}-#{}:'.format(shrinker, compilation_index))
+      dex_size = result.get('dex_size')
+      msg = '    dex size: {}'.format(dex_size)
+      if dex_size != proguard_dex_size and proguard_dex_size >= 0:
+        msg = '{} ({}, {})'.format(
+          msg, dex_size - proguard_dex_size,
+          percentage_diff_as_string(proguard_dex_size, dex_size))
+        success(msg) if dex_size < proguard_dex_size else warn(msg)
+      else:
+        print(msg)
+
+      recompilation_status = result.get('recompilation_status', '')
+      if recompilation_status == 'failed':
+        app_error = True
+        warn('    recompilation {}-#{}: failed'.format(shrinker,
+                                                       compilation_index))
+        continue
+
+      compilation_index += 1
+
+  return app_error
+
+
+def parse_options(argv):
+  result = optparse.OptionParser()
+  result.add_option('--app',
+                    help='What app to run on',
+                    choices=[app.name for app in APPS],
+                    action='append')
+  result.add_option('--bot',
+                    help='Running on bot, use third_party dependency.',
+                    default=False,
+                    action='store_true')
+  result.add_option('--debug-agent',
+                    help='Enable Java debug agent and suspend compilation '
+                         '(default disabled)',
+                    default=False,
+                    action='store_true')
+  result.add_option('--disable-assertions', '--disable_assertions',
+                    help='Disable assertions when compiling',
+                    default=False,
+                    action='store_true')
+  result.add_option('--golem',
+                    help='Running on golem, do not download',
+                    default=False,
+                    action='store_true')
+  result.add_option('--hash',
+                    help='The commit of R8 to use')
+  result.add_option('--keystore',
+                    help='Path to app.keystore',
+                    default=os.path.join(utils.TOOLS_DIR, 'debug.keystore'))
+  result.add_option('--keystore-password', '--keystore_password',
+                    help='Password for app.keystore',
+                    default='android')
+  result.add_option('--app-logging-filter', '--app_logging_filter',
+                    help='The apps for which to turn on logging',
+                    action='append')
+  result.add_option('--monkey',
+                    help='Whether to install and run app(s) with monkey',
+                    default=False,
+                    action='store_true')
+  result.add_option('--monkey-events', '--monkey_events',
+                    help='Number of events that the monkey should trigger',
+                    default=250,
+                    type=int)
+  result.add_option('--no-build', '--no_build',
+                    help='Run without building ToT first (only when using ToT)',
+                    default=False,
+                    action='store_true')
+  result.add_option('--no-logging', '--no_logging',
+                    help='Disable logging except for errors',
+                    default=False,
+                    action='store_true')
+  result.add_option('--print-dexsegments',
+                    metavar='BENCHMARKNAME',
+                    help='Print the sizes of individual dex segments as ' +
+                         '\'<BENCHMARKNAME>-<APP>-<segment>(CodeSize): '
+                         '<bytes>\'')
+  result.add_option('--quiet',
+                    help='Disable verbose logging',
+                    default=False,
+                    action='store_true')
+  result.add_option('--r8-compilation-steps', '--r8_compilation_steps',
+                    help='Number of times R8 should be run on each app',
+                    default=2,
+                    type=int)
+  result.add_option('--run-tests', '--run_tests',
+                    help='Whether to run instrumentation tests',
+                    default=False,
+                    action='store_true')
+  result.add_option('--sign-apks', '--sign_apks',
+                    help='Whether the APKs should be signed',
+                    default=False,
+                    action='store_true')
+  result.add_option('--shrinker',
+                    help='The shrinkers to use (by default, all are run)',
+                    action='append')
+  result.add_option('--version',
+                    help='The version of R8 to use (e.g., 1.4.51)')
+  (options, args) = result.parse_args(argv)
+  if options.app:
+    options.apps = [app for app in APPS if app.name in options.app]
+    del options.app
+  else:
+    options.apps = APPS
+  if options.app_logging_filter:
+    for app_name in options.app_logging_filter:
+      assert any(app.name == app_name for app in options.apps)
+  if options.shrinker:
+    for shrinker in options.shrinker:
+      assert shrinker in SHRINKERS
+  else:
+    options.shrinker = [shrinker for shrinker in SHRINKERS]
+
+  if options.hash or options.version:
+    # No need to build R8 if a specific version should be used.
+    options.no_build = True
+    if 'r8-nolib' in options.shrinker:
+      warn('Skipping shrinker r8-nolib because a specific version '
+           + 'of r8 was specified')
+      options.shrinker.remove('r8-nolib')
+    if 'r8-nolib-full' in options.shrinker:
+      warn('Skipping shrinker r8-nolib-full because a specific version '
+           + 'of r8 was specified')
+      options.shrinker.remove('r8-nolib-full')
+  return (options, args)
+
+
+def main(argv):
+  (options, args) = parse_options(argv)
+
+  if options.bot:
+    options.no_logging = True
+    options.shrinker = ['r8', 'r8-full']
+    print(options.shrinker)
+
+  if options.golem:
+    golem.link_third_party()
+    options.disable_assertions = True
+    options.no_build = True
+    options.r8_compilation_steps = 1
+    options.quiet = True
+    options.no_logging = True
+
+
+  with utils.TempDir() as temp_dir:
+    if options.hash:
+      # Download r8-<hash>.jar from
+      # https://storage.googleapis.com/r8-releases/raw/.
+      target = 'r8-{}.jar'.format(options.hash)
+      update_prebuilds_in_android.download_hash(
+        temp_dir, 'com/android/tools/r8/' + options.hash, target)
+      as_utils.MoveFile(
+        os.path.join(temp_dir, target), os.path.join(temp_dir, 'r8lib.jar'),
+        quiet=options.quiet)
+    elif options.version:
+      # Download r8-<version>.jar from
+      # https://storage.googleapis.com/r8-releases/raw/.
+      target = 'r8-{}.jar'.format(options.version)
+      update_prebuilds_in_android.download_version(
+        temp_dir, 'com/android/tools/r8/' + options.version, target)
+      as_utils.MoveFile(
+        os.path.join(temp_dir, target), os.path.join(temp_dir, 'r8lib.jar'),
+        quiet=options.quiet)
+    else:
+      if not (options.no_build or options.golem):
+        gradle.RunGradle(['r8', '-Pno_internal'])
+        build_r8lib = False
+        for shrinker in options.shrinker:
+          if is_minified_r8(shrinker):
+            build_r8lib = True
+        if build_r8lib:
+          gradle.RunGradle(['r8lib', '-Pno_internal'])
+      # Make a copy of r8.jar and r8lib.jar such that they stay the same for
+      # the entire execution of this script.
+      if 'r8-nolib' in options.shrinker or 'r8-nolib-full' in options.shrinker:
+        assert os.path.isfile(utils.R8_JAR), 'Cannot build without r8.jar'
+        shutil.copyfile(utils.R8_JAR, os.path.join(temp_dir, 'r8.jar'))
+      if 'r8' in options.shrinker or 'r8-full' in options.shrinker:
+        assert os.path.isfile(utils.R8LIB_JAR), 'Cannot build without r8lib.jar'
+        shutil.copyfile(utils.R8LIB_JAR, os.path.join(temp_dir, 'r8lib.jar'))
+
+    result_per_shrinker_per_app = []
+    for app in options.apps:
+      if app.skip:
+        continue
+      result_per_shrinker_per_app.append(
+        (app, get_results_for_app(app, options, temp_dir)))
+    return log_results_for_apps(result_per_shrinker_per_app, options)
+
+
+def success(message):
+  CGREEN = '\033[32m'
+  CEND = '\033[0m'
+  print(CGREEN + message + CEND)
+
+
+def warn(message):
+  CRED = '\033[91m'
+  CEND = '\033[0m'
+  print(CRED + message + CEND)
+
+
+if __name__ == '__main__':
+  sys.exit(main(sys.argv[1:]))
diff --git a/tools/utils.py b/tools/utils.py
index 96708d2..0ece764 100644
--- a/tools/utils.py
+++ b/tools/utils.py
@@ -70,7 +70,9 @@
     THIRD_PARTY, 'sample_libraries.tar.gz.sha1')
 OPENSOURCE_APPS_SHA_FILE = os.path.join(
     THIRD_PARTY, 'opensource_apps.tar.gz.sha1')
+# TODO(b/152155164): Remove this when all apps has been migrated.
 OPENSOURCE_APPS_FOLDER = os.path.join(THIRD_PARTY, 'opensource_apps')
+OPENSOURCE_DUMPS_DIR = os.path.join(THIRD_PARTY, 'opensource-apps')
 BAZEL_SHA_FILE = os.path.join(THIRD_PARTY, 'bazel.tar.gz.sha1')
 BAZEL_TOOL = os.path.join(THIRD_PARTY, 'bazel')
 JAVA8_SHA_FILE = os.path.join(THIRD_PARTY, 'openjdk', 'jdk8', 'linux-x86.tar.gz.sha1')