| # Copyright lowRISC contributors. | |
| # Licensed under the Apache License, Version 2.0, see LICENSE for details. | |
| # SPDX-License-Identifier: Apache-2.0 | |
| r"""Efficiently bisect FPGA regressions with the bitstream cache. | |
| This tool bisects while minimizing the number of calls to the slow command. | |
| Under the hood, it actually runs up to two git bisects. The first bisect only | |
| tests commits that have cached bitstreams. The optional second bisect narrows | |
| down ambiguous results. (For more information on bisecting, see `man 1 | |
| git-bisect` or <https://git-scm.com/docs/git-bisect>.) | |
| In the first phase, the tool classifies commits as good/bad/skip with the | |
| user-provided `--fast-command`. In the second phase, it uses `--slow-command`. | |
| These commands classify the current commit via their exit status. Exiting with 0 | |
| indicates that the commit is good. Exiting with 125 indicates the commit cannot | |
| be tested. Exiting with any other status indicates that the commit is bad. | |
| Run bitstream_bisect.py with Bazel like so: | |
| ./bazelisk.sh run //util/fpga:bitstream_bisect -- \ | |
| --good HEAD~30 \ | |
| --bad HEAD \ | |
| --fast-command "./bazelisk.sh test //sw/device/tests:uart_smoketest_fpga_cw310_rom" \ | |
| --slow-command "./bazelisk.sh test --define bitstream=vivado \ | |
| //sw/device/tests:uart_smoketest_fpga_cw310_rom" | |
| """ | |
| import abc | |
| import enum | |
| import os | |
| import re | |
| import shlex | |
| import string | |
| import subprocess | |
| from typing import Dict, List, Optional, Set, Tuple | |
| import rich | |
| import rich.console | |
| import typer | |
| from rules.scripts.bitstreams_workspace import BitstreamCache | |
| class CommitHash(str): | |
| """A string representation of a Git commit hash. | |
| The given string must contain only hex digits. It is canonicalized to | |
| lowercase, but not canonicalized relative to the current git repository. As | |
| such, two CommitHash objects may fail to compare equal even though git would | |
| say they refer to the same commit. | |
| """ | |
| def __new__(cls, s): | |
| t = super().__new__(cls, s).lower() | |
| if len(t) == 0 or len(set(t) - set(string.hexdigits)) > 0: | |
| raise Exception("Invalid CommitHash input: '{}'".format(t)) | |
| return t | |
| class CommitJudgment(enum.Enum): | |
| GOOD = enum.auto() | |
| BAD = enum.auto() | |
| SKIP = enum.auto() | |
| class BisectLog: | |
| """A container and parser for the results of `git bisect log`. | |
| """ | |
| class ParserBug(Exception): | |
| pass | |
| def __init__(self, log: str): | |
| """Parse `log` and initialize the object.""" | |
| lines = [s.rstrip() for s in log.splitlines()] | |
| self.judgments = BisectLog._extract_judgments(lines) | |
| self.candidates = BisectLog._extract_candidates(lines) | |
| @staticmethod | |
| def _extract_judgments( | |
| lines: List[str]) -> List[Tuple[CommitHash, CommitJudgment]]: | |
| out = [] | |
| for line in lines: | |
| if line == '' or line.startswith("#"): | |
| continue | |
| if line == 'git bisect start': | |
| continue | |
| match = re.match("^git bisect (good|bad|skip) ([0-9a-f]+)$", line) | |
| if match is None: | |
| raise BisectLog.ParserBug( | |
| "Failed to parse line: '{}'".format(line)) | |
| assert len(match.groups()) == 2 | |
| judgment, commit = match.groups() | |
| if judgment == 'good': | |
| out.append((CommitHash(commit), CommitJudgment.GOOD)) | |
| elif judgment == 'bad': | |
| out.append((CommitHash(commit), CommitJudgment.BAD)) | |
| elif judgment == 'skip': | |
| out.append((CommitHash(commit), CommitJudgment.SKIP)) | |
| else: | |
| raise BisectLog.ParserBug("Unreachable") | |
| return out | |
| @staticmethod | |
| def _extract_candidates(lines: List[str]) -> List[CommitHash]: | |
| # Scan through the log to find a line indicating that there is a single | |
| # first bad commit. | |
| re_first_bad = re.compile(r"^# first bad commit: \[([0-9a-f]+)\] .*$") | |
| for i, line in enumerate(lines): | |
| match = re_first_bad.match(line) | |
| if match is None: | |
| continue | |
| assert len(match.groups()) == 1 | |
| first_bad = match.group(1) | |
| return [CommitHash(first_bad)] | |
| # Scan through the log to find a sequence of possible first bad commits. | |
| LOG_LINE_ONLY_SKIPPED = "# only skipped commits left to test" | |
| candidate_lines = [] | |
| for i, line in reversed(list(enumerate(lines))): | |
| # When only skipped commits remain, expect a sequence of "possible | |
| # first bad commit" lines. | |
| if line == LOG_LINE_ONLY_SKIPPED: | |
| candidate_lines = lines[i + 1:] | |
| break | |
| re_possible_first_bad = re.compile( | |
| r"^# possible first bad commit: \[([0-9a-f]+)\] .*$") | |
| candidates = [] | |
| for line in candidate_lines: | |
| match = re_possible_first_bad.match(line) | |
| if match is None: | |
| continue | |
| assert len(match.groups()) == 1 | |
| commit = match.group(1) | |
| candidates.append(CommitHash(commit)) | |
| return candidates | |
| class GitControllerABC(abc.ABC): | |
| """An abstract Git interface that will never run the system `git`.""" | |
| @abc.abstractmethod | |
| def _git(self, *args: str) -> Tuple[int, str, str]: | |
| """Runs git with `args`. Returns (returncode, stdout, stderr).""" | |
| pass | |
| @abc.abstractmethod | |
| def _git_no_capture_output(self, *args: str) -> int: | |
| """Like `_git()`, but doesn't capture stdout/stderr.""" | |
| pass | |
| def _git_checked(self, *args: str) -> Tuple[str, str]: | |
| """Like _git(), but raises an exception when the returncode != 0.""" | |
| returncode, stdout, stderr = self._git(*args) | |
| if returncode != 0: | |
| lines = [ | |
| "Git exited with {}".format(returncode), | |
| "---stdout---", | |
| stdout, | |
| "---stderr---", | |
| stderr, | |
| ] | |
| raise Exception("\n".join(lines)) | |
| return (stdout, stderr) | |
| def rev_parse(self, commitish: str) -> CommitHash: | |
| stdout, _ = self._git_checked("rev-parse", commitish) | |
| return CommitHash(stdout.rstrip()) | |
| def bisect_start(self): | |
| self._git_checked("bisect", "start") | |
| def bisect_reset(self): | |
| self._git_checked("bisect", "reset") | |
| def bisect_good(self, rev: CommitHash): | |
| self._git_checked("bisect", "good", rev) | |
| def bisect_bad(self, rev: CommitHash): | |
| self._git_checked("bisect", "bad", rev) | |
| def bisect_skip(self, *revs: CommitHash): | |
| self._git_checked("bisect", "skip", *revs) | |
| def bisect_run(self, *args: str) -> BisectLog: | |
| """Executes `git bisect run` without capturing stdout/stderr.""" | |
| # Git's exit status is non-zero when bisect results are ambiguous. | |
| _ = self._git_no_capture_output("bisect", "run", *args) | |
| return self.bisect_log() | |
| def bisect_log(self) -> BisectLog: | |
| stdout, _ = self._git_checked("bisect", "log") | |
| return BisectLog(stdout) | |
| def bisect_view(self) -> List[Tuple[CommitHash, str]]: | |
| stdout, _ = self._git_checked("bisect", "view", "--oneline", | |
| "--no-abbrev-commit") | |
| out = [] | |
| for line in stdout.splitlines(): | |
| commit, tail = line.split(" ", 1) | |
| out.append((CommitHash(commit), tail)) | |
| return out | |
| class GitController(GitControllerABC): | |
| """A concrete git controller that actually executes git commands.""" | |
| def __init__(self, git_binary: str, verbose: bool): | |
| self._git_binary = git_binary | |
| self._verbose = verbose | |
| self._console = rich.console.Console() | |
| self._env: Dict[str, str] = dict(os.environ) | |
| # Prevent `git bisect view` from launching the `gitk` GUI. | |
| self._env.pop("DISPLAY", None) | |
| def _git(self, *args: str) -> Tuple[int, str, str]: | |
| command = [self._git_binary] + list(args) | |
| proc = subprocess.run(command, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| env=self._env, | |
| encoding="utf-8") | |
| if self._verbose or proc.returncode != 0: | |
| print() | |
| command_str = " ".join(command) | |
| rich.print( | |
| "Command [green]{}[/green] exited with status {}".format( | |
| rich.markup.escape(command_str), proc.returncode)) | |
| rich.print( | |
| rich.panel.Panel(rich.markup.escape(proc.stdout), | |
| title="git stdout")) | |
| rich.print( | |
| rich.panel.Panel(rich.markup.escape(proc.stderr), | |
| title="git stderr")) | |
| return (proc.returncode, proc.stdout, proc.stderr) | |
| def _git_no_capture_output(self, *args: str) -> int: | |
| command = [self._git_binary] + list(args) | |
| command_str = " ".join(command) | |
| command_str = rich.markup.escape(command_str) | |
| self._console.rule( | |
| "Streaming output from [bold]{}".format(command_str)) | |
| proc = subprocess.run(["git"] + list(args), env=self._env) | |
| self._console.rule("Git exited with status {}".format(proc.returncode)) | |
| self._console.print() | |
| return proc.returncode | |
| class BisectSession: | |
| def __init__(self, | |
| git: GitControllerABC, | |
| cache_keys: Set[CommitHash], | |
| visualize: bool = False): | |
| self._git = git | |
| self._cache_keys: Set[CommitHash] = cache_keys | |
| self._console = rich.console.Console() | |
| self._visualize = visualize | |
| def run(self, | |
| good_commit: CommitHash, | |
| bad_commit: CommitHash, | |
| fast_command: List[str], | |
| slow_command: Optional[List[str]] = None) -> BisectLog: | |
| """Run an unattended bisect session in one or two phases. | |
| This method is conceptually equivalent to `git bisect run`. Unlike plain | |
| `git bisect`, it runs in two phases. The first phase only operates on | |
| cached bitstreams. This will only get us so far, so it optionally can | |
| fall back to a slower command, e.g. one that builds the bitstreams it | |
| tests. | |
| Args: | |
| good_commit: | |
| Commit where `fast_command` and `slow_command` are known to succeed. | |
| bad_commit: | |
| Commit where `fast_command` and `slow_command` are known to fail. | |
| fast_command: | |
| A list of strings that represent a git-bisect script and args. These | |
| are passed to `git bisect run` in the first phase. | |
| slow_command: | |
| Just like `fast_command`, but for the second phase. If this arg is | |
| None, we skip the second phase. | |
| Returns: | |
| Parsed output from `git bisect log`, run after the final bisect phase. | |
| """ | |
| self._git.bisect_start() | |
| self._git.bisect_good(good_commit) | |
| self._git.bisect_bad(bad_commit) | |
| # Skip commits that aren't in the bitstream cache. | |
| uncached_commits = [c for c, _ in self._git.bisect_view() if c not in self._cache_keys] | |
| if uncached_commits != []: | |
| self._git.bisect_skip(*uncached_commits) | |
| self._maybe_visualize("Only testing commits with cached bitstreams") | |
| bisect_log = self._git.bisect_run(*fast_command) | |
| # Skip the second phase when results are unambiguous. | |
| if len(bisect_log.candidates) == 1: | |
| self._maybe_visualize("Final results") | |
| self._git.bisect_reset() | |
| return bisect_log | |
| # Prepare a new git bisect session where we will run the slow script | |
| # without the benefit of the bitstream cache. Replay all the "good" and | |
| # "bad" commands, but none of the "skip" commands. | |
| if slow_command is None: | |
| self._git.bisect_reset() | |
| return bisect_log | |
| self._git.bisect_reset() | |
| self._git.bisect_start() | |
| # Replay only "good" and "bad" decisions from the previous phase. It's | |
| # tempting to unconditionally skip cached commits, but it's better to | |
| # simply reuse judgments from the previous phase. It's possible that the | |
| # user-supplied `fast_command` chose to skip some cached commits for | |
| # reasons outside the scope of this tool, and we now need to test them | |
| # in the second phase. | |
| for commit, judgment in bisect_log.judgments: | |
| if judgment == CommitJudgment.GOOD: | |
| self._git.bisect_good(commit) | |
| elif judgment == CommitJudgment.BAD: | |
| self._git.bisect_bad(commit) | |
| self._maybe_visualize("Narrowing down ambiguous results") | |
| bisect_log = self._git.bisect_run(*slow_command) | |
| self._maybe_visualize("Final results") | |
| self._git.bisect_reset() | |
| return bisect_log | |
| def _maybe_visualize(self, label: str): | |
| """Prints a table summarizing the current range of commits.""" | |
| if not self._visualize: | |
| return | |
| table = rich.table.Table( | |
| title="Bitstream Bisect Status: [bold]{}".format(label)) | |
| table.add_column("Commit") | |
| table.add_column("Description") | |
| table.add_column("Cached") | |
| table.add_column("Judgment") | |
| bisect_log = self._git.bisect_log() | |
| judgment_dict: Dict[CommitHash, | |
| CommitJudgment] = dict(bisect_log.judgments) | |
| for commit, description in self._git.bisect_view(): | |
| judgment = judgment_dict.get(commit) | |
| table.add_row(commit, rich.markup.escape(description), | |
| "Cached" if commit in self._cache_keys else "", | |
| judgment.name if judgment else "") | |
| self._console.print(table) | |
| app = typer.Typer(pretty_exceptions_enable=False, | |
| rich_markup_mode="rich", | |
| add_completion=False) | |
| @app.command(help=__doc__) | |
| def main( | |
| good: str = typer.Option(..., help="Commit-ish for known-good revision."), | |
| bad: str = typer.Option(..., help="Commit-ish for known-bad revision."), | |
| fast_command: str = typer.Option( | |
| ..., help="Command for testing commits with cached bitstreams."), | |
| slow_command: Optional[str] = typer.Option( | |
| None, help="Command that tests remaining commits in second phase."), | |
| git_binary: str = typer.Option("/usr/bin/git", help="Path to Git binary."), | |
| verbose: bool = typer.Option( | |
| False, help="Log stdout/stderr of every git command."), | |
| ): | |
| # Escape the Bazel sandbox. Without this step, git would not find the .git/ | |
| # directory. Note that this works nicely with git worktrees. | |
| workspace_dir = os.environ['BUILD_WORKSPACE_DIRECTORY'] | |
| os.chdir(workspace_dir) | |
| # Create a Git controller capable of invoking the system's `git` binary. Use | |
| # it to canonicalize potentially-symbolic "commitish" strings so we don't | |
| # misinterpret them when a different commit is checked out. | |
| git = GitController(git_binary, verbose) | |
| good_commit = git.rev_parse(good) | |
| bad_commit = git.rev_parse(bad) | |
| print("Canonicalized good revision:", good_commit) | |
| print("Canonicalized bad revision: ", bad_commit) | |
| # NOTE: This will generate network traffic! | |
| # Fetch a listing of the current bitstream cache. | |
| bitstream_cache = BitstreamCache.MakeWithDefaults() | |
| bitstream_cache.GetBitstreamsAvailable(refresh=True) | |
| bitstream_cache_keys = set(bitstream_cache.available.keys()) | |
| fast_command_pieces = shlex.split(fast_command) | |
| slow_command_pieces = shlex.split( | |
| slow_command) if slow_command is not None else None | |
| session = BisectSession(git, bitstream_cache_keys, visualize=True) | |
| session.run(good_commit, | |
| bad_commit, | |
| fast_command_pieces, | |
| slow_command=slow_command_pieces) | |
| return 0 | |
| if __name__ == '__main__': | |
| app() |