#!/usr/bin/env python3 # Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information # regarding copyright ownership. The ASF licenses this file # to you under the Apache License, Version 2.0 (the # "License"); you may not use this file except in compliance # with the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, # software distributed under the License is distributed on an # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. # ruff: noqa: E501, RUF005 import argparse import getpass import grp import inspect import json import multiprocessing import os import platform import random import shutil import string import subprocess import sys import textwrap import typing from collections.abc import Callable from pathlib import Path from typing import Any REPO_ROOT = Path(__file__).resolve().parent.parent.parent SCRIPT_DIR = REPO_ROOT / ".ci-py-scripts" NPROC = multiprocessing.cpu_count() class col: BLUE = "\033[94m" CYAN = "\033[96m" GREEN = "\033[92m" YELLOW = "\033[93m" RED = "\033[91m" RESET = "\033[0m" BOLD = "\033[1m" UNDERLINE = "\033[4m" def print_color(color: str, msg: str, bold: bool, **kwargs: Any) -> None: if hasattr(sys.stdout, "isatty") and sys.stdout.isatty(): bold_code = col.BOLD if bold else "" print(bold_code + color + msg + col.RESET, **kwargs) else: print(msg, **kwargs) warnings: list[str] = [] def clean_exit(msg: str) -> None: print_color(col.RED, msg, bold=True, file=sys.stderr) for warning in warnings: print_color(col.YELLOW, warning, bold=False, file=sys.stderr) exit(1) def cmd(commands: list[Any], **kwargs: Any): commands = [str(s) for s in commands] command_str = " ".join(commands) print_color(col.BLUE, command_str, bold=True) proc = subprocess.run(commands, **kwargs) if proc.returncode != 0: raise RuntimeError(f"Command failed: '{command_str}'") return proc def get_build_dir(name: str) -> str: build_dir = REPO_ROOT / f"build-{name}" return str(build_dir.relative_to(REPO_ROOT)) def check_docker(): executable = shutil.which("docker") if executable is None: clean_exit("'docker' executable not found, install it first (e.g. 'apt install docker.io')") if sys.platform == "linux": # Check that the user is in the docker group before running try: group = grp.getgrnam("docker") if getpass.getuser() not in group.gr_mem: warnings.append( f"Note: User '{getpass.getuser()}' is not in the 'docker' group, either:\n" " * run with 'sudo'\n" " * add user to 'docker': sudo usermod -aG docker $(whoami), then log out and back in", ) except KeyError: warnings.append("Note: 'docker' group does not exist") def check_gpu(): if not (sys.platform == "linux" and shutil.which("lshw")): # Can't check GPU on non-Linux platforms return # See if we can check if a GPU is present in case of later failures, # but don't block on execution since this isn't critical try: proc = cmd( ["lshw", "-json", "-C", "display"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", ) stdout = proc.stdout.strip().strip(",") stdout = json.loads(stdout) except (subprocess.CalledProcessError, json.decoder.JSONDecodeError): # Do nothing if any step failed return if isinstance(stdout, dict): # Sometimes lshw outputs a single item as a dict instead of a list of # dicts, so wrap it up if necessary stdout = [stdout] if not isinstance(stdout, list): return vendors = [s.get("vendor", "").lower() for s in stdout] if not any("nvidia" in vendor for vendor in vendors): warnings.append( "nvidia GPU not found in 'lshw', maybe use --cpu flag when running 'docs' command?" ) def gen_name(s: str) -> str: # random 4 letters suffix = "".join([random.choice(string.ascii_lowercase) for i in range(5)]) return f"{s}-{suffix}" def docker( name: str, image: str, scripts: list[str], env: dict[str, str], interactive: bool, additional_flags: dict[str, str] | None = None, ): """ Invoke a set of bash scripts through docker/bash.sh name: container name image: docker image name scripts: list of bash commands to run env: environment to set """ check_docker() # As sccache is added to these images these can be uncommented sccache_images = { "ci_gpu", "ci_cpu", # "ci_wasm", "ci_cortexm", "ci_arm", "ci_riscv", "ci_adreno", } if image in sccache_images and os.getenv("USE_SCCACHE", "1") == "1": scripts = [ "sccache --start-server", ] + scripts # Set the C/C++ compiler so CMake picks them up in the build env["CC"] = "/opt/sccache/cc" env["CXX"] = "/opt/sccache/c++" env["SCCACHE_CACHE_SIZE"] = os.getenv("SCCACHE_CACHE_SIZE", "50G") env["SCCACHE_SERVER_PORT"] = os.getenv("SCCACHE_SERVER_PORT", "4226") env["PLATFORM"] = name docker_bash = REPO_ROOT / "docker" / "bash.sh" command = [docker_bash] if sys.stdout.isatty(): command.append("-t") command.append("--name") command.append(name) if interactive: command.append("-i") scripts = ["interact() {", " bash", "}", "trap interact 0", ""] + scripts for key, value in env.items(): command.append("--env") command.append(f"{key}={value}") if additional_flags is not None: for key, value in additional_flags.items(): command.append(key) command.append(value) SCRIPT_DIR.mkdir(exist_ok=True) script_file = SCRIPT_DIR / f"{name}.sh" with open(script_file, "w") as f: f.write("set -eux\n\n") f.write("\n".join(scripts)) f.write("\n") command += [image, "bash", str(script_file.relative_to(REPO_ROOT))] try: cmd(command) except RuntimeError as e: clean_exit(f"Error invoking Docker: {e}") except KeyboardInterrupt: cmd(["docker", "stop", "--time", "1", name]) finally: if os.getenv("DEBUG", "0") != "1": script_file.unlink() def docs( tutorial_pattern: str | None = None, cpu: bool = False, full: bool = False, interactive: bool = False, skip_build: bool = False, docker_image: str | None = None, ) -> None: """ Build the documentation from gallery/ and docs/. By default this builds only the Python docs without any tutorials. arguments: full -- Build all language docs, not just Python (cannot be used with --cpu) cpu -- Use the 'ci_cpu' Docker image (useful for building docs on a machine without a GPU) tutorial-pattern -- Regex for which tutorials to execute when building docs (cannot be used with --cpu) skip_build -- skip build and setup scripts interactive -- start a shell after running build / test scripts docker-image -- manually specify the docker image to use """ build_dir = get_build_dir("gpu") extra_setup = [] image = "ci_gpu" if docker_image is None else docker_image if cpu: # TODO: Change this to tlcpack/docs once that is uploaded image = "ci_cpu" if docker_image is None else docker_image build_dir = get_build_dir("cpu") config_script = " && ".join( [ f"mkdir -p {build_dir}", f"pushd {build_dir}", "cp ../cmake/config.cmake .", "popd", ] ) # These are taken from the ci-gpu image via pip freeze, consult that # if there are any changes: https://github.com/apache/tvm/tree/main/docs#native requirements = [ "Sphinx==8.1.3", "sphinx_autodoc_annotation~=1.0", "sphinx-gallery==0.20.0", "sphinx-book-theme==1.1.4", "pydata-sphinx-theme==0.15.4", "autodocsumm==0.2.14", "image==1.5.33", "matplotlib==3.10.8", "commonmark==0.9.1", "docutils==0.21.2", "Pillow==12.1.1", ] extra_setup = [ "uv pip install " + " ".join(requirements), ] else: check_gpu() config_script = f"./tests/scripts/task_config_build_gpu.sh {build_dir}" scripts = extra_setup + [ config_script, f"./tests/scripts/task_build.py --build-dir {build_dir}", ] if skip_build: scripts = [] scripts.append("./tests/scripts/task_python_docs.sh") if tutorial_pattern is None: tutorial_pattern = os.getenv("TVM_TUTORIAL_EXEC_PATTERN", ".py" if full else "none") env = { "TVM_TUTORIAL_EXEC_PATTERN": tutorial_pattern, "PYTHON_DOCS_ONLY": "0" if full else "1", "IS_LOCAL": "1", "TVM_LIBRARY_PATH": str(REPO_ROOT / build_dir), } docker(name=gen_name("docs"), image=image, scripts=scripts, env=env, interactive=interactive) print_color( col.GREEN, "Done building the docs. You can view them by running " "'python3 tests/scripts/ci.py serve-docs' and visiting:" " http://localhost:8000 in your browser.", bold=True, ) def serve_docs(directory: str = "_docs") -> None: """ Serve the docs using Python's http server arguments: directory -- Directory to serve from """ directory_path = Path(directory) if not directory_path.exists(): clean_exit("Docs have not been built, run 'ci.py docs' first") cmd([sys.executable, "-m", "http.server"], cwd=directory_path) def lint(interactive: bool = False, docker_image: str | None = None) -> None: """ Run lint checks locally. arguments: interactive -- start a shell after running build / test scripts when using --docker-image docker-image -- manually specify a docker image to use """ scripts = ["pre-commit run --all-files"] if docker_image is None: if interactive: clean_exit("--interactive requires --docker-image") cmd(scripts[0].split()) return docker( name=gen_name("lint"), image=docker_image, scripts=scripts, env={}, interactive=interactive, ) Option = tuple[str, list[str]] def generate_command( name: str, options: dict[str, Option], help: str, precheck: Callable[[], None] | None = None, post_build: list[str] | None = None, additional_flags: dict[str, str] | None = None, env: dict[str, str] | None = None, ): """ Helper to generate CLIs that: 1. Build a with a config matching a specific CI Docker image (e.g. 'cpu') 2. Run tests (either a pre-defined set from scripts or manually via invoking pytest) 3. (optional) Drop down into a terminal into the Docker container """ def fn( tests: list[str] | None = None, skip_build: bool = False, interactive: bool = False, docker_image: str | None = None, verbose: bool = False, **kwargs, ) -> None: """ arguments: tests -- pytest test IDs (e.g. tests/python or tests/python/a_file.py::a_test[param=1]) skip_build -- skip build and setup scripts interactive -- start a shell after running build / test scripts docker-image -- manually specify the docker image to use verbose -- run verbose build """ if precheck is not None: precheck() build_dir = get_build_dir(name) if skip_build: scripts = [] else: scripts = [ f"./tests/scripts/task_config_build_{name}.sh {build_dir}", f"./tests/scripts/task_build.py --build-dir {build_dir}", ] if post_build is not None: scripts += post_build # Check that a test suite was not used alongside specific test names if any(v for v in kwargs.values()) and tests is not None: option_flags = ", ".join([f"--{k}" for k in options.keys()]) clean_exit(f"{option_flags} cannot be used with --tests") if tests is not None: scripts.append(f"python3 -m pytest {' '.join(tests)}") # Add named test suites for option_name, (_, extra_scripts) in options.items(): if kwargs.get(option_name, False): scripts.extend(script.format(build_dir=build_dir) for script in extra_scripts) docker_env = { # Need to specify the library path manually or else TVM can't # determine which build directory to use (i.e. if there are # multiple copies of libtvm_compiler.so / libtvm_runtime.so laying around) "TVM_LIBRARY_PATH": str(REPO_ROOT / get_build_dir(name)), "VERBOSE": "true" if verbose else "false", } if env is not None: docker_env.update(env) docker( name=gen_name(f"ci-{name}"), image=f"ci_{name}" if docker_image is None else docker_image, scripts=scripts, env=docker_env, interactive=interactive, additional_flags=additional_flags, ) fn.__name__ = name return fn, options, help def check_arm_qemu() -> None: """ Check if a machine is ready to run an ARM Docker image """ machine = platform.machine().lower() if "arm" in machine or "aarch64" in machine: # No need to check anything if the machine runs ARM return binfmt = Path("/proc/sys/fs/binfmt_misc") if not binfmt.exists() or len(list(binfmt.glob("qemu-*"))) == 0: clean_exit( textwrap.dedent( """ You must run a one-time setup to use ARM containers on x86 via QEMU: sudo apt install -y qemu binfmt-support qemu-user-static docker run --rm --privileged multiarch/qemu-user-static --reset -p yes See https://www.stereolabs.com/docs/docker/building-arm-container-on-x86/ for details""".strip( "\n" ) ) ) def cli_name(s: str) -> str: return s.replace("_", "-") def typing_get_origin(annotation): return typing.get_origin(annotation) def typing_get_args(annotation): return typing.get_args(annotation) def is_optional_type(annotation): return ( hasattr(annotation, "__origin__") and (typing_get_origin(annotation) == typing.Union) and (type(None) in typing_get_args(annotation)) ) def add_subparser( func: Callable, subparsers: Any, options: dict[str, Option] | None = None, help: str | None = None, ) -> Any: """ Utility function to make it so subparser commands can be defined locally as a function rather than directly via argparse and manually dispatched out. """ # Each function is intended follow the example for arguments in PEP257, so # split apart the function documentation from the arguments split = [s.strip() for s in func.__doc__.split("arguments:\n")] if len(split) == 1: args_help = None command_help = split[0] else: command_help, args_help = split if help is not None: command_help = help # Parse out the help text for each argument if present arg_help_texts = {} if args_help is not None: for line in args_help.split("\n"): line = line.strip() name, help_text = [t.strip() for t in line.split(" -- ")] arg_help_texts[cli_name(name)] = help_text subparser = subparsers.add_parser(cli_name(func.__name__), help=command_help) seen_prefixes = set() # Add each parameter to the subparser signature = inspect.signature(func) for name, value in signature.parameters.items(): if name == "kwargs": continue arg_cli_name = cli_name(name) kwargs: dict[str, str | bool] = {"help": arg_help_texts[arg_cli_name]} is_optional = is_optional_type(value.annotation) if is_optional: arg_type = typing_get_args(value.annotation)[0] else: arg_type = value.annotation # Grab the default value if present has_default = False if value.default is not value.empty: kwargs["default"] = value.default has_default = True # Check if it should be a flag if arg_type is bool: kwargs["action"] = "store_true" else: kwargs["required"] = not is_optional and not has_default if str(arg_type).startswith("typing.List"): kwargs["action"] = "append" if arg_cli_name[0] not in seen_prefixes: subparser.add_argument(f"-{arg_cli_name[0]}", f"--{arg_cli_name}", **kwargs) seen_prefixes.add(arg_cli_name[0]) else: subparser.add_argument(f"--{arg_cli_name}", **kwargs) if options is not None: for option_name, (help, _) in options.items(): option_cli_name = cli_name(option_name) if option_cli_name[0] not in seen_prefixes: subparser.add_argument( f"-{option_cli_name[0]}", f"--{option_cli_name}", action="store_true", help=help ) seen_prefixes.add(option_cli_name[0]) else: subparser.add_argument(f"--{option_cli_name}", action="store_true", help=help) return subparser CPP_UNITTEST = ("run c++ unitests", ["./tests/scripts/task_cpp_unittest.sh {build_dir}"]) generated = [ generate_command( name="gpu", help="Run GPU build and test(s)", options={ "cpp": CPP_UNITTEST, "unittest": ( "run unit tests", [ "./tests/scripts/task_java_unittest.sh", "./tests/scripts/task_python_unittest_gpuonly.sh", ], ), }, ), generate_command( name="cpu", help="Run CPU build and test(s)", options={ "cpp": CPP_UNITTEST, "unittest": ( "run unit tests", [ "./tests/scripts/task_python_unittest.sh", ], ), }, ), generate_command( name="wasm", help="Run WASM build and test(s)", options={ "cpp": CPP_UNITTEST, "test": ("run WASM tests", ["./tests/scripts/task_web_wasm.sh"]), }, ), generate_command( name="arm", help="Run ARM build and test(s) (native or via QEMU on x86)", precheck=check_arm_qemu, options={ "cpp": CPP_UNITTEST, "python": ( "run full Python tests", [ "./tests/scripts/task_python_unittest.sh", ], ), }, ), ] def main(): description = """ Run CI jobs locally via Docker. This facilitates reproducing CI failures for fast iteration. Note that many of the Docker images required are large (the CPU and GPU images are both over 25GB) and may take some time to download on first use. """ parser = argparse.ArgumentParser(description=description) subparsers = parser.add_subparsers(dest="command") commands = {} # Add manually defined commands for func in [docs, serve_docs, lint]: add_subparser(func, subparsers) commands[cli_name(func.__name__)] = func # Add generated commands for func, options, help in generated: add_subparser(func, subparsers, options, help) commands[cli_name(func.__name__)] = func args = parser.parse_args() if args.command is None: # Command not found in list, error out parser.print_help() exit(1) func = commands[args.command] # Extract out the parsed args and invoke the relevant function kwargs = {k: getattr(args, k) for k in dir(args) if not k.startswith("_") and k != "command"} func(**kwargs) if __name__ == "__main__": main()