1#!/usr/bin/env python3 2# -- coding: utf-8 -- 3# Copyright (c) 2025 Huawei Device Co., Ltd. 4# Licensed under the Apache License, Version 2.0 (the "License"); 5# you may not use this file except in compliance with the License. 6# You may obtain a copy of the License at 7# 8# http://www.apache.org/licenses/LICENSE-2.0 9# 10# Unless required by applicable law or agreed to in writing, software 11# distributed under the License is distributed on an "AS IS" BASIS, 12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13# See the License for the specific language governing permissions and 14# limitations under the License. 15 16import subprocess 17import sys 18import shutil 19import glob 20import re 21import random 22import os 23from collections import namedtuple 24from typing import List, Set, Dict, Optional, Tuple, Iterable 25 26HELP_TEXT = """ 27Script to help find minimal subsets of compiled methods, inlined methods and compiler passes 28to reproduce compiler bugs. 29 30Usage: 31 Write a shell command or a bash script which launches a test and exits with non-zero code 32 when the test fails. Use `$GEN_OPTIONS` variable in your script/command where 33 compiler options are needed (probably next to other arguments of `ark`/`ark_aot`), 34 and this script will set it to different values and run your script to find minimal subsets 35 of options which lead to failure. 36 37 Run `./compiler_bisect.py [COMMAND] [ARGS]...` 38 Or put command in a script and do `./compiler_bisect.py ./script.sh` 39 40Example usage: 41 cd $ARK_BUILD 42 $ARK_ROOT/scripts/compiler_bisect.py bin/ark --boot-panda-files=plugins/ets/etsstdlib.abc --load-runtimes=ets \ 43 --compiler-enable-jit=true --no-async-jit=true --compiler-hotness-threshold=0 --compiler-ignore-failures=false \ 44 \$GEN_OPTIONS test.abc ETSGLOBAL::main 45 46 Or put command in `run.sh` and do `./compiler_bisect.py ./run.sh` 47 48 Note that you need escaped \$GEN_OPTIONS when passing command with arguments, and not escaped 49 $GEN_OPTIONS inside your script 50""" 51 52 53class Colors: 54 """ANSI color codes for terminal output""" 55 OKCYAN = '\033[96m' 56 OKGREEN = '\033[92m' 57 WARNING = '\033[93m' 58 FAIL = '\033[91m' 59 ENDC = '\033[0m' 60 BOLD = '\033[1m' 61 62 63class PassConfig: 64 """Configuration for compiler passes and their logging""" 65 PASS_LOGS: Dict[str, Optional[str]] = { 66 'lowering': 'lowering', 67 'code-sink': 'code-sink', 68 'balance-expressions': 'balance-expr', 69 'branch-elimination': 'branch-elim', 70 'checks-elimination': 'checks-elim', 71 'deoptimize-elimination': 'deoptimize-elim', 72 'licm': 'licm-opt', 73 'licm-conditions': 'licm-cond-opt', 74 'loop-unswitch': 'loop-unswitch', 75 'loop-idioms': None, 76 'loop-peeling': None, 77 'loop-unroll': None, 78 'lse': 'lse-opt', 79 'cse': 'cse-opt', 80 'vn': 'vn-opt', 81 'memory-coalescing': 'memory-coalescing', 82 'inlining': 'inlining', 83 'if-conversion': 'ifconversion', 84 'adjust-refs': None, 85 'scalar-replacement': 'pea', 86 'simplify-string-builder': 'simplify-sb', 87 'enable-fast-interop': None, 88 'interop-intrinsic-optimization': 'interop-intrinsic-opt', 89 'peepholes': 'peephole', 90 'move-constants': None, 91 'if-merging': 'if-merging', 92 'redundant-loop-elimination': 'rle-opt', 93 'scheduling': 'scheduler', 94 'optimize-memory-barriers': None, 95 } 96 97 @classmethod 98 def get_pass_list(cls) -> List[str]: 99 """Get list of all available passes""" 100 return list(cls.PASS_LOGS.keys()) 101 102 103class CommandRunner: 104 """Handles execution of test commands with different options""" 105 106 def __init__(self, args: List[str]): 107 self.args = args 108 self.env = os.environ.copy() 109 110 def get_run_options( 111 self, 112 compiled_methods: Optional[Iterable[str]], 113 noinline_methods: Iterable[str], 114 passes: Optional[Iterable[str]], 115 dump: bool, 116 verbose: bool 117 ) -> List[str]: 118 """Generate compiler options based on the specified parameters. 119 120 Args: 121 compiled_methods: Methods to compile (None means all methods) 122 noinline_methods: Methods to exclude from inlining 123 passes: Optimization passes to enable (None means all passes) 124 dump: Whether to enable compiler dump 125 verbose: Whether to enable verbose output 126 127 Returns: 128 List of compiler option strings 129 """ 130 pass_list = PassConfig.get_pass_list() 131 # Generate compiler regex pattern 132 compiler_regex = '.*' if compiled_methods is None else '(' + '|'.join(compiled_methods) + ')' 133 134 135 inline_exclude = ','.join(noinline_methods) 136 137 # Determine which passes to use 138 passes = pass_list if passes is None else passes 139 140 # NOLINTNEXTLINE 141 options = [f'--compiler-regex={compiler_regex}', f'--compiler-inlining-blacklist={inline_exclude}'] 142 options += [f'--compiler-{opt}={str(opt in passes).lower()}' for opt in pass_list] 143 # Add dump options if needed 144 if dump or verbose: 145 options.append('--compiler-dump') 146 147 # Add verbose options if needed 148 if verbose: 149 options.extend([ 150 '--compiler-disasm-dump:single-file', 151 '--log-debug=compiler' 152 ]) 153 154 # Generate compiler log options 155 compiler_log = set(PassConfig.PASS_LOGS.get(opt) for opt in passes) - {None} 156 if any('loop' in opt for opt in passes): 157 compiler_log.add('loop-transform') 158 if compiler_log: 159 options.append('--compiler-log=' + ','.join(compiler_log)) 160 161 return options 162 163 164 def run_test( 165 self, 166 compiled_methods: Optional[Iterable[str]], 167 noinline_methods: Iterable[str], 168 passes: Optional[Iterable[str]], 169 dump: bool = False, 170 verbose: bool = False, 171 expect_fail: Optional[bool] = None 172 ) -> subprocess.CompletedProcess: 173 """Execute the compiler with specified options and return the result. 174 175 Args: 176 compiled_methods: Methods to compile (None for all methods) 177 noinline_methods: Methods to exclude from inlining 178 passes: Optimization passes to enable (None for all passes) 179 dump: Whether to enable compiler dump 180 verbose: Whether to enable verbose output 181 expect_fail: Whether failure is expected (None for no expectation) 182 183 Returns: 184 Completed process result from subprocess.run() 185 186 Raises: 187 RuntimeError: If command execution fails 188 """ 189 options = self.get_run_options(compiled_methods, noinline_methods, passes, dump, verbose) 190 options_str = ' '.join(options) 191 self.env["GEN_OPTIONS"] = options_str 192 193 shutil.rmtree('ir_dump', ignore_errors=True) 194 195 ## Prepare command 196 cmd = self._prepare_command(options) 197 198 if verbose or (expect_fail is not None): 199 self.env['SHELLOPTS'] = 'xtrace' 200 201 try: 202 res = subprocess.run( 203 cmd, 204 env=self.env, 205 stdout=subprocess.PIPE, 206 stderr=subprocess.PIPE, 207 text=True 208 ) 209 except Exception as e: 210 raise RuntimeError(f"Command execution failed: {e}") from e 211 212 # Process and display results 213 self._handle_results(res, options_str, cmd, verbose, expect_fail) 214 215 return res 216 217 def _is_shell_script(self, path: str) -> bool: 218 """Check if a file is a shell script""" 219 try: 220 with open(path, 'r') as f: 221 first_line = f.readline() 222 return first_line.startswith('#!') and 'sh' in first_line 223 except (IOError, FileNotFoundError): 224 return False 225 226 def _prepare_command(self, options: List[str]) -> List[str]: 227 """Prepare the command to be executed.""" 228 if len(sys.argv) > 1 and (sys.argv[1].endswith('.sh') or self._is_shell_script(sys.argv[1])): 229 return ['bash', sys.argv[1]] 230 231 cmd = [] 232 for arg in sys.argv[1:]: 233 if arg == '$GEN_OPTIONS': 234 cmd.extend(options) # More efficient than += 235 else: 236 cmd.append(arg) 237 return cmd 238 239 def _handle_results( 240 self, 241 result: subprocess.CompletedProcess, 242 options_str: str, 243 cmd: List[str], 244 verbose: bool, 245 expect_fail: Optional[bool] 246 ) -> None: 247 """Handle and display the command results.""" 248 # Determine status color 249 status_color = Colors.WARNING if result.returncode else Colors.OKGREEN 250 print_color(status_color, "Return value:", result.returncode) 251 252 # Check if we need to force verbose output 253 if expect_fail is not None and (result.returncode != 0) != expect_fail: 254 verbose = True 255 256 # Show detailed output if verbose 257 if verbose: 258 print(f"GEN_OPTIONS='{options_str}'") 259 print('Command:', ' '.join(cmd)) 260 if result.stdout: 261 print("Standard Output:") 262 print(result.stdout) 263 if result.stderr: 264 print("Standard Error:") 265 print(result.stderr) 266 267 268class OptionSet: 269 """Manages a set of options for bisection""" 270 271 def __init__(self, values: Set[str]): 272 self.all_values = set(values) 273 self.current_values = list(values) 274 275 @property 276 def negative_values(self) -> Set[str]: 277 """Get values not in current set""" 278 return self.all_values - set(self.current_values) 279 280 281class BisectionEngine: 282 """Handles the bisection process to find minimal failing options""" 283 284 Options = namedtuple('Options', ['compiled', 'inline', 'passes']) 285 286 def __init__(self, runner: CommandRunner): 287 self.runner = runner 288 self.opts = None 289 self.current_option = '' 290 291 def initialize_options( 292 self, 293 compiled_methods: Set[str], 294 inline_methods: Set[str], 295 passes: Set[str] 296 ) -> None: 297 """Initialize the options for bisection. 298 299 Args: 300 compiled_methods: Set of methods to compile 301 inline_methods: Set of methods to inline 302 passes: Set of compiler passes to enable 303 """ 304 self.opts = self.Options( 305 compiled=OptionSet(compiled_methods), 306 inline=OptionSet(inline_methods), 307 passes=OptionSet(passes) 308 ) 309 310 def set_option(self, name, value): 311 self.opts = self.opts._replace(**{name: value}) 312 313 def run_with_options(self, verbose: bool = False) -> bool: 314 """Run test with current options and return whether it failed. 315 316 Args: 317 verbose: Whether to show verbose output 318 319 Returns: 320 bool: True if test failed, False otherwise 321 """ 322 result = self.runner.run_test(self.opts.compiled.current_values, self.opts.inline.negative_values, 323 self.opts.passes.current_values, verbose=verbose) 324 return result.returncode != 0 325 326 def bisect_option(self, option_name: str) -> None: 327 """Perform bisection on a specific option set to find minimal failing subset. 328 329 Args: 330 option_name: Name of option to bisect ('compiled', 'inline', or 'passes') 331 """ 332 self.current_option = option_name 333 option_set = getattr(self.opts, option_name) 334 print_color(Colors.OKCYAN, f'Bisecting {option_name}...') 335 336 while True: 337 current_values = option_set.current_values 338 if len(current_values) <= 0: 339 break 340 341 # First try splitting the set in half 342 if len(current_values) > 3: 343 mid = len(current_values) // 2 344 halves = [current_values[:mid], current_values[mid:]] 345 346 if any(self._try_option_values(option_set, half) for half in halves): 347 continue 348 349 # Then try removing individual elements 350 random.shuffle(current_values) 351 for i in range(len(current_values)): 352 reduced_values = current_values[:i] + current_values[i + 1:] 353 if self._try_option_values(option_set, reduced_values): 354 break 355 else: 356 break # No further reduction possible 357 358 print_color( 359 Colors.OKGREEN + Colors.BOLD, 360 f'Minimal {option_name} set: {option_set.current_values}' 361 ) 362 363 def _try_option_values(self, option_set: OptionSet, new_values: List[str]) -> bool: 364 """Temporarily try new values for an option set. 365 366 Args: 367 option_set: The option set to modify 368 new_values: New values to try 369 370 Returns: 371 bool: True if the new values should be kept 372 """ 373 print_color(Colors.OKCYAN, f'Testing {self.current_option}={new_values}') 374 old_values = option_set.current_values 375 option_set.current_values = new_values 376 should_keep = self.run_with_options() 377 378 if not should_keep: 379 option_set.current_values = old_values 380 return should_keep 381 382 383def parse_methods() -> Tuple[Set[str], Set[str]]: 384 """Parse compiled and inlined methods from IR dumps""" 385 compiled = set() 386 inlined = set() 387 388 for dump in glob.glob('ir_dump/*IrBuilder.ir'): 389 try: 390 method = re.match(r'ir_dump/\d+_pass_\d+_(.*)_IrBuilder.ir', dump).group(1) 391 except Exception as e: 392 raise RuntimeError(f"Failed to parse IR dump {dump}: {e}") 393 parts = re.split('(?<!_)_(?!_*$)', method) 394 395 if parts[-1] in ['_ctor_', '_cctor_']: 396 parts[-1] = f'<{parts[-1].strip("_")}>' 397 398 dest = compiled 399 if parts[0] == 'inlined': 400 dest = inlined 401 parts.pop(0) 402 403 qname = '::'.join(('.'.join(parts[:-1]), parts[-1])) 404 dest.add(qname) 405 return compiled, inlined 406 407 408def print_color(color: str, *args, **kwargs) -> None: 409 """Print colored text to terminal with proper ANSI code handling. 410 411 Args: 412 color: ANSI color code from Colors class 413 *args: Positional arguments to print 414 **kwargs: Keyword arguments for print() 415 416 Example: 417 print_color(Colors.OKGREEN, "Success!", file=sys.stderr) 418 """ 419 print(color, end='') 420 print(*args, **kwargs) 421 print(Colors.ENDC, end='') 422 423 424def exit_with_error(message: str = '', usage: bool = False) -> None: 425 """Exit with colored error message and optional usage help. 426 427 Args: 428 message: Error message to display 429 usage: Whether to show usage information 430 """ 431 buffer = [] 432 if message: 433 buffer.append(f"{Colors.FAIL}{message}") 434 if usage: 435 buffer.extend([ 436 f"{Colors.FAIL}Usage: {sys.argv[0]} COMMAND [ARG]...", 437 f"COMMAND or ARGS should use $GEN_OPTIONS variable.", 438 f"{sys.argv[0]} -h for help" 439 ]) 440 print('\n'.join(buffer), file=sys.stderr) 441 sys.exit(1) 442 443 444def main(): 445 if len(sys.argv) < 2: 446 exit_with_error(usage=True) 447 if sys.argv[1] in ('-h', '--help'): 448 print(HELP_TEXT) 449 sys.exit(0) 450 451 # Initialize components 452 runner = CommandRunner(sys.argv[1:]) 453 bisector = BisectionEngine(runner) 454 455 # Verify baseline cases 456 print_color(Colors.OKCYAN, 'Running without compiled methods') 457 if runner.run_test([], [], [], expect_fail=False).returncode != 0: 458 exit_with_error("Script failed without compiled methods") 459 460 print_color(Colors.OKCYAN, 'Running with all methods compiled and optimizations enabled') 461 if runner.run_test(None, [], None, dump=True, expect_fail=True).returncode == 0: 462 exit_with_error("Script didn't fail with default args") 463 464 compiled, _ = parse_methods() 465 bisector.initialize_options(compiled, set(), PassConfig.get_pass_list()) 466 467 # Perform bisection on each option type 468 bisector.bisect_option('compiled') 469 470 runner.run_test(bisector.opts.compiled.current_values, [], None, dump=True) 471 _, inlined = parse_methods() 472 bisector.set_option('inline', OptionSet(inlined)) 473 bisector.bisect_option('inline') 474 bisector.bisect_option('passes') 475 476 # Print final results 477 print_color(Colors.OKGREEN + Colors.BOLD, 'Found minimal failing configuration:') 478 if bisector.run_with_options(verbose=True): 479 print_color(Colors.OKGREEN + Colors.BOLD, 'Options:') 480 for name, opt in bisector.opts._asdict().items(): 481 print_color(Colors.OKGREEN + Colors.BOLD, f" {name}: {opt.current_values} ") 482 else: 483 exit_with_error("Didn't fail with found options") 484 485 486if __name__ == "__main__": 487 main() 488