blob: 61314eb483532ac447475d7e766c566f4cae9da0 [file] [log] [blame]
#!/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_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.
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 typing import Any, Dict, Sequence, Tuple, Union
from common.benchmark_description import BenchmarkResults, get_output
GITHUB_IREE_API_PREFIX = "https://api.github.com/repos/google/iree"
IREE_PROJECT_ID = 'IREE'
THIS_DIRECTORY = os.path.dirname(os.path.realpath(__file__))
RESULT_EMPHASIS_THRESHOLD = 0.05
def get_git_commit_hash(commit: str, verbose: bool = False) -> str:
"""Gets the commit hash for the given commit."""
return 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 = get_output(['git', 'rev-list', '--count', commit],
cwd=THIS_DIRECTORY,
verbose=verbose)
return int(count)
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_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}')
return response.json()
def aggregate_all_benchmarks(
benchmark_files: Sequence[str]) -> Sequence[Tuple[Union[str, int]]]:
"""Aggregates all benchmarks in the given files.
Args:
- benchmark_files: A list of JSON files, each can be decoded as a
BenchmarkResults.
Returns:
- A list of (name, mean-latency, median-latency, stddev-latency) tuples.
"""
pr_commit = get_required_env_var("BUILDKITE_COMMIT")
aggregate_results = {}
for benchmark_file in benchmark_files:
with open(benchmark_file) as f:
content = f.read()
file_results = BenchmarkResults.from_json_str(content)
if file_results.commit != pr_commit:
raise ValueError("Inconsistent pull request commit")
for benchmark_index in range(len(file_results.benchmarks)):
benchmark_case = file_results.benchmarks[benchmark_index]
# Make sure each benchmark has a unique name.
name = str(benchmark_case["benchmark"])
if name in aggregate_results:
raise ValueError(f"Duplicated benchmarks: {name}")
# Now scan all benchmark iterations and find the aggregate results.
mean_time = file_results.get_aggregate_time(benchmark_index, "mean")
median_time = file_results.get_aggregate_time(benchmark_index, "median")
stddev_time = file_results.get_aggregate_time(benchmark_index, "stddev")
aggregate_results[name] = (mean_time, median_time, stddev_time)
return sorted([(k,) + v for k, v in aggregate_results.items()])
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_comparsion_against_base(pr_means: Sequence[int],
base_means: Sequence[int]) -> Sequence[str]:
"""Returns a tuple of strings comparsing mean latency numbers."""
comparisions = []
for pr, base in zip(pr_means, base_means):
if base is None:
comparisions.append(str(pr))
continue
diff = abs(pr - base) / base
if pr > base:
percent = "{:.2%}".format(diff)
direction = "↑"
if diff > RESULT_EMPHASIS_THRESHOLD:
direction += ", 🚩"
elif pr < base:
percent = "{:.2%}".format(diff)
direction = "↓"
if diff > RESULT_EMPHASIS_THRESHOLD:
direction += ", 🎉"
else:
percent = "{:.0%}".format(diff)
direction = ""
comparisions.append(f"{pr} (vs. {base}, {percent}{direction})")
return tuple(comparisions)
def get_benchmark_result_markdown(benchmark_files: Sequence[str],
query_base: bool,
verbose: bool = False) -> str:
"""Gets markdown summary of all benchmarks in the given files."""
all_benchmarks = aggregate_all_benchmarks(benchmark_files)
names, means, medians, stddevs = zip(*all_benchmarks)
build_url = get_required_env_var("BUILDKITE_BUILD_URL")
pr_commit = get_required_env_var("BUILDKITE_COMMIT")
if query_base:
base_branch = get_required_env_var("BUILDKITE_PULL_REQUEST_BASE_BRANCH")
commit = get_git_commit_hash(base_branch, verbose)
base_benchmarks = query_base_benchmark_results(commit, verbose)
base_means = [base_benchmarks.get(v) for v in names]
means = get_comparsion_against_base(means, base_means)
commit_info = f"@ commit {pr_commit} (vs. base {commit})"
else:
commit_info = f"@ commit {pr_commit}"
names = ("Benchmark Name",) + names
means = ("Average Latency (ms)",) + means
medians = ("Median Latency (ms)",) + medians
stddevs = ("Latency Standard Deviation (ms)",) + stddevs
header = md.header("Benchmark results", 3)
benchmark_table = md.table([names, means, medians, stddevs])
link = "See more details on " + md.link("Buildkite", build_url)
return "\n\n".join([header, commit_info, benchmark_table, link])
def comment_on_pr(content):
"""Posts the given content as comments to the current pull request."""
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")
api_token = get_required_env_var('GITHUB_TOKEN')
headers = {
"Accept": "application/vnd.github.v3+json",
"Authorization": f"token {api_token}",
}
payload = json.dumps({"event": "COMMENT", "body": content})
api_endpoint = f"{GITHUB_IREE_API_PREFIX}/pulls/{pr_number}/reviews"
request = requests.post(api_endpoint, data=payload, headers=headers)
if request.status_code != 200:
raise requests.RequestException(
f"Failed to comment on GitHub; error code: {request.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):
benchmarks_md = get_benchmark_result_markdown(args.benchmark_files,
query_base=args.query_base,
verbose=args.verbose)
if args.dry_run:
print(benchmarks_md)
else:
comment_on_pr(benchmarks_md)
if __name__ == "__main__":
main(parse_arguments())