shinka-backup / ccevolve /baselines /ttt-discover /examples /gpu_mode /lib /libkernelbot /launchers /github.py
| import asyncio | |
| import base64 | |
| import dataclasses | |
| import datetime | |
| import io | |
| import json | |
| import math | |
| import pprint | |
| import uuid | |
| import zipfile | |
| import zlib | |
| from typing import Awaitable, Callable, Optional | |
| import github | |
| import requests | |
| from github import Github, UnknownObjectException | |
| from github.GithubObject import NotSet, Opt | |
| from github.Workflow import Workflow | |
| from github.WorkflowRun import WorkflowRun | |
| from libkernelbot.consts import ( | |
| AMD_REQUIREMENTS, | |
| DEFAULT_GITHUB_TIMEOUT_MINUTES, | |
| GPU, | |
| NVIDIA_REQUIREMENTS, | |
| TIMEOUT_BUFFER_MINUTES, | |
| GitHubGPU, | |
| SubmissionMode, | |
| ) | |
| from libkernelbot.report import RunProgressReporter | |
| from libkernelbot.run_eval import ( | |
| CompileResult, | |
| EvalResult, | |
| FullResult, | |
| ProfileResult, | |
| RunResult, | |
| SystemInfo, | |
| ) | |
| from libkernelbot.utils import KernelBotError, setup_logging | |
| from .launcher import Launcher | |
| logger = setup_logging() | |
| def get_timeout(config: dict) -> int: | |
| mode = config.get("mode") | |
| sec_map = { | |
| SubmissionMode.TEST.value: config.get("test_timeout"), | |
| SubmissionMode.BENCHMARK.value: config.get("benchmark_timeout"), | |
| SubmissionMode.LEADERBOARD.value: config.get("ranked_timeout"), | |
| } | |
| seconds = sec_map.get(mode) or DEFAULT_GITHUB_TIMEOUT_MINUTES * 60 | |
| return math.ceil(seconds / 60) | |
| class GitHubLauncher(Launcher): | |
| def __init__(self, repo: str, token: str, branch: str): | |
| super().__init__(name="GitHub", gpus=GitHubGPU) | |
| self.repo = repo | |
| self.token = token | |
| self.branch = branch | |
| async def run_submission( # noqa: C901 | |
| self, config: dict, gpu_type: GPU, status: RunProgressReporter | |
| ) -> FullResult: | |
| gpu_vendor = None | |
| if gpu_type.value in ["MI300", "MI250", "MI300x8"]: | |
| selected_workflow = "amd_workflow.yml" | |
| runner_name = { | |
| "MI300": "amdgpu-mi300-x86-64", | |
| "MI250": "amdgpu-mi250-x86-64", | |
| "MI300x8": "amdgpu-mi300-8-x86-64", | |
| }[gpu_type.value] | |
| gpu_vendor = "AMD" | |
| requirements = AMD_REQUIREMENTS | |
| elif gpu_type.value == "NVIDIA": | |
| selected_workflow = "nvidia_workflow.yml" | |
| gpu_vendor = "NVIDIA" | |
| requirements = NVIDIA_REQUIREMENTS | |
| else: | |
| raise ValueError(f"Invalid GPU type: {gpu_type.value}") | |
| lang = config["lang"] | |
| if lang == "cu" and gpu_vendor == "AMD": | |
| # TODO implement HIP | |
| raise NotImplementedError("Cannot use CUDA runs with AMD GPUs") | |
| lang_name = {"py": "Python", "cu": "CUDA"}[lang] | |
| logger.info(f"Attempting to trigger GitHub action for {lang_name} on {selected_workflow}") | |
| run = GitHubRun(self.repo, self.token, self.branch, selected_workflow) | |
| logger.info(f"Successfully created GitHub run: {run.run_id}") | |
| payload = base64.b64encode(zlib.compress(json.dumps(config).encode("utf-8"))).decode( | |
| "utf-8" | |
| ) | |
| inputs = {"payload": payload} | |
| if lang == "py": | |
| inputs["requirements"] = requirements | |
| if gpu_vendor == "AMD": | |
| inputs["runner"] = runner_name | |
| if not await run.trigger(inputs): | |
| raise RuntimeError("Failed to trigger GitHub Action. Please check the configuration.") | |
| await status.push("⏳ Waiting for workflow to start...") | |
| logger.info("Waiting for workflow to start...") | |
| timeout = get_timeout(config) + TIMEOUT_BUFFER_MINUTES | |
| logger.info(f"Waiting for workflow to complete... (timeout: {timeout} minutes)") | |
| await run.wait_for_completion( | |
| lambda x: self.wait_callback(x, status), timeout_minutes=timeout | |
| ) | |
| await status.update(f"Workflow [{run.run_id}](<{run.html_url}>) completed") | |
| logger.info(f"Workflow [{run.run_id}]({run.html_url}) completed") | |
| await status.push("Downloading artifacts...") | |
| logger.info("Downloading artifacts...") | |
| index = run.get_artifact_index() | |
| if "run-result" not in index: | |
| logger.error("Could not find `run-result` among artifacts: %s", index.keys()) | |
| await status.push("Downloading artifacts... failed") | |
| return FullResult( | |
| success=False, error="Could not download artifacts", runs={}, system=SystemInfo() | |
| ) | |
| artifact = await run.download_artifact(index["run-result"]) | |
| logs = artifact["result.json"].decode("utf-8") | |
| await status.update("Downloading artifacts... done") | |
| logger.info("Downloading artifacts... done") | |
| data = json.loads(logs) | |
| runs = {} | |
| # convert json back to EvalResult structures, which requires | |
| # special handling for datetime and our dataclasses. | |
| for k, v in data["runs"].items(): | |
| comp_res = None if v.get("compilation") is None else CompileResult(**v["compilation"]) | |
| run_res = None if v.get("run") is None else RunResult(**v["run"]) | |
| profile_res = None if v.get("profile") is None else ProfileResult(**v["profile"]) | |
| # Update profile artifact to the actual download URL. | |
| # For the GitHub launcher the profile_artifact currently just contains | |
| # the name of the artifact. | |
| if profile_res is not None and "profile-data" in index: | |
| profile_res.download_url = index["profile-data"].public_download_url | |
| res = EvalResult( | |
| start=datetime.datetime.fromisoformat(v["start"]), | |
| end=datetime.datetime.fromisoformat(v["end"]), | |
| compilation=comp_res, | |
| run=run_res, | |
| profile=profile_res, | |
| ) | |
| runs[k] = res | |
| system = SystemInfo(**data.get("system", {})) | |
| return FullResult(success=True, error="", runs=runs, system=system) | |
| async def wait_callback(self, run: "GitHubRun", status: RunProgressReporter): | |
| await status.update( | |
| f"⏳ Workflow [{run.run_id}](<{run.html_url}>): {run.status} " | |
| f"({run.elapsed_time.total_seconds():.1f}s)" | |
| ) | |
| class GitHubArtifact: | |
| name: str | |
| archive_download_url: str | |
| public_download_url: str | |
| _WORKFLOW_FILE_CACHE: dict[str, Workflow] = {} | |
| def patched_create_dispatch( | |
| workflow: Workflow, | |
| ref: github.Branch.Branch | github.Tag.Tag | github.Commit.Commit | str, | |
| inputs: Opt[dict] = NotSet, | |
| ) -> bool: | |
| """ | |
| :calls: `POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches <https://docs.github.com/en/rest/reference/actions#create-a-workflow-dispatch-event>`_ | |
| """ | |
| assert ( | |
| isinstance(ref, github.Branch.Branch) | |
| or isinstance(ref, github.Tag.Tag) | |
| or isinstance(ref, github.Commit.Commit) | |
| or isinstance(ref, str) | |
| ), ref | |
| assert inputs is NotSet or isinstance(inputs, dict), inputs | |
| if isinstance(ref, github.Branch.Branch): | |
| ref = ref.name | |
| elif isinstance(ref, github.Commit.Commit): | |
| ref = ref.sha | |
| elif isinstance(ref, github.Tag.Tag): | |
| ref = ref.name | |
| if inputs is NotSet: | |
| inputs = {} | |
| status, _, _ = workflow._requester.requestJson( | |
| "POST", f"{workflow.url}/dispatches", input={"ref": ref, "inputs": inputs} | |
| ) | |
| return status == 200 or status == 204 | |
| class GitHubRun: | |
| def __init__(self, repo: str, token: str, branch: str, workflow_file: str): | |
| gh = Github(token) | |
| try: | |
| self.repo = gh.get_repo(repo) | |
| except UnknownObjectException as e: | |
| raise KernelBotError(f"Could not find GitHub repository {repo}: 404") from e | |
| self.token = token | |
| self.branch = branch | |
| self.workflow_file = workflow_file | |
| self.run: Optional[WorkflowRun.WorkflowRun] = None | |
| self.start_time = None | |
| def run_id(self): | |
| if self.run is None: | |
| return None | |
| return self.run.id | |
| def html_url(self): | |
| if self.run is None: | |
| return None | |
| return self.run.html_url | |
| def status(self): | |
| if self.run is None: | |
| return None | |
| return self.run.status | |
| def elapsed_time(self): | |
| if self.start_time is None: | |
| return None | |
| return datetime.datetime.now(datetime.timezone.utc) - self.start_time | |
| async def get_workflow(self) -> Workflow: | |
| if self.workflow_file in _WORKFLOW_FILE_CACHE: | |
| logger.info(f"Returning cached workflow {self.workflow_file}") | |
| return _WORKFLOW_FILE_CACHE[self.workflow_file] | |
| logger.info(f"Fetching workflow {self.workflow_file} from GitHub") | |
| workflow = self.repo.get_workflow(self.workflow_file) | |
| _WORKFLOW_FILE_CACHE[self.workflow_file] = workflow | |
| return workflow | |
| async def trigger(self, inputs: dict) -> bool: | |
| """ | |
| Trigger this run with the provided inputs. | |
| Sets `self.run` to the new WorkflowRun on success. | |
| Returns: Whether the run was successfully triggered, | |
| """ | |
| run_id = str(uuid.uuid4()) | |
| inputs_with_run_id = {**inputs, "run_id": run_id} | |
| if self.workflow_file == "amd_workflow.yml": | |
| expected_run_name = f"AMD Job - {run_id}" | |
| elif self.workflow_file == "nvidia_workflow.yml": | |
| expected_run_name = f"NVIDIA Job - {run_id}" | |
| else: | |
| raise ValueError(f"Unknown workflow file: {self.workflow_file}") | |
| trigger_time = datetime.datetime.now(datetime.timezone.utc) | |
| try: | |
| workflow = await self.get_workflow() | |
| except UnknownObjectException as e: | |
| logger.error(f"Could not find workflow {self.workflow_file}", exc_info=e) | |
| raise ValueError(f"Could not find workflow {self.workflow_file}") from e | |
| logger.info( | |
| "Dispatching workflow %s on branch %s with run_id %s", | |
| self.workflow_file, | |
| self.branch, | |
| run_id, | |
| ) | |
| logger.debug( | |
| "Dispatching workflow %s on branch %s with inputs %s", | |
| self.workflow_file, | |
| self.branch, | |
| pprint.pformat(inputs_with_run_id), | |
| ) | |
| success = await asyncio.to_thread( | |
| patched_create_dispatch, workflow, self.branch, inputs=inputs_with_run_id | |
| ) # noqa: E501 | |
| if success: | |
| wait_seconds = 10 | |
| logger.info( | |
| f"Workflow dispatch successful. Waiting {wait_seconds}s for the run to appear..." | |
| ) | |
| await asyncio.sleep(wait_seconds) | |
| recent_runs_paginated = await asyncio.to_thread( | |
| workflow.get_runs, event="workflow_dispatch" | |
| ) | |
| logger.info(f"Looking for workflow run with name: '{expected_run_name}'") | |
| found_run = None | |
| runs_checked = 0 | |
| try: | |
| run_iterator = recent_runs_paginated.__iter__() | |
| while runs_checked < 100: | |
| try: | |
| run = next(run_iterator) | |
| runs_checked += 1 | |
| logger.debug( | |
| f"Checking run {run.id} with name '{run.name}'" | |
| f" created at {run.created_at.isoformat()}" | |
| ) | |
| if run.name == expected_run_name and run.created_at.replace( | |
| tzinfo=datetime.timezone.utc | |
| ) > trigger_time - datetime.timedelta(seconds=30): | |
| found_run = run | |
| logger.info( | |
| f"Found matching workflow run: ID {found_run.id} " | |
| f"with name '{found_run.name}'" | |
| ) | |
| break | |
| except StopIteration: | |
| logger.debug("Reached end of recent runs list.") | |
| break | |
| except Exception as e: | |
| logger.error(f"Error iterating through recent runs: {e}", exc_info=True) | |
| return False | |
| if found_run: | |
| self.run = found_run | |
| return True | |
| else: | |
| logger.warning( | |
| f"Could not find a workflow run with name '{expected_run_name}' " | |
| f"created after {trigger_time.isoformat()}." | |
| ) | |
| return False | |
| else: | |
| logger.error( | |
| f"Failed to dispatch workflow {self.workflow_file} on branch {self.branch}." | |
| ) | |
| return False | |
| async def wait_for_completion( | |
| self, callback: Callable[["GitHubRun"], Awaitable[None]], timeout_minutes: int = 10 | |
| ): | |
| if self.run is None: | |
| raise ValueError("Run needs to be triggered before a status check!") | |
| self.start_time = datetime.datetime.now(datetime.timezone.utc) | |
| timeout = datetime.timedelta(minutes=timeout_minutes) | |
| while True: | |
| try: | |
| run_update = await asyncio.to_thread(self.repo.get_workflow_run, self.run_id) | |
| self.run = run = run_update | |
| if self.elapsed_time > timeout: | |
| try: | |
| self.run.cancel() | |
| # Wait briefly to ensure cancellation is processed | |
| # And Verify the run was actually cancelled | |
| await asyncio.sleep(5) | |
| run = self.repo.get_workflow_run(self.run_id) | |
| if run.status != "completed": | |
| logger.warning(f"Failed to cancel workflow run {self.run_id}") | |
| except Exception as e: | |
| logger.error(f"Error cancelling workflow: {str(e)}", exc_info=e) | |
| raise | |
| logger.warning( | |
| f"Workflow {self.run_id} cancelled - " | |
| f"exceeded {timeout_minutes} minute timeout" | |
| ) | |
| raise TimeoutError( | |
| f"Workflow {self.run_id} cancelled - " | |
| f"exceeded {timeout_minutes} minute timeout" | |
| ) | |
| if run.status == "completed": | |
| return | |
| await callback(self) | |
| await asyncio.sleep(30) # Yield control while waiting | |
| except TimeoutError: | |
| raise # Re-raise the specific TimeoutError from the timeout block | |
| except Exception as e: | |
| logger.error(f"Error waiting for GitHub run {self.run_id}: {e}", exc_info=e) | |
| raise # Re-raise other exceptions | |
| def get_artifact_index(self) -> dict[str, GitHubArtifact]: | |
| logger.info("Creating artifact index for run %s", self.run_id) | |
| artifacts = self.run.get_artifacts() | |
| extracted = {} | |
| for artifact in artifacts: | |
| extracted[artifact.name] = GitHubArtifact( | |
| name=artifact.name, | |
| archive_download_url=artifact.archive_download_url, | |
| # Non-machine users cannot download from the archive_download_url and | |
| # the GitHub API does not give us access to the public download url. | |
| public_download_url=f"{self.repo.html_url}/actions/runs/{self.run_id}/artifacts/{artifact.id}", | |
| ) | |
| return extracted | |
| async def download_artifact(self, artifact: GitHubArtifact) -> dict: | |
| logger.info("Attempting to download artifact '%s' for run %s", artifact.name, self.run_id) | |
| url = artifact.archive_download_url | |
| headers = {"Authorization": f"token {self.token}"} | |
| response = requests.get(url, headers=headers) | |
| if response.status_code == 200: | |
| artifact_dict = {} | |
| with zipfile.ZipFile(io.BytesIO(response.content)) as z: | |
| for file in z.namelist(): | |
| with z.open(file) as f: | |
| artifact_dict[file] = f.read() | |
| logger.info("Downloaded artifact '%s' for run %s", artifact.name, self.run_id) | |
| return artifact_dict | |
| else: | |
| raise RuntimeError( | |
| f"Failed to download artifact {artifact.name}. Status code: {response.status_code}" | |
| ) | |