bisection_search.py revision 698e4b30e6d0e937e34e73a3c5349f67a076e1b5
1#!/usr/bin/env python3.4
2#
3# Copyright (C) 2016 The Android Open Source Project
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9#   http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16
17"""Performs bisection bug search on methods and optimizations.
18
19See README.md.
20
21Example usage:
22./bisection-search.py -cp classes.dex --expected-output output Test
23"""
24
25import abc
26import argparse
27import re
28import shlex
29from subprocess import call
30import sys
31from tempfile import NamedTemporaryFile
32
33from common import DeviceTestEnv
34from common import FatalError
35from common import GetEnvVariableOrError
36from common import HostTestEnv
37from common import LogSeverity
38from common import RetCode
39
40
41# Passes that are never disabled during search process because disabling them
42# would compromise correctness.
43MANDATORY_PASSES = ['dex_cache_array_fixups_arm',
44                    'dex_cache_array_fixups_mips',
45                    'instruction_simplifier$before_codegen',
46                    'pc_relative_fixups_x86',
47                    'pc_relative_fixups_mips',
48                    'x86_memory_operand_generation']
49
50# Passes that show up as optimizations in compiler verbose output but aren't
51# driven by run-passes mechanism. They are mandatory and will always run, we
52# never pass them to --run-passes.
53NON_PASSES = ['builder', 'prepare_for_register_allocation',
54              'liveness', 'register']
55
56# If present in raw cmd, this tag will be replaced with runtime arguments
57# controlling the bisection search. Otherwise arguments will be placed on second
58# position in the command.
59RAW_CMD_RUNTIME_ARGS_TAG = '{ARGS}'
60
61# Default core image path relative to ANDROID_HOST_OUT.
62DEFAULT_IMAGE_RELATIVE_PATH = '/framework/core.art'
63
64class Dex2OatWrapperTestable(object):
65  """Class representing a testable compilation.
66
67  Accepts filters on compiled methods and optimization passes.
68  """
69
70  def __init__(self, base_cmd, test_env, expected_retcode=None,
71               output_checker=None, verbose=False):
72    """Constructor.
73
74    Args:
75      base_cmd: list of strings, base command to run.
76      test_env: ITestEnv.
77      expected_retcode: RetCode, expected normalized return code.
78      output_checker: IOutputCheck, output checker.
79      verbose: bool, enable verbose output.
80    """
81    self._base_cmd = base_cmd
82    self._test_env = test_env
83    self._expected_retcode = expected_retcode
84    self._output_checker = output_checker
85    self._compiled_methods_path = self._test_env.CreateFile('compiled_methods')
86    self._passes_to_run_path = self._test_env.CreateFile('run_passes')
87    self._verbose = verbose
88    if RAW_CMD_RUNTIME_ARGS_TAG in self._base_cmd:
89      self._arguments_position = self._base_cmd.index(RAW_CMD_RUNTIME_ARGS_TAG)
90      self._base_cmd.pop(self._arguments_position)
91    else:
92      self._arguments_position = 1
93
94  def Test(self, compiled_methods, passes_to_run=None):
95    """Tests compilation with compiled_methods and run_passes switches active.
96
97    If compiled_methods is None then compiles all methods.
98    If passes_to_run is None then runs default passes.
99
100    Args:
101      compiled_methods: list of strings representing methods to compile or None.
102      passes_to_run: list of strings representing passes to run or None.
103
104    Returns:
105      True if test passes with given settings. False otherwise.
106    """
107    if self._verbose:
108      print('Testing methods: {0} passes: {1}.'.format(
109          compiled_methods, passes_to_run))
110    cmd = self._PrepareCmd(compiled_methods=compiled_methods,
111                           passes_to_run=passes_to_run)
112    (output, ret_code) = self._test_env.RunCommand(
113        cmd, LogSeverity.ERROR)
114    res = True
115    if self._expected_retcode:
116      res = self._expected_retcode == ret_code
117    if self._output_checker:
118      res = res and self._output_checker.Check(output)
119    if self._verbose:
120      print('Test passed: {0}.'.format(res))
121    return res
122
123  def GetAllMethods(self):
124    """Get methods compiled during the test.
125
126    Returns:
127      List of strings representing methods compiled during the test.
128
129    Raises:
130      FatalError: An error occurred when retrieving methods list.
131    """
132    cmd = self._PrepareCmd()
133    (output, _) = self._test_env.RunCommand(cmd, LogSeverity.INFO)
134    match_methods = re.findall(r'Building ([^\n]+)\n', output)
135    if not match_methods:
136      raise FatalError('Failed to retrieve methods list. '
137                       'Not recognized output format.')
138    return match_methods
139
140  def GetAllPassesForMethod(self, compiled_method):
141    """Get all optimization passes ran for a method during the test.
142
143    Args:
144      compiled_method: string representing method to compile.
145
146    Returns:
147      List of strings representing passes ran for compiled_method during test.
148
149    Raises:
150      FatalError: An error occurred when retrieving passes list.
151    """
152    cmd = self._PrepareCmd(compiled_methods=[compiled_method])
153    (output, _) = self._test_env.RunCommand(cmd, LogSeverity.INFO)
154    match_passes = re.findall(r'Starting pass: ([^\n]+)\n', output)
155    if not match_passes:
156      raise FatalError('Failed to retrieve passes list. '
157                       'Not recognized output format.')
158    return [p for p in match_passes if p not in NON_PASSES]
159
160  def _PrepareCmd(self, compiled_methods=None, passes_to_run=None,
161                  verbose_compiler=False):
162    """Prepare command to run."""
163    cmd = self._base_cmd[0:self._arguments_position]
164    # insert additional arguments before the first argument
165    if compiled_methods is not None:
166      self._test_env.WriteLines(self._compiled_methods_path, compiled_methods)
167      cmd += ['-Xcompiler-option', '--compiled-methods={0}'.format(
168          self._compiled_methods_path)]
169    if passes_to_run is not None:
170      self._test_env.WriteLines(self._passes_to_run_path, passes_to_run)
171      cmd += ['-Xcompiler-option', '--run-passes={0}'.format(
172          self._passes_to_run_path)]
173    cmd += ['-Xcompiler-option', '--runtime-arg', '-Xcompiler-option',
174            '-verbose:compiler', '-Xcompiler-option', '-j1']
175    cmd += self._base_cmd[self._arguments_position:]
176    return cmd
177
178
179class IOutputCheck(object):
180  """Abstract output checking class.
181
182  Checks if output is correct.
183  """
184  __meta_class__ = abc.ABCMeta
185
186  @abc.abstractmethod
187  def Check(self, output):
188    """Check if output is correct.
189
190    Args:
191      output: string, output to check.
192
193    Returns:
194      boolean, True if output is correct, False otherwise.
195    """
196
197
198class EqualsOutputCheck(IOutputCheck):
199  """Concrete output checking class checking for equality to expected output."""
200
201  def __init__(self, expected_output):
202    """Constructor.
203
204    Args:
205      expected_output: string, expected output.
206    """
207    self._expected_output = expected_output
208
209  def Check(self, output):
210    """See base class."""
211    return self._expected_output == output
212
213
214class ExternalScriptOutputCheck(IOutputCheck):
215  """Concrete output checking class calling an external script.
216
217  The script should accept two arguments, path to expected output and path to
218  program output. It should exit with 0 return code if outputs are equivalent
219  and with different return code otherwise.
220  """
221
222  def __init__(self, script_path, expected_output_path, logfile):
223    """Constructor.
224
225    Args:
226      script_path: string, path to checking script.
227      expected_output_path: string, path to file with expected output.
228      logfile: file handle, logfile.
229    """
230    self._script_path = script_path
231    self._expected_output_path = expected_output_path
232    self._logfile = logfile
233
234  def Check(self, output):
235    """See base class."""
236    ret_code = None
237    with NamedTemporaryFile(mode='w', delete=False) as temp_file:
238      temp_file.write(output)
239      temp_file.flush()
240      ret_code = call(
241          [self._script_path, self._expected_output_path, temp_file.name],
242          stdout=self._logfile, stderr=self._logfile, universal_newlines=True)
243    return ret_code == 0
244
245
246def BinarySearch(start, end, test):
247  """Binary search integers using test function to guide the process."""
248  while start < end:
249    mid = (start + end) // 2
250    if test(mid):
251      start = mid + 1
252    else:
253      end = mid
254  return start
255
256
257def FilterPasses(passes, cutoff_idx):
258  """Filters passes list according to cutoff_idx but keeps mandatory passes."""
259  return [opt_pass for idx, opt_pass in enumerate(passes)
260          if opt_pass in MANDATORY_PASSES or idx < cutoff_idx]
261
262
263def BugSearch(testable):
264  """Find buggy (method, optimization pass) pair for a given testable.
265
266  Args:
267    testable: Dex2OatWrapperTestable.
268
269  Returns:
270    (string, string) tuple. First element is name of method which when compiled
271    exposes test failure. Second element is name of optimization pass such that
272    for aforementioned method running all passes up to and excluding the pass
273    results in test passing but running all passes up to and including the pass
274    results in test failing.
275
276    (None, None) if test passes when compiling all methods.
277    (string, None) if a method is found which exposes the failure, but the
278      failure happens even when running just mandatory passes.
279
280  Raises:
281    FatalError: Testable fails with no methods compiled.
282    AssertionError: Method failed for all passes when bisecting methods, but
283    passed when bisecting passes. Possible sporadic failure.
284  """
285  all_methods = testable.GetAllMethods()
286  faulty_method_idx = BinarySearch(
287      0,
288      len(all_methods) + 1,
289      lambda mid: testable.Test(all_methods[0:mid]))
290  if faulty_method_idx == len(all_methods) + 1:
291    return (None, None)
292  if faulty_method_idx == 0:
293    raise FatalError('Testable fails with no methods compiled. '
294                     'Perhaps issue lies outside of compiler.')
295  faulty_method = all_methods[faulty_method_idx - 1]
296  all_passes = testable.GetAllPassesForMethod(faulty_method)
297  faulty_pass_idx = BinarySearch(
298      0,
299      len(all_passes) + 1,
300      lambda mid: testable.Test([faulty_method],
301                                FilterPasses(all_passes, mid)))
302  if faulty_pass_idx == 0:
303    return (faulty_method, None)
304  assert faulty_pass_idx != len(all_passes) + 1, ('Method must fail for some '
305                                                  'passes.')
306  faulty_pass = all_passes[faulty_pass_idx - 1]
307  return (faulty_method, faulty_pass)
308
309
310def PrepareParser():
311  """Prepares argument parser."""
312  parser = argparse.ArgumentParser(
313      description='Tool for finding compiler bugs. Either --raw-cmd or both '
314                  '-cp and --class are required.')
315  command_opts = parser.add_argument_group('dalvikvm command options')
316  command_opts.add_argument('-cp', '--classpath', type=str, help='classpath')
317  command_opts.add_argument('--class', dest='classname', type=str,
318                            help='name of main class')
319  command_opts.add_argument('--lib', type=str, default='libart.so',
320                            help='lib to use, default: libart.so')
321  command_opts.add_argument('--dalvikvm-option', dest='dalvikvm_opts',
322                            metavar='OPT', nargs='*', default=[],
323                            help='additional dalvikvm option')
324  command_opts.add_argument('--arg', dest='test_args', nargs='*', default=[],
325                            metavar='ARG', help='argument passed to test')
326  command_opts.add_argument('--image', type=str, help='path to image')
327  command_opts.add_argument('--raw-cmd', type=str,
328                            help='bisect with this command, ignore other '
329                                 'command options')
330  bisection_opts = parser.add_argument_group('bisection options')
331  bisection_opts.add_argument('--64', dest='x64', action='store_true',
332                              default=False, help='x64 mode')
333  bisection_opts.add_argument(
334      '--device', action='store_true', default=False, help='run on device')
335  bisection_opts.add_argument(
336      '--device-serial', help='device serial number, implies --device')
337  bisection_opts.add_argument('--expected-output', type=str,
338                              help='file containing expected output')
339  bisection_opts.add_argument(
340      '--expected-retcode', type=str, help='expected normalized return code',
341      choices=[RetCode.SUCCESS.name, RetCode.TIMEOUT.name, RetCode.ERROR.name])
342  bisection_opts.add_argument(
343      '--check-script', type=str,
344      help='script comparing output and expected output')
345  bisection_opts.add_argument(
346      '--logfile', type=str, help='custom logfile location')
347  bisection_opts.add_argument('--cleanup', action='store_true',
348                              default=False, help='clean up after bisecting')
349  bisection_opts.add_argument('--timeout', type=int, default=60,
350                              help='if timeout seconds pass assume test failed')
351  bisection_opts.add_argument('--verbose', action='store_true',
352                              default=False, help='enable verbose output')
353  return parser
354
355
356def PrepareBaseCommand(args, classpath):
357  """Prepares base command used to run test."""
358  if args.raw_cmd:
359    return shlex.split(args.raw_cmd)
360  else:
361    base_cmd = ['dalvikvm64'] if args.x64 else ['dalvikvm32']
362    if not args.device:
363      base_cmd += ['-XXlib:{0}'.format(args.lib)]
364      if not args.image:
365        image_path = (GetEnvVariableOrError('ANDROID_HOST_OUT') +
366                      DEFAULT_IMAGE_RELATIVE_PATH)
367      else:
368        image_path = args.image
369      base_cmd += ['-Ximage:{0}'.format(image_path)]
370    if args.dalvikvm_opts:
371      base_cmd += args.dalvikvm_opts
372    base_cmd += ['-cp', classpath, args.classname] + args.test_args
373  return base_cmd
374
375
376def main():
377  # Parse arguments
378  parser = PrepareParser()
379  args = parser.parse_args()
380  if not args.raw_cmd and (not args.classpath or not args.classname):
381    parser.error('Either --raw-cmd or both -cp and --class are required')
382  if args.device_serial:
383    args.device = True
384  if args.expected_retcode:
385    args.expected_retcode = RetCode[args.expected_retcode]
386  if not args.expected_retcode and not args.check_script:
387    args.expected_retcode = RetCode.SUCCESS
388
389  # Prepare environment
390  classpath = args.classpath
391  if args.device:
392    test_env = DeviceTestEnv(
393        'bisection_search_', args.cleanup, args.logfile, args.timeout,
394        args.device_serial)
395    if classpath:
396      classpath = test_env.PushClasspath(classpath)
397  else:
398    test_env = HostTestEnv(
399        'bisection_search_', args.cleanup, args.logfile, args.timeout, args.x64)
400  base_cmd = PrepareBaseCommand(args, classpath)
401  output_checker = None
402  if args.expected_output:
403    if args.check_script:
404      output_checker = ExternalScriptOutputCheck(
405          args.check_script, args.expected_output, test_env.logfile)
406    else:
407      with open(args.expected_output, 'r') as expected_output_file:
408        output_checker = EqualsOutputCheck(expected_output_file.read())
409
410  # Perform the search
411  try:
412    testable = Dex2OatWrapperTestable(base_cmd, test_env, args.expected_retcode,
413                                      output_checker, args.verbose)
414    (method, opt_pass) = BugSearch(testable)
415  except Exception as e:
416    print('Error occurred.\nLogfile: {0}'.format(test_env.logfile.name))
417    test_env.logfile.write('Exception: {0}\n'.format(e))
418    raise
419
420  # Report results
421  if method is None:
422    print('Couldn\'t find any bugs.')
423  elif opt_pass is None:
424    print('Faulty method: {0}. Fails with just mandatory passes.'.format(
425        method))
426  else:
427    print('Faulty method and pass: {0}, {1}.'.format(method, opt_pass))
428  print('Logfile: {0}'.format(test_env.logfile.name))
429  sys.exit(0)
430
431
432if __name__ == '__main__':
433  main()
434