blob: a6650c22543b2a626ca1feb58ee85c2290e0042f [file] [edit]
#!/usr/bin/env python3
# ===----------------------------------------------------------------------===##
#
# Part of the LLVM Project, 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
#
# ===----------------------------------------------------------------------===##
from typing import Dict, List, NamedTuple, Optional, Sequence, Set, TextIO
import argparse
import common
import github
import logging
import os
import re
import sys
import tabulate
class RunName(NamedTuple):
"""The name of a workflow run and the information it encodes."""
target: common.Target
dry_run: bool # whether that workflow run was only a dry-run
class WorkflowRun(NamedTuple):
"""A run of the benchmark workflow, as the Github API describes it."""
identifier: int
target: common.Target
finished: bool
class Decision(NamedTuple):
"""What to do with one work item, and why."""
item: common.WorkItem
action: str # 'dispatch', 'skip' or 'defer'
jobs: int # how many runs to request now
reason: str
class Runs(NamedTuple):
"""What the workflow is currently doing, counted per target."""
in_flight: Dict[common.Target, int]
completed: Dict[common.Target, int]
ids: Dict[common.Target, List[int]] # in-flight run ids, for the summary
def in_flight_on(self, machine: str) -> int:
"""How many runs are in flight on a machine, whatever they are benchmarking."""
return sum(n for (target, n) in self.in_flight.items() if target.machine == machine)
# The repository and the workflow driven by this tool, and the ref to use for the
# definition of the workflow.
REPOSITORY = 'llvm/llvm-project'
WORKFLOW = 'libcxx-benchmark-commit.yml'
WORKFLOW_REF = 'main'
# The name the benchmark workflow gives its runs. What a run is benchmarking can
# only be recovered from its name (the inputs a workflow was dispatched with are
# not available from the API) so this format is a contract with the
# libcxx-benchmark-commit.yml workflow.
RUN_NAME_PREFIX = '[libc++] Run benchmark suite against '
RUN_NAME = re.compile(re.escape(RUN_NAME_PREFIX) + r'([0-9a-fA-F]{40}) on (\S+)( \(dry run\))?$')
class HelpFormatter(argparse.ArgumentDefaultsHelpFormatter,
argparse.RawDescriptionHelpFormatter):
"""Show defaults, but keep the paragraphs of the description intact."""
class GithubError(Exception):
pass
def parse_plan(stream: TextIO) -> List[common.WorkItem]:
"""Parse a plan into a list of work items, in the order they were written."""
items: List[common.WorkItem] = []
for (number, line) in enumerate(stream, start=1):
line = line.strip()
if not line:
continue
try:
items.append(common.WorkItem.parse(line))
except ValueError as error:
raise ValueError(f'line {number} of the plan is not a valid work item: {error}')
return items
def collapse(items: Sequence[common.WorkItem]) -> List[common.WorkItem]:
"""
Keep one item per commit and machine, the one asking for the most runs.
A plan can be concatenated from multiple sources or handwritten, so we
de-duplicate commits against the same machine to avoid duplicate work.
"""
best: Dict[common.Target, common.WorkItem] = {}
for item in items:
existing = best.get(item.target)
if existing is None or item.runs_to_request > existing.runs_to_request:
best[item.target] = item
# Preserve the order of the plan: it is the order the work should be done in.
seen = set()
ordered: List[common.WorkItem] = []
for item in items:
if item.target not in seen:
seen.add(item.target)
ordered.append(best[item.target])
return ordered
def parse_run_name(title: str) -> Optional[RunName]:
"""
Return the information encoded in a worfklow's run-name, or None if it can't be parsed.
The name has to match the workflow's `run-name` exactly.
"""
match = RUN_NAME.match(title.strip())
if match is None:
return None
return RunName(target=common.Target(match.group(1).lower(), match.group(2)),
dry_run=match.group(3) is not None)
def connect(token: str, repository: str, workflow: str) -> github.Workflow.Workflow:
"""Return the workflow this tool drives, as the Github API describes it."""
# Ask for the largest page size possible since we walk the whole run history.
client = github.Github(auth=github.Auth.Token(token), per_page=100)
logging.debug(f'looking up {workflow} in {repository}')
try:
return client.get_repo(repository).get_workflow(workflow)
except github.GithubException as error:
raise GithubError(f'could not find the {workflow} workflow in {repository}: {error}')
def workflow_runs(workflow: github.Workflow.Workflow) -> List[WorkflowRun]:
"""
Return every run of a workflow whose name says what it benchmarks.
The whole history is walked, however Github only retains runs for 90 days.
Runs are extracted without filtering on status.
Cancelled runs or runs named in any other format are ignored: they either
predate this tooling or don't represent expended capacity towards benchmarking
a target. Dry-runs are ignored too, since they never submit to LNT.
"""
runs: Dict[int, WorkflowRun] = {}
dry: Set[int] = set()
try:
for run in workflow.get_runs():
parsed = parse_run_name(run.display_title) if run.display_title else None
if parsed is None or run.conclusion == 'cancelled':
continue
if parsed.dry_run:
dry.add(run.id)
continue
# Deduplicate by id: pages can overlap while runs are being created.
runs[run.id] = WorkflowRun(identifier=run.id, target=parsed.target,
finished=run.status == 'completed')
except github.GithubException as error:
raise GithubError(f'could not list the runs of {workflow.path}: {error}')
if dry:
logging.debug(f'ignoring {len(dry)} workflow dry runs: {", ".join(str(i) for i in sorted(dry))}')
return list(runs.values())
def dispatch(workflow: github.Workflow.Workflow, inputs: Dict[str, str]) -> str:
"""
Request one run of a workflow and return the URL of the run that was created.
The definition on WORKFLOW_REF is always the one that runs, whatever commit is being
benchmarked.
"""
logging.debug(f'dispatching {workflow.path} with {inputs}')
try:
# Don't go through PyGithub's create_dispatch() since it doesn't support return_run_details
# until https://github.com/PyGithub/PyGithub/issues/3474 is fixed.
(_, details) = workflow.requester.requestJsonAndCheck(
'POST', f'{workflow.url}/dispatches',
input={'ref': WORKFLOW_REF, 'inputs': inputs, 'return_run_details': True})
except github.GithubException as error:
raise GithubError(f'could not dispatch a run for {inputs["commit"]} on {inputs["lnt-machine"]}: {error}')
if not isinstance(details, dict) or not details.get('html_url'):
raise GithubError(f'failed to dispatch a workflow run for {inputs["commit"]} on {inputs["lnt-machine"]}: {details}')
return details['html_url']
def summarize(runs: Sequence[WorkflowRun]) -> Runs:
"""Count what the workflow is doing, per target."""
in_flight: Dict[common.Target, int] = {}
completed: Dict[common.Target, int] = {}
ids: Dict[common.Target, List[int]] = {}
for run in runs:
if run.finished:
completed[run.target] = completed.get(run.target, 0) + 1
else:
in_flight[run.target] = in_flight.get(run.target, 0) + 1
ids.setdefault(run.target, []).append(run.identifier)
return Runs(in_flight=in_flight, completed=completed, ids=ids)
def decide(items: Sequence[common.WorkItem],
runs: Runs,
max_in_flight: int,
extra_attempts: int) -> List[Decision]:
"""
Return a list of Decisions describing what to do with each item.
Items are considered in the order the plan gave them, which is the order the
work should be done in.
Two budgets are enforced. Capacity: no more than `max_in_flight` runs may exist
at once on a machine, including queued runs since they occupy the queue just as
much as currently running jobs. Per machine rather than overall because the
machines are separate pools of runners.
Attempts: a target whose runs keep completing without the results reaching LNT
(e.g. cancelled, timed out, or failing before the submission step) would
otherwise risk being requested forever, since the planner only sees what reached
LNT. That budget is per machine too, and runs already in flight count against it.
"""
remaining = {machine: max(0, max_in_flight - runs.in_flight_on(machine))
for machine in {item.machine for item in items}}
decisions: List[Decision] = []
def in_flight_detail(target: common.Target, running: int) -> str:
ids = ', '.join(str(i) for i in runs.ids.get(target, [])[:3])
return f'{running} already in flight' + (f' (run {ids})' if ids else '')
for item in items:
target = item.target
running = runs.in_flight.get(target, 0)
finished = runs.completed.get(target, 0)
budget = item.samples_want + extra_attempts
# Only a target whose budget is spent with nothing left running is really abandoned.
if finished + running >= budget and running == 0:
reason = f'giving up: {item.samples_have}/{item.samples_want} runs recorded in LNT after {finished} completed runs, budget is {budget}'
decisions.append(Decision(item, action='skip', jobs=0, reason=reason))
continue
pending = min(item.runs_to_request - running, budget - finished - running)
if pending <= 0:
decisions.append(Decision(item, action='skip', jobs=0,
reason=in_flight_detail(target, running)))
continue
if remaining[item.machine] == 0:
reason = 'no capacity'
if running:
reason = f'{reason}, {in_flight_detail(target, running)}'
decisions.append(Decision(item, action='defer', jobs=0, reason=reason))
continue
dispatching = min(pending, remaining[item.machine])
remaining[item.machine] -= dispatching
deferred = pending - dispatching
reason = item.reason
if deferred:
reason = f'{reason} ({deferred} more deferred, no capacity)'
decisions.append(Decision(item, action='dispatch', jobs=dispatching, reason=reason))
return decisions
def workflow_inputs(args: argparse.Namespace, item: common.WorkItem) -> Dict[str, str]:
"""Return the inputs to dispatch the workflow with for a work item."""
inputs = {'commit': item.commit,
'lnt-machine': item.machine,
# Submitting to LNT is mandatory, since a run whose results are not recorded
# anywhere would be requested again on every future invocation. When run
# with --dry-run, this tool never actually dispatches the workflow with
# `submit-lnt: false`.
'submit-lnt': 'false' if args.dry_run else 'true'}
if args.lnt_url:
inputs['lnt-url'] = args.lnt_url
return inputs
def report(args: argparse.Namespace, decisions: Sequence[Decision], runs: Runs) -> None:
"""Print a human-readable summary of what is about to be done."""
out = sys.stderr
print(f'Submitting to: {args.lnt_url or "whatever the workflow defaults to"}', file=out)
# One line per machine the plan is about, since capacity is per machine.
for machine in sorted({d.item.machine for d in decisions}):
in_flight = runs.in_flight_on(machine)
available = max(0, args.max_in_flight - in_flight)
print(f'Capacity on {machine}: {args.max_in_flight} runs allowed at once, {in_flight} '
f'in flight -> {available} slots available', file=out)
print('', file=out)
symbols = {'dispatch': '+', 'skip': '~', 'defer': '.'}
rows = [(f'{symbols[d.action]} {d.action}', d.item.commit[:12], d.item.machine,
str(d.jobs) if d.action == 'dispatch' else '-', d.reason)
for d in decisions]
print(tabulate.tabulate(rows, headers=['ACTION', 'COMMIT', 'MACHINE', 'JOBS', 'REASON'],
tablefmt='plain', disable_numparse=True,
colalign=('left', 'left', 'left', 'right', 'left'))
if rows else ' nothing to do', file=out)
if args.dry_run:
print('', file=out)
print('DRY RUN: nothing was requested.', file=out)
def main(argv: List[str]) -> int:
parser = argparse.ArgumentParser(
prog='dispatch-benchmarks',
description='Read a benchmark plan in a compatible format (see plan-benchmarks), and request the '
'corresponding Github Actions workflow runs.\n'
'\n'
'The plan is read as one unit of work per line. A unit of work is a commit to benchmark '
'on a specific machine. It is represented as a JSON object containing these fields: '
'`commit`, the commit to benchmark; `machine`, the LNT machine to benchmark on; '
'`samples_have` and `samples_want`, how many runs it already has and how many it '
'should end up with; and `reason`, a human-readable explanation.\n'
'\n'
'Work that is already running is not requested again, and no more than a fixed '
'number of runs are allowed to exist at once, so that the benchmark machines '
'remain available for other jobs. Work that does not fit is left for a later '
'invocation: since the plan is recomputed from scratch every time, nothing is '
'lost by deferring it.\n'
'\n'
'Results are submitted to LNT unless this is a dry run. That is not optional: '
'results that are not recorded anywhere would be requested again on every '
'invocation, forever.',
epilog='This script depends on the modules listed in `libcxx/utils/ci/lnt/requirements.txt`.',
formatter_class=HelpFormatter)
parser.add_argument('--work-items', type=argparse.FileType('r'), default=sys.stdin,
help='A file containing the plan, one JSON object per line. By default, this is read from '
'standard input.')
parser.add_argument('--max-in-flight', type=common.at_least(1), default=4,
help='The largest number of workflow runs allowed to exist at once for a single LNT machine. '
'This counts runs that are queued but not yet started.')
parser.add_argument('--extra-attempts', type=common.at_least(0), default=2,
help='How many runs beyond the desired number of samples a commit may consume on a machine '
'before it is given up on. This bounds the cost of a job that keeps dying before it '
'can submit, since such a run leaves no trace in LNT and would otherwise be requested '
'forever. It is counted per machine.')
parser.add_argument('--lnt-url', type=str, required=False,
help='The LNT instance the runs should submit to. By default the workflow submits to '
'whichever instance it defaults to.')
parser.add_argument('--token', type=str, required=False, # no default to avoid printing the token in --help
help='A Github token with write access to the repository, which is needed both to look at '
'the existing runs and to request new ones. Defaults to the GITHUB_TOKEN environment '
'variable. `gh auth token` can be used to print the token registered locally.')
parser.add_argument('--dry-run', action='store_true',
help='Do not request anything, just report what would be requested.')
parser.add_argument('-q', '--quiet', action='store_true',
help='Do not print the human-readable summary to standard error.')
parser.add_argument('-v', '--verbose', action='count', default=0,
help='Verbosity level: passing the option multiple times increases the level.')
args = parser.parse_args(argv)
# --quiet raises the logging threshold rather than silencing everything, since we
# still want to see warnings.
logging.basicConfig(format='%(levelname)s: %(message)s',
level=logging.DEBUG if args.verbose else
logging.WARNING if args.quiet else logging.INFO)
token = args.token or os.environ.get('GITHUB_TOKEN')
if not token:
raise ValueError('no Github token was provided: pass --token or set the GITHUB_TOKEN environment variable.')
items = collapse(parse_plan(args.work_items))
workflow = connect(token, REPOSITORY, WORKFLOW)
runs = summarize(workflow_runs(workflow))
decisions = decide(items, runs, args.max_in_flight, args.extra_attempts)
# Warn then we give up for good on a commit/machine pair (because enough attempts have been done).
for decision in decisions:
if decision.action == 'skip' and decision.reason.startswith('giving up'):
logging.warning(f'{decision.item.commit[:12]} on {decision.item.machine}: {decision.reason}')
if not args.quiet:
report(args, decisions, runs)
# Requests are made one at a time and reported as they succeed.
dispatched = 0
try:
for decision in decisions:
if decision.action != 'dispatch':
continue
inputs = workflow_inputs(args, decision.item)
for _ in range(decision.jobs):
if not args.dry_run:
url = dispatch(workflow, inputs)
if not args.quiet:
# Print heading with the first run to avoid clutter in the "nothing dispatched" case.
if dispatched == 0:
print('', file=sys.stderr)
print('Requested runs:', file=sys.stderr)
print(f' {decision.item.commit[:12]} on {decision.item.machine}: {url}', file=sys.stderr)
dispatched += 1
except GithubError:
logging.error(f'requested {dispatched} runs before failing')
raise
if not args.quiet and not args.dry_run:
print(f'Requested {dispatched} runs.', file=sys.stderr)
return 0
if __name__ == '__main__':
try:
sys.exit(main(sys.argv[1:]))
except (ValueError, OSError, GithubError, github.GithubException) as error:
sys.exit(f'error: {error}')