• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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