Added a profile bisection tool

This tool allows one to bisect methods in a profile to figure out
(e.g.) which one is being miscompiled. It will either take a
pre-existing profile/text-profile to start from or generate one from
the apks where every method is marked as a hot-startup method.

Usage: ./art/tools/bisect_profile.py --apk /tmp/benchmarks.dex \
                                     --apk /tmp/ritz.jar \
                                     --output-source bad.text-prof \
                                     bad.prof

Test: manual

Change-Id: I2744a2c6e2b6cf3dda3ccbb486fa2dbfd888351b
diff --git a/tools/bisect_profile.py b/tools/bisect_profile.py
new file mode 100755
index 0000000..9b2479a
--- /dev/null
+++ b/tools/bisect_profile.py
@@ -0,0 +1,189 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2021 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+#
+# Generates profiles from the set of all methods in a given set of dex/jars and
+# bisects to find minimal repro sets.
+#
+
+import shlex
+import argparse
+import pylibdexfile
+import math
+import subprocess
+from collections import namedtuple
+import sys
+import random
+import os
+
+ApkEntry = namedtuple("ApkEntry", ["file", "location"])
+
+
+def get_parser():
+  parser = argparse.ArgumentParser(
+      description="Bisect profile contents. We will wait while the user runs test"
+  )
+
+  class ApkAction(argparse.Action):
+
+    def __init__(self, option_strings, dest, **kwargs):
+      super(ApkAction, self).__init__(option_strings, dest, **kwargs)
+
+    def __call__(self, parser, namespace, values, option_string=None):
+      lst = getattr(namespace, self.dest)
+      if lst is None:
+        setattr(namespace, self.dest, [])
+        lst = getattr(namespace, self.dest)
+      if len(values) == 1:
+        values = (values[0], values[0])
+      assert len(values) == 2, values
+      lst.append(ApkEntry(*values))
+
+  apks = parser.add_argument_group(title="APK selection")
+  apks.add_argument(
+      "--apk",
+      action=ApkAction,
+      dest="apks",
+      nargs=1,
+      default=[],
+      help="an apk/dex/jar to get methods from. Uses same path as location. " +
+           "Use --apk-and-location if this isn't desired."
+  )
+  apks.add_argument(
+      "--apk-and-location",
+      action=ApkAction,
+      nargs=2,
+      dest="apks",
+      help="an apk/dex/jar + location to get methods from."
+  )
+  profiles = parser.add_argument_group(
+      title="Profile selection").add_mutually_exclusive_group()
+  profiles.add_argument(
+      "--input-text-profile", help="a text profile to use for bisect")
+  profiles.add_argument("--input-profile", help="a profile to use for bisect")
+  parser.add_argument(
+      "--output-source", help="human readable file create the profile from")
+  parser.add_argument("output_file", help="file we will write the profiles to")
+  return parser
+
+
+def dump_files(meths, args, output):
+  for m in meths:
+    print("HS{}".format(m), file=output)
+  output.flush()
+  profman_args = [
+      "profmand", "--reference-profile-file={}".format(args.output_file),
+      "--create-profile-from={}".format(args.output_source)
+  ]
+  print(" ".join(map(shlex.quote, profman_args)))
+  for apk in args.apks:
+    profman_args += [
+        "--apk={}".format(apk.file), "--dex-location={}".format(apk.location)
+    ]
+  profman = subprocess.run(profman_args)
+  profman.check_returncode()
+
+
+def run_test(meths, args):
+  with open(args.output_source, "wt") as output:
+    dump_files(meths, args, output)
+    print("Currently testing {} methods. ~{} rounds to go.".format(
+        len(meths), 1 + math.floor(math.log2(len(meths)))))
+    while True:
+      answer = input("Does the file at {} cause the issue (y/n):".format(
+          args.output_file))
+      if answer[0].lower() == "y":
+        return "y"
+      elif answer[0].lower() == "n":
+        return "n"
+      else:
+        print("Please enter 'y' or 'n' only!")
+
+
+def main():
+  parser = get_parser()
+  args = parser.parse_args()
+  if args.output_source is None:
+    fdnum = os.memfd_create("tempfile_profile")
+    args.output_source = "/proc/{}/fd/{}".format(os.getpid(), fdnum)
+  all_dexs = list()
+  for f in args.apks:
+    try:
+      all_dexs.append(pylibdexfile.FileDexFile(f.file, f.location))
+    except Exception as e1:
+      try:
+        all_dexs += pylibdexfile.OpenJar(f.file)
+      except Exception as e2:
+        parser.error("Failed to open file: {}. errors were {} and {}".format(
+            f.file, e1, e2))
+  if args.input_profile is not None:
+    profman_args = [
+        "profmand", "--dump-classes-and-methods",
+        "--profile-file={}".format(args.input_profile)
+    ]
+    for apk in args.apks:
+      profman_args.append("--apk={}".format(apk.file))
+    print(" ".join(map(shlex.quote, profman_args)))
+    res = subprocess.run(
+        profman_args, capture_output=True, universal_newlines=True)
+    res.check_returncode()
+    meth_list = list(filter(lambda a: a != "", res.stdout.split()))
+  elif args.input_text_profile is not None:
+    with open(args.input_text_profile, "rt") as inp:
+      meth_list = list(filter(lambda a: a != "", inp.readlines()))
+  else:
+    all_methods = set()
+    for d in all_dexs:
+      for m in d.methods:
+        all_methods.add(m.descriptor)
+    meth_list = list(all_methods)
+  print("Found {} methods. Will take ~{} iterations".format(
+      len(meth_list), 1 + math.floor(math.log2(len(meth_list)))))
+  print(
+      "type 'yes' if the behavior you are looking for is present (i.e. the compiled code crashes " +
+      "or something)"
+  )
+  print("Performing single check with all methods")
+  result = run_test(meth_list, args)
+  if result[0].lower() != "y":
+    cont = input(
+        "The behavior you were looking for did not occur when run against all methods. Continue " +
+        "(yes/no)? "
+    )
+    if cont[0].lower() != "y":
+      print("Aborting!")
+      sys.exit(1)
+  needs_dump = False
+  while len(meth_list) > 1:
+    test_methods = list(meth_list[0:len(meth_list) // 2])
+    result = run_test(test_methods, args)
+    if result[0].lower() == "y":
+      meth_list = test_methods
+      needs_dump = False
+    else:
+      meth_list = meth_list[len(meth_list) // 2:]
+      needs_dump = True
+  if needs_dump:
+    with open(args.output_source, "wt") as output:
+      dump_files(meth_list, args, output)
+  print("Found result!")
+  print("{}".format(meth_list[0]))
+  print("Leaving profile at {} and text profile at {}".format(
+      args.output_file, args.output_source))
+
+
+if __name__ == "__main__":
+  main()