| #!/usr/bin/env python3 | |
| # Copyright 2021 The IREE Authors | |
| # | |
| # Licensed under the Apache License v2.0 with LLVM Exceptions. | |
| # See https://llvm.org/LICENSE.txt for license information. | |
| # SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception | |
| """Posts benchmark results to GitHub as pull request comments. | |
| This script is meant to be used by Buildkite for automation. It requires the | |
| following environment to be set: | |
| - BUILDKITE_BUILD_NUMBER: the build number of current Buildkite build. | |
| - BUILDKITE_BUILD_URL: the link to the current Buildkite build. | |
| - BUILDKITE_COMMIT: the pull request HEAD commit. | |
| - BUILDKITE_PULL_REQUEST: the current pull request number. | |
| - GITHUB_TOKEN: personal access token to authenticate against GitHub API; | |
| it should have "public_repo" and "gist" scope. | |
| if --query-base in toggled on, then it additionally requires: | |
| - BUILDKITE_PULL_REQUEST_BASE_BRANCH: the targeting base branch. | |
| - IREE_DASHBOARD_URL: the url to IREE's performance dashboard. | |
| This script uses pip package "markdown_strings". | |
| Example usage: | |
| # Export necessary environment variables: | |
| export ... | |
| # Then run the script: | |
| python3 post_benchmarks_as_pr_comment.py <benchmark-json-file>... | |
| # where each <benchmark-json-file> is expected to be of format expected | |
| # by BenchmarkResults objects. | |
| """ | |
| import argparse | |
| import json | |
| import os | |
| import requests | |
| import markdown_strings as md | |
| from dataclasses import dataclass | |
| from typing import Any, Dict, Optional, Sequence, Tuple, Union | |
| from common.benchmark_definition import execute_cmd_and_get_output | |
| from common.benchmark_presentation import * | |
| ABBR_PR_COMMENT_TITLE = "Abbreviated Benchmark Summary" | |
| GITHUB_GIST_API_PREFIX = "https://api.github.com/gists" | |
| GITHUB_IREE_API_PREFIX = "https://api.github.com/repos/google/iree" | |
| GITHUB_IREE_REPO_PREFIX = "https://github.com/google/iree" | |
| GITHUB_USER = "iree-github-actions-bot" | |
| IREE_PROJECT_ID = 'IREE' | |
| # The maximal numbers of trials when querying base commit benchmark results. | |
| MAX_BASE_COMMIT_QUERY_COUNT = 10 | |
| # The max number of rows to show per table. | |
| TABLE_SIZE_CUT = 3 | |
| THIS_DIRECTORY = os.path.dirname(os.path.realpath(__file__)) | |
| def get_required_env_var(var: str) -> str: | |
| """Gets the value for a required environment variable.""" | |
| value = os.getenv(var, None) | |
| if value is None: | |
| raise RuntimeError(f'Missing environment variable "{var}"') | |
| return value | |
| def get_git_commit_hash(commit: str, verbose: bool = False) -> str: | |
| """Gets the commit hash for the given commit.""" | |
| return execute_cmd_and_get_output(['git', 'rev-parse', commit], | |
| cwd=THIS_DIRECTORY, | |
| verbose=verbose) | |
| def get_git_total_commit_count(commit: str, verbose: bool = False) -> int: | |
| """Gets the total commit count in history ending with the given commit.""" | |
| count = execute_cmd_and_get_output(['git', 'rev-list', '--count', commit], | |
| cwd=THIS_DIRECTORY, | |
| verbose=verbose) | |
| return int(count) | |
| def get_origin_tree_commit(distance: int, verbose: bool = False) -> str: | |
| """Returns the hash for the commit with the given distance from top of the | |
| tree for the origin base branch.""" | |
| base_branch = get_required_env_var("BUILDKITE_PULL_REQUEST_BASE_BRANCH") | |
| execute_cmd_and_get_output( | |
| ['git', 'fetch', '--prune', '--', 'origin', base_branch], | |
| cwd=THIS_DIRECTORY, | |
| verbose=verbose) | |
| return get_git_commit_hash(f'origin/{base_branch}~{distance}', verbose) | |
| def get_from_dashboard(url: str, | |
| payload: Dict[str, Any], | |
| verbose: bool = False) -> Dict[str, int]: | |
| headers = {'Content-type': 'application/json'} | |
| data = json.dumps(payload) | |
| if verbose: | |
| print(f'API request payload: {data}') | |
| response = requests.get(url, data=data, headers=headers) | |
| code = response.status_code | |
| if code != 200: | |
| raise requests.RequestException( | |
| f'Failed to get from dashboard server with status code {code}') | |
| data = response.json() | |
| if verbose: | |
| print(f'Queried base benchmark data: {data}') | |
| return data | |
| def query_base_benchmark_results(commit, | |
| verbose: bool = False) -> Dict[str, int]: | |
| """Queries the benchmark results for the given commit.""" | |
| build_id = get_git_total_commit_count(commit, verbose) | |
| url = get_required_env_var('IREE_DASHBOARD_URL') | |
| payload = {'projectId': IREE_PROJECT_ID, 'buildId': build_id} | |
| return get_from_dashboard(f'{url}/apis/getBuild', payload, verbose=verbose) | |
| def get_benchmark_result_markdown(benchmark_files: Sequence[str], | |
| query_base: bool, | |
| verbose: bool = False) -> Tuple[str, str]: | |
| """Gets the full/abbreviated markdown summary of all benchmarks in files.""" | |
| pr_commit = get_required_env_var("BUILDKITE_COMMIT") | |
| all_benchmarks = aggregate_all_benchmarks(benchmark_files, | |
| pr_commit, | |
| verbose=verbose) | |
| build_url = get_required_env_var("BUILDKITE_BUILD_URL") | |
| pr_number = get_required_env_var("BUILDKITE_PULL_REQUEST") | |
| pr_commit = md.link(pr_commit, | |
| f"{GITHUB_IREE_REPO_PREFIX}/commit/{pr_commit}") | |
| commit_info = f"@ commit {pr_commit}" | |
| if query_base: | |
| # Try to query some base benchmark to diff against, from the top of the | |
| # tree. Bail out if the maximal trial number is exceeded. | |
| for i in range(MAX_BASE_COMMIT_QUERY_COUNT): | |
| base_commit = get_origin_tree_commit(i, verbose) | |
| base_benchmarks = query_base_benchmark_results(base_commit, verbose) | |
| base_commit = md.link(base_commit, | |
| f"{GITHUB_IREE_REPO_PREFIX}/commit/{base_commit}") | |
| if len(base_benchmarks) == 0: | |
| commit_info = (f"@ commit {pr_commit} (no previous benchmark results to" | |
| f" compare against since {base_commit})") | |
| continue | |
| # Update the aggregate benchmarks with base numbers. | |
| for bench in base_benchmarks: | |
| if bench in all_benchmarks: | |
| all_benchmarks[bench].base_mean_time = base_benchmarks[bench] | |
| commit_info = f"@ commit {pr_commit} (vs. base {base_commit})" | |
| break | |
| pr_info = md.link("Pull request", | |
| f"{GITHUB_IREE_REPO_PREFIX}/pull/{pr_number}") | |
| buildkite_info = md.link("Buildkite build", build_url) | |
| # Compose the full benchmark tables. | |
| full_table = [md.header("Full Benchmark Summary", 2)] | |
| full_table.append(md.unordered_list([commit_info, pr_info, buildkite_info])) | |
| full_table.append(categorize_benchmarks_into_tables(all_benchmarks)) | |
| # Compose the abbreviated benchmark tables. | |
| abbr_table = [md.header(ABBR_PR_COMMENT_TITLE, 2)] | |
| abbr_table.append(commit_info) | |
| tables = categorize_benchmarks_into_tables(all_benchmarks, TABLE_SIZE_CUT) | |
| if len(tables) == 0: | |
| abbr_table.append("No improved or regressed benchmarks 🏖️") | |
| else: | |
| abbr_table.append(tables) | |
| abbr_table.append("For more information:") | |
| # We don't know until a Gist is really created. Use a placeholder for now | |
| # and replace later. | |
| full_result_info = md.link("Full benchmark result tables", | |
| "<<placeholder-link>>") | |
| abbr_table.append(md.unordered_list([full_result_info, buildkite_info])) | |
| return "\n\n".join(full_table), "\n\n".join(abbr_table) | |
| def post_to_gist(filename: str, content: str, verbose: bool = False): | |
| """Posts the given content to a new GitHub Gist and returns the URL to it.""" | |
| api_token = get_required_env_var('GITHUB_TOKEN') | |
| headers = { | |
| "Accept": "application/vnd.github.v3+json", | |
| "Authorization": f"token {api_token}", | |
| } | |
| payload = json.dumps({ | |
| "public": True, | |
| "files": { | |
| filename: { | |
| "content": content | |
| } | |
| } | |
| }) | |
| api_endpoint = GITHUB_GIST_API_PREFIX | |
| response = requests.post(api_endpoint, data=payload, headers=headers) | |
| if response.status_code != 201: | |
| raise requests.RequestException( | |
| f"Failed to comment on GitHub; error code: {response.status_code}") | |
| response = response.json() | |
| if verbose: | |
| print(f"Gist posting response: {response}") | |
| if response["truncated"]: | |
| raise requests.RequestException(f"Content too large and gotten truncated") | |
| gist_id = response["id"] | |
| return f"https://gist.github.com/{GITHUB_USER}/{gist_id}" | |
| def get_previous_comment_on_pr(pr_number: str, | |
| verbose: bool = False) -> Optional[int]: | |
| """Gets the previous comment's ID from GitHub.""" | |
| # Increasing per_page limit requires user authentication. | |
| api_token = get_required_env_var('GITHUB_TOKEN') | |
| headers = { | |
| "Accept": "application/vnd.github.v3+json", | |
| "Authorization": f"token {api_token}", | |
| } | |
| payload = json.dumps({"per_page": 100}) | |
| api_endpoint = f"{GITHUB_IREE_API_PREFIX}/issues/{pr_number}/comments" | |
| response = requests.get(api_endpoint, data=payload, headers=headers) | |
| if response.status_code != 200: | |
| raise requests.RequestException( | |
| f"Failed to get PR comments from GitHub; error code: {response.status_code}" | |
| ) | |
| response = response.json() | |
| if verbose: | |
| print(f"Previous comment query response: {response}") | |
| # Find the last comment from GITHUB_USER and has the ABBR_PR_COMMENT_TITILE | |
| # keyword. | |
| for comment in reversed(response): | |
| if (comment["user"]["login"] == GITHUB_USER) and (ABBR_PR_COMMENT_TITLE | |
| in comment["body"]): | |
| return comment["id"] | |
| return None | |
| def create_comment_on_pr(pr_number: str, content: str, verbose: bool = False): | |
| """Posts the given content as comments to the current pull request.""" | |
| api_token = get_required_env_var('GITHUB_TOKEN') | |
| headers = { | |
| "Accept": "application/vnd.github.v3+json", | |
| "Authorization": f"token {api_token}", | |
| } | |
| payload = json.dumps({"body": content}) | |
| api_endpoint = f"{GITHUB_IREE_API_PREFIX}/issues/{pr_number}/comments" | |
| response = requests.post(api_endpoint, data=payload, headers=headers) | |
| if response.status_code != 201: | |
| raise requests.RequestException( | |
| f"Failed to comment on GitHub; error code: {response.status_code}") | |
| def update_comment_on_pr(comment_id: int, content: str, verbose: bool = False): | |
| """Updates the content of the given comment.""" | |
| api_token = get_required_env_var('GITHUB_TOKEN') | |
| headers = { | |
| "Accept": "application/vnd.github.v3+json", | |
| "Authorization": f"token {api_token}", | |
| } | |
| payload = json.dumps({"body": content}) | |
| api_endpoint = f"{GITHUB_IREE_API_PREFIX}/issues/comments/{comment_id}" | |
| response = requests.patch(api_endpoint, data=payload, headers=headers) | |
| if response.status_code != 200: | |
| raise requests.RequestException( | |
| f"Failed to comment on GitHub; error code: {response.status_code}") | |
| def parse_arguments(): | |
| """Parses command-line options.""" | |
| def check_file_path(path): | |
| if os.path.isfile(path): | |
| return path | |
| else: | |
| raise ValueError(path) | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("benchmark_files", | |
| metavar="<benchmark-json-file>", | |
| type=check_file_path, | |
| nargs="+", | |
| help="Path to the JSON file containing benchmark results") | |
| parser.add_argument("--dry-run", | |
| action="store_true", | |
| help="Print the comment instead of posting to GitHub") | |
| parser.add_argument( | |
| "--query-base", | |
| action="store_true", | |
| help= | |
| "Query the dashboard for the benchmark results of the targeting base branch" | |
| ) | |
| parser.add_argument("--verbose", | |
| action="store_true", | |
| help="Print internal information during execution") | |
| args = parser.parse_args() | |
| return args | |
| def main(args): | |
| full_md, abbr_md = get_benchmark_result_markdown(args.benchmark_files, | |
| query_base=args.query_base, | |
| verbose=args.verbose) | |
| if args.dry_run: | |
| print(full_md, "\n\n", abbr_md) | |
| return | |
| pr_number = get_required_env_var("BUILDKITE_PULL_REQUEST") | |
| # Buildkite sets this to "false" if not running on a PR: | |
| # https://buildkite.com/docs/pipelines/environment-variables#bk-env-vars-buildkite-pull-request | |
| if pr_number == "false": | |
| raise ValueError("Not a pull request") | |
| build_number = get_required_env_var("BUILDKITE_BUILD_NUMBER") | |
| filename = f"iree-full-benchmark-result-{build_number}.md" | |
| gist_url = post_to_gist(filename, full_md, args.verbose) | |
| abbr_md = abbr_md.replace("<<placeholder-link>>", gist_url) | |
| previous_comment = get_previous_comment_on_pr(pr_number, args.verbose) | |
| if previous_comment is not None: | |
| update_comment_on_pr(previous_comment, abbr_md, args.verbose) | |
| else: | |
| create_comment_on_pr(pr_number, abbr_md, args.verbose) | |
| if __name__ == "__main__": | |
| main(parse_arguments()) |