| #!/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. | |
| """Runner for the Comet micro benchmark suites on a single machine (typically EC2). | |
| The script is self-contained and can be downloaded on its own, before the | |
| repository is cloned: | |
| curl -sSLO https://raw.githubusercontent.com/apache/datafusion-comet/main/benchmarks/micro/run.py | |
| python3 run.py all | |
| Subcommands: | |
| setup install prerequisites, clone/update the repository, build a release | |
| run run the benchmark suites, one JVM per suite | |
| collect copy the generated results into benchmarks/results/micro | |
| publish commit the collected results and optionally open a pull request | |
| all setup + run + collect | |
| Examples: | |
| python3 run.py all --ref main | |
| python3 run.py run --only Cast | |
| python3 run.py run --suites my-suites.txt --heap 12g | |
| python3 run.py collect --publish | |
| python3 run.py publish --push --open-pr | |
| """ | |
| import argparse | |
| import json | |
| import os | |
| import platform | |
| import re | |
| import shutil | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import time | |
| import urllib.error | |
| import urllib.request | |
| import zipfile | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| BENCH_PACKAGE = "org.apache.spark.sql.benchmark" | |
| BENCH_SOURCE_DIR = Path("spark/src/test/scala/org/apache/spark/sql/benchmark") | |
| DEFAULT_REPO = "https://github.com/apache/datafusion-comet.git" | |
| DEFAULT_COMET_HOME = Path.home() / "datafusion-comet" | |
| DEFAULT_RUNS_ROOT = Path.home() / "comet-bench-runs" | |
| DEFAULT_HEAP = "8g" | |
| DEFAULT_JDK = "17" | |
| # Spark 4.x, the default build profile, does not compile on anything older | |
| MINIMUM_JDK = 17 | |
| DEFAULT_TIMEOUT_MINUTES = 60 | |
| # Only used when the distribution has no protoc package. prost-build needs a | |
| # protoc on the PATH to compile the Comet protobuf definitions. | |
| PROTOC_VERSION = "25.5" | |
| RESULTS_SUBDIR = Path("benchmarks") / "results" / "micro" | |
| IMDS_BASE = "http://169.254.169.254/latest" | |
| # Suites that legitimately take much longer than the default timeout allows. | |
| # Minutes, applied in place of --timeout for that suite only. | |
| SUITE_TIMEOUT_OVERRIDES = { | |
| "CometShuffleBenchmark": 180, | |
| } | |
| # Suites deliberately left out of the default set, with the reason. They can | |
| # still be run by naming them in a --suites file. Everything else found in | |
| # BENCH_SOURCE_DIR is run, so a new benchmark needs no change here. | |
| EXCLUDED_SUITES = { | |
| "CometTPCHQueryBenchmark": "needs TPC-H data, pass --data-location", | |
| "CometTPCDSQueryBenchmark": "needs TPC-DS data, pass --data-location", | |
| "CometTPCDSMicroBenchmark": "needs TPC-DS data, pass --data-location", | |
| "CometC2RIsolatedBench": "prints to stdout only, writes no results file", | |
| "CometReadHdfsBenchmark": "starts a local HDFS mini cluster", | |
| } | |
| # --------------------------------------------------------------------------- | |
| # small helpers | |
| # --------------------------------------------------------------------------- | |
| def log(message): | |
| stamp = datetime.now().strftime("%H:%M:%S") | |
| print(f"[{stamp}] {message}", flush=True) | |
| def fail(message): | |
| print(f"error: {message}", file=sys.stderr) | |
| sys.exit(1) | |
| def utc_now(): | |
| return datetime.now(timezone.utc) | |
| def format_duration(seconds): | |
| minutes, seconds = divmod(int(seconds), 60) | |
| hours, minutes = divmod(minutes, 60) | |
| if hours: | |
| return f"{hours}h{minutes:02d}m{seconds:02d}s" | |
| if minutes: | |
| return f"{minutes}m{seconds:02d}s" | |
| return f"{seconds}s" | |
| def run_command(cmd, cwd=None, env=None, log_path=None, timeout=None, check=True, dry_run=False): | |
| """Run a command, optionally sending its output to a log file.""" | |
| printable = " ".join(str(part) for part in cmd) | |
| if dry_run: | |
| log(f"[dry-run] {printable}") | |
| return 0 | |
| log(printable) | |
| if log_path is None: | |
| completed = subprocess.run(cmd, cwd=cwd, env=env, timeout=timeout) | |
| else: | |
| with open(log_path, "w") as handle: | |
| handle.write(f"$ {printable}\n\n") | |
| handle.flush() | |
| completed = subprocess.run( | |
| cmd, cwd=cwd, env=env, stdout=handle, stderr=subprocess.STDOUT, timeout=timeout | |
| ) | |
| if check and completed.returncode != 0: | |
| fail(f"command failed with exit code {completed.returncode}: {printable}") | |
| return completed.returncode | |
| def capture(cmd, cwd=None, env=None): | |
| """Run a command and return stripped stdout, or None if it fails.""" | |
| try: | |
| completed = subprocess.run( | |
| cmd, cwd=cwd, env=env, capture_output=True, text=True, timeout=300 | |
| ) | |
| except (OSError, subprocess.SubprocessError): | |
| return None | |
| if completed.returncode != 0: | |
| return None | |
| return completed.stdout.strip() | |
| def sudo_prefix(): | |
| if os.geteuid() == 0: | |
| return [] | |
| if shutil.which("sudo") is None: | |
| fail("this step needs root privileges but sudo was not found") | |
| return ["sudo"] | |
| def tail_file(path, lines=20): | |
| try: | |
| content = Path(path).read_text(errors="replace").splitlines() | |
| except OSError: | |
| return "" | |
| return "\n".join(content[-lines:]) | |
| def read_proc_field(path, prefix): | |
| """The value after the colon of the first line in `path` starting with `prefix`.""" | |
| try: | |
| for line in Path(path).read_text().splitlines(): | |
| if line.startswith(prefix): | |
| return line.split(":", 1)[1].strip() | |
| except (OSError, IndexError): | |
| pass | |
| return None | |
| # The last lines of a failed run are Maven's epilogue, which says nothing about | |
| # the cause. Look for the exception that started it instead. | |
| EXCEPTION_PATTERN = re.compile(r"^\s*[\w.$]+(Exception|Error)(:|\b)") | |
| MAVEN_FAILURE_PATTERN = re.compile(r"^\[ERROR\] Failed to execute goal") | |
| def failure_excerpt(path, context=12, fallback_lines=20): | |
| """The first exception in a log, falling back to Maven's message or the tail.""" | |
| try: | |
| content = Path(path).read_text(errors="replace").splitlines() | |
| except OSError: | |
| return "" | |
| for pattern in (EXCEPTION_PATTERN, MAVEN_FAILURE_PATTERN): | |
| for index, line in enumerate(content): | |
| if pattern.search(line): | |
| return "\n".join(content[index : index + context]) | |
| return "\n".join(content[-fallback_lines:]) | |
| # --------------------------------------------------------------------------- | |
| # environment | |
| # --------------------------------------------------------------------------- | |
| def find_checkout_from_script(): | |
| """Return the Comet checkout this script lives in, if any.""" | |
| for parent in Path(__file__).resolve().parents: | |
| if (parent / "Makefile").is_file() and (parent / "native" / "Cargo.toml").is_file(): | |
| return parent | |
| return None | |
| def resolve_comet_home(args): | |
| if args.comet_home: | |
| return Path(args.comet_home).expanduser().resolve() | |
| if os.environ.get("COMET_HOME"): | |
| return Path(os.environ["COMET_HOME"]).expanduser().resolve() | |
| checkout = find_checkout_from_script() | |
| if checkout is not None: | |
| return checkout | |
| return DEFAULT_COMET_HOME | |
| def require_comet_home(args): | |
| comet_home = resolve_comet_home(args) | |
| if not (comet_home / "Makefile").is_file(): | |
| fail(f"{comet_home} is not a Comet checkout, run `setup` first or pass --comet-home") | |
| return comet_home | |
| def detect_java_home(): | |
| if os.environ.get("JAVA_HOME"): | |
| return os.environ["JAVA_HOME"] | |
| candidates = [] | |
| for pattern in ("java-*-amazon-corretto*", "java-*-openjdk*", "jdk-*"): | |
| candidates.extend(Path("/usr/lib/jvm").glob(pattern)) | |
| def version_of(path): | |
| match = re.search(r"(\d+)", path.name) | |
| return int(match.group(1)) if match else 0 | |
| # Newest first, so that an older JDK left on the machine is not picked up | |
| ordered = sorted(candidates, key=version_of, reverse=True) | |
| for candidate in ordered: | |
| if (candidate / "bin" / "javac").is_file(): | |
| return str(candidate) | |
| java = shutil.which("java") | |
| if java is not None: | |
| resolved = Path(java).resolve() | |
| return str(resolved.parent.parent) | |
| return None | |
| def base_env(comet_home): | |
| """Environment shared by the build and the benchmark runs.""" | |
| env = dict(os.environ) | |
| cargo_bin = Path.home() / ".cargo" / "bin" | |
| if cargo_bin.is_dir() and str(cargo_bin) not in env.get("PATH", ""): | |
| env["PATH"] = f"{cargo_bin}{os.pathsep}{env.get('PATH', '')}" | |
| java_home = detect_java_home() | |
| if java_home: | |
| env["JAVA_HOME"] = java_home | |
| java_bin = Path(java_home) / "bin" | |
| if str(java_bin) not in env.get("PATH", ""): | |
| env["PATH"] = f"{java_bin}{os.pathsep}{env.get('PATH', '')}" | |
| env["COMET_CONF_DIR"] = str(comet_home / "conf") | |
| return env | |
| def java_major_version(env): | |
| """Major version of the JDK the build will use, or None if unknown.""" | |
| javac = Path(env["JAVA_HOME"]) / "bin" / "javac" if env.get("JAVA_HOME") else "javac" | |
| try: | |
| completed = subprocess.run( | |
| [str(javac), "-version"], capture_output=True, text=True, env=env, timeout=60 | |
| ) | |
| except (OSError, subprocess.SubprocessError): | |
| return None | |
| match = re.search(r"(\d+)", (completed.stdout or "") + (completed.stderr or "")) | |
| return int(match.group(1)) if match else None | |
| def require_supported_jdk(env): | |
| """Spark 4.x needs JDK 17, and javac silently stubs java.lang.Record below it.""" | |
| major = java_major_version(env) | |
| if major is None: | |
| fail(f"could not run javac from JAVA_HOME={env.get('JAVA_HOME')}, install a JDK") | |
| if major < MINIMUM_JDK: | |
| fail( | |
| f"JDK {major} found at JAVA_HOME={env.get('JAVA_HOME')}, but the default Spark 4.x " | |
| f"profile needs JDK {MINIMUM_JDK} or later. Compiling with an older JDK fails with " | |
| "'Class java.lang.Record not found'. Install a newer JDK, point JAVA_HOME at it, and " | |
| "run `./mvnw clean` to discard classes built against the old one." | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # setup | |
| # --------------------------------------------------------------------------- | |
| def install_packages(args): | |
| dnf_packages = [ | |
| "git", | |
| "make", | |
| "cmake", | |
| "gcc", | |
| "gcc-c++", | |
| "protobuf-compiler", | |
| "python3", | |
| f"java-{args.jdk}-amazon-corretto-devel", | |
| ] | |
| apt_packages = [ | |
| "git", | |
| "make", | |
| "cmake", | |
| "build-essential", | |
| "protobuf-compiler", | |
| "python3", | |
| "curl", | |
| f"openjdk-{args.jdk}-jdk", | |
| ] | |
| if shutil.which("dnf"): | |
| run_command(sudo_prefix() + ["dnf", "install", "-y"] + dnf_packages, dry_run=args.dry_run) | |
| elif shutil.which("yum"): | |
| run_command(sudo_prefix() + ["yum", "install", "-y"] + dnf_packages, dry_run=args.dry_run) | |
| elif shutil.which("apt-get"): | |
| run_command(sudo_prefix() + ["apt-get", "update"], dry_run=args.dry_run) | |
| run_command( | |
| sudo_prefix() + ["apt-get", "install", "-y"] + apt_packages, dry_run=args.dry_run | |
| ) | |
| else: | |
| log("no supported package manager found, skipping package installation") | |
| install_protoc(args) | |
| def protoc_asset_name(): | |
| machine = platform.machine().lower() | |
| if platform.system() == "Darwin": | |
| return f"protoc-{PROTOC_VERSION}-osx-universal_binary.zip" | |
| if machine in ("aarch64", "arm64"): | |
| return f"protoc-{PROTOC_VERSION}-linux-aarch_64.zip" | |
| return f"protoc-{PROTOC_VERSION}-linux-x86_64.zip" | |
| def install_protoc(args): | |
| """Install protoc from the protobuf releases when the distribution has none. | |
| Only the Rust build needs a protoc on the PATH. The JVM side downloads its | |
| own through protoc-jar-maven-plugin. | |
| """ | |
| if shutil.which("protoc"): | |
| log(f"protoc already installed: {capture(['protoc', '--version'])}") | |
| return | |
| asset = protoc_asset_name() | |
| url = f"https://github.com/protocolbuffers/protobuf/releases/download/v{PROTOC_VERSION}/{asset}" | |
| if args.dry_run: | |
| log(f"[dry-run] download {url} and install bin/protoc into /usr/local/bin") | |
| return | |
| log(f"protoc not found, installing {PROTOC_VERSION} from {url}") | |
| with tempfile.TemporaryDirectory() as work_dir: | |
| archive = Path(work_dir) / asset | |
| try: | |
| with urllib.request.urlopen(url, timeout=120) as response: | |
| archive.write_bytes(response.read()) | |
| with zipfile.ZipFile(archive) as bundle: | |
| bundle.extract("bin/protoc", work_dir) | |
| except (OSError, urllib.error.URLError, zipfile.BadZipFile) as error: | |
| fail(f"could not install protoc: {error}") | |
| run_command( | |
| sudo_prefix() + ["install", "-m", "755", f"{work_dir}/bin/protoc", "/usr/local/bin/"] | |
| ) | |
| log(f"installed {capture(['/usr/local/bin/protoc', '--version'])}") | |
| def install_rust(args): | |
| if shutil.which("cargo") or (Path.home() / ".cargo" / "bin" / "cargo").is_file(): | |
| log("rust toolchain already installed") | |
| return | |
| log("installing rust toolchain") | |
| if args.dry_run: | |
| log("[dry-run] curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y") | |
| return | |
| installer = subprocess.run( | |
| ["curl", "--proto", "=https", "--tlsv1.2", "-sSf", "https://sh.rustup.rs"], | |
| capture_output=True, | |
| ) | |
| if installer.returncode != 0: | |
| fail("failed to download rustup") | |
| shell = subprocess.run(["sh", "-s", "--", "-y"], input=installer.stdout) | |
| if shell.returncode != 0: | |
| fail("rustup installation failed") | |
| def clone_or_update(args, comet_home): | |
| # .git is a directory in a normal clone and a file in a worktree | |
| if not (comet_home / ".git").exists(): | |
| comet_home.parent.mkdir(parents=True, exist_ok=True) | |
| run_command( | |
| ["git", "clone", args.repo, str(comet_home)], | |
| dry_run=args.dry_run, | |
| ) | |
| ref = args.ref or "main" | |
| else: | |
| log(f"using existing checkout at {comet_home}") | |
| ref = args.ref | |
| if ref: | |
| run_command(["git", "fetch", "origin", "--tags"], cwd=comet_home, dry_run=args.dry_run) | |
| run_command(["git", "checkout", ref], cwd=comet_home, dry_run=args.dry_run) | |
| # Fast-forward when the ref is a branch. Detached heads and tags fail | |
| # here, which is fine. | |
| run_command( | |
| ["git", "merge", "--ff-only", f"origin/{ref}"], | |
| cwd=comet_home, | |
| check=False, | |
| dry_run=args.dry_run, | |
| ) | |
| def build_release(args, comet_home): | |
| env = base_env(comet_home) | |
| if env.get("JAVA_HOME") is None: | |
| fail("JAVA_HOME could not be determined, install a JDK or export JAVA_HOME") | |
| require_supported_jdk(env) | |
| if args.profile: | |
| env["PROFILES"] = args.profile | |
| log("building Comet in release mode, this takes a while") | |
| run_command(["make", "release"], cwd=comet_home, env=env, dry_run=args.dry_run) | |
| def cmd_setup(args): | |
| comet_home = resolve_comet_home(args) | |
| if not args.skip_packages: | |
| install_packages(args) | |
| install_rust(args) | |
| clone_or_update(args, comet_home) | |
| if not args.skip_build: | |
| build_release(args, comet_home) | |
| log(f"setup complete: {comet_home}") | |
| return comet_home | |
| # --------------------------------------------------------------------------- | |
| # run | |
| # --------------------------------------------------------------------------- | |
| # Top-level `object Name`, with whatever it extends up to the start of its body | |
| OBJECT_PATTERN = re.compile(r"^object\s+(\w+)\s*(extends[^{]*)?\{", re.MULTILINE) | |
| def discover_suites(comet_home): | |
| """The benchmark entry points, read from the sources rather than listed here. | |
| An entry point is a top-level `object` in the benchmark package that either | |
| mixes in a `*Benchmark*` base, which supplies `main`, or declares its own. | |
| Helpers such as `TPCDSSchemaHelper` do neither and are skipped. Discovering | |
| them this way means a newly added benchmark runs without anyone having to | |
| remember to also add it to this file. | |
| """ | |
| source_dir = comet_home / BENCH_SOURCE_DIR | |
| if not source_dir.is_dir(): | |
| fail(f"no benchmark sources at {source_dir}, is {comet_home} a Comet checkout?") | |
| suites = set() | |
| for path in sorted(source_dir.glob("*.scala")): | |
| text = path.read_text(errors="replace") | |
| declares_main = "def main(" in text | |
| for name, bases in OBJECT_PATTERN.findall(text): | |
| if "Benchmark" in bases or declares_main: | |
| suites.add(name) | |
| if not suites: | |
| fail(f"no benchmark objects found in {source_dir}") | |
| return sorted(suites) | |
| def stale_suite_entries(comet_home): | |
| """Names in this file that no longer match the benchmark sources. | |
| EXCLUDED_SUITES and SUITE_TIMEOUT_OVERRIDES are keyed by suite name, so a | |
| rename upstream silently turns an exclusion into a suite that runs, or a | |
| timeout override into one that does not apply. Report them instead. | |
| """ | |
| discovered = set(discover_suites(comet_home)) | |
| return [ | |
| f"{suite} is listed in {label} but is not a benchmark in {BENCH_SOURCE_DIR}" | |
| for label, names in ( | |
| ("EXCLUDED_SUITES", EXCLUDED_SUITES), | |
| ("SUITE_TIMEOUT_OVERRIDES", SUITE_TIMEOUT_OVERRIDES), | |
| ) | |
| for suite in sorted(set(names) - discovered) | |
| ] | |
| def load_suites(args, comet_home): | |
| if args.suites: | |
| path = Path(args.suites).expanduser() | |
| if not path.is_file(): | |
| fail(f"suite file not found: {path}") | |
| suites = [] | |
| for line in path.read_text().splitlines(): | |
| line = line.split("#", 1)[0].strip() | |
| if line: | |
| suites.append(line.rsplit(".", 1)[-1]) | |
| else: | |
| suites = [s for s in discover_suites(comet_home) if s not in EXCLUDED_SUITES] | |
| if args.only: | |
| suites = [s for s in suites if any(re.search(pattern, s) for pattern in args.only)] | |
| if args.skip: | |
| suites = [s for s in suites if not any(re.search(pattern, s) for pattern in args.skip)] | |
| if not suites: | |
| fail("no suites selected") | |
| return suites | |
| def benchmark_args(comet_home, env, profile, heap): | |
| """The benchmark invocation, read from the Makefile's `print-benchmark-args`. | |
| The Makefile is the one definition of how a suite is invoked. The | |
| `benchmark-%` target itself cannot be used here because its `release` | |
| prerequisite would rebuild Comet before every suite. | |
| """ | |
| command = ["make", "print-benchmark-args", f"BENCH_HEAP={heap}"] | |
| if profile: | |
| command.append(f"PROFILES={profile}") | |
| output = capture(command, cwd=comet_home, env=env) | |
| if output is None: | |
| fail(f"`{' '.join(command)}` failed in {comet_home}, is make installed?") | |
| values = {} | |
| for line in output.splitlines(): | |
| key, separator, value = line.partition("=") | |
| if separator: | |
| values[key.strip()] = value.strip() | |
| missing = [key for key in ("MAVEN_OPTS", "MVN_ARGS") if not values.get(key)] | |
| if missing: | |
| fail(f"print-benchmark-args did not report {', '.join(missing)}") | |
| return values | |
| def suite_command(suite, mvn_args, profile): | |
| """The `mvnw` invocation for one suite, run from the `spark` directory.""" | |
| command = ["../mvnw"] + mvn_args.split() + [f"-Dexec.mainClass={BENCH_PACKAGE}.{suite}"] | |
| if profile: | |
| command.append(profile) | |
| return command | |
| def warn_unless_release_build(comet_home): | |
| release_dir = comet_home / "native" / "target" / "release" | |
| if any((release_dir / name).is_file() for name in ("libcomet.so", "libcomet.dylib")): | |
| return | |
| log( | |
| "warning: no native release build found in native/target/release. " | |
| "Run `setup` or `make release` first, otherwise the numbers will not reflect " | |
| "a release build." | |
| ) | |
| def git_info(comet_home): | |
| return { | |
| "commit": capture(["git", "rev-parse", "HEAD"], cwd=comet_home), | |
| "commit_short": capture(["git", "rev-parse", "--short", "HEAD"], cwd=comet_home), | |
| "branch": capture(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=comet_home), | |
| "describe": capture(["git", "log", "-1", "--pretty=%s"], cwd=comet_home), | |
| } | |
| def cmd_run(args, comet_home=None): | |
| comet_home = comet_home or require_comet_home(args) | |
| suites = load_suites(args, comet_home) | |
| for problem in stale_suite_entries(comet_home): | |
| log(f"warning: {problem}") | |
| if args.list: | |
| for suite in suites: | |
| print(f"{BENCH_PACKAGE}.{suite}") | |
| for suite, reason in sorted(EXCLUDED_SUITES.items()): | |
| print(f"# {BENCH_PACKAGE}.{suite} ({reason})") | |
| return None | |
| warn_unless_release_build(comet_home) | |
| runs_root = Path(args.runs_root).expanduser() | |
| run_dir = runs_root / utc_now().strftime("%Y%m%dT%H%M%SZ") | |
| log_dir = run_dir / "logs" | |
| log_dir.mkdir(parents=True, exist_ok=True) | |
| env = base_env(comet_home) | |
| if env.get("JAVA_HOME") is None: | |
| fail("JAVA_HOME could not be determined, install a JDK or export JAVA_HOME") | |
| require_supported_jdk(env) | |
| bench = benchmark_args(comet_home, env, args.profile, args.heap) | |
| env["MAVEN_OPTS"] = bench["MAVEN_OPTS"] | |
| env["SPARK_GENERATE_BENCHMARK_FILES"] = "1" | |
| spark_dir = comet_home / "spark" | |
| started = time.time() | |
| started_utc = utc_now() | |
| log(f"running {len(suites)} suite(s), logs in {log_dir}") | |
| results = [] | |
| for index, suite in enumerate(suites, start=1): | |
| log_path = log_dir / f"{suite}.log" | |
| timeout_minutes = SUITE_TIMEOUT_OVERRIDES.get(suite, args.timeout) | |
| timeout = timeout_minutes * 60 if timeout_minutes else None | |
| if args.timeout and timeout_minutes != args.timeout: | |
| log(f"[{index}/{len(suites)}] {suite} (timeout raised to {timeout_minutes}m)") | |
| else: | |
| log(f"[{index}/{len(suites)}] {suite}") | |
| suite_started = time.time() | |
| status = "ok" | |
| try: | |
| code = run_command( | |
| suite_command(suite, bench["MVN_ARGS"], args.profile), | |
| cwd=spark_dir, | |
| env=env, | |
| log_path=log_path, | |
| timeout=timeout, | |
| check=False, | |
| dry_run=args.dry_run, | |
| ) | |
| if code != 0: | |
| status = "failed" | |
| except subprocess.TimeoutExpired: | |
| status = "timeout" | |
| duration = time.time() - suite_started | |
| results.append( | |
| { | |
| "suite": suite, | |
| "status": status, | |
| "duration_seconds": round(duration, 1), | |
| "log": str(log_path), | |
| } | |
| ) | |
| if status == "ok": | |
| log(f" ok in {format_duration(duration)}") | |
| else: | |
| excerpt = tail_file(log_path) if status == "timeout" else failure_excerpt(log_path) | |
| log(f" {status} after {format_duration(duration)}, from {log_path}:") | |
| print(excerpt, flush=True) | |
| summary = { | |
| "started_utc": started_utc.isoformat(), | |
| "finished_utc": utc_now().isoformat(), | |
| "duration_seconds": round(time.time() - started, 1), | |
| "comet_home": str(comet_home), | |
| "profile": args.profile or "default", | |
| "heap": args.heap, | |
| "git": git_info(comet_home), | |
| "suites": results, | |
| } | |
| summary_path = run_dir / "summary.json" | |
| summary_path.write_text(json.dumps(summary, indent=2) + "\n") | |
| failed = [entry for entry in results if entry["status"] != "ok"] | |
| log(f"finished in {format_duration(summary['duration_seconds'])}, summary: {summary_path}") | |
| for entry in results: | |
| marker = " " if entry["status"] == "ok" else "!" | |
| log( | |
| f" {marker} {entry['suite']:<45} {entry['status']:<8} " | |
| f"{format_duration(entry['duration_seconds'])}" | |
| ) | |
| if failed: | |
| log(f"{len(failed)} suite(s) did not complete successfully") | |
| return run_dir | |
| # --------------------------------------------------------------------------- | |
| # collect | |
| # --------------------------------------------------------------------------- | |
| def imds(path): | |
| """Read an EC2 instance metadata value, returning None when not on EC2.""" | |
| try: | |
| token_request = urllib.request.Request( | |
| f"{IMDS_BASE}/api/token", | |
| method="PUT", | |
| headers={"X-aws-ec2-metadata-token-ttl-seconds": "60"}, | |
| ) | |
| token = urllib.request.urlopen(token_request, timeout=2).read().decode() | |
| value_request = urllib.request.Request( | |
| f"{IMDS_BASE}/meta-data/{path}", | |
| headers={"X-aws-ec2-metadata-token": token}, | |
| ) | |
| return urllib.request.urlopen(value_request, timeout=2).read().decode() | |
| except Exception: | |
| return None | |
| def machine_info(env): | |
| info = { | |
| "instance_type": imds("instance-type") or "unknown (not an EC2 instance?)", | |
| "availability_zone": imds("placement/availability-zone") or "unknown", | |
| "cpus": str(os.cpu_count()), | |
| "cpu_model": "unknown", | |
| "memory": "unknown", | |
| "os": platform.platform(), | |
| "java": "unknown", | |
| "rustc": capture(["rustc", "--version"], env=env) or "unknown", | |
| } | |
| cpu_model = read_proc_field("/proc/cpuinfo", "model name") | |
| if cpu_model: | |
| info["cpu_model"] = cpu_model | |
| mem_total = read_proc_field("/proc/meminfo", "MemTotal") | |
| if mem_total: | |
| # "16311236 kB" | |
| info["memory"] = f"{int(mem_total.split()[0]) / 1024 / 1024:.1f} GiB" | |
| if info["cpu_model"] == "unknown" and platform.system() == "Darwin": | |
| # /proc does not exist on macOS, which is where a local collect runs | |
| info["cpu_model"] = capture(["sysctl", "-n", "machdep.cpu.brand_string"]) or "unknown" | |
| memory_bytes = capture(["sysctl", "-n", "hw.memsize"]) | |
| if memory_bytes: | |
| info["memory"] = f"{int(memory_bytes) / 1024**3:.1f} GiB" | |
| # `java -version` writes to stderr rather than stdout | |
| java_version = None | |
| try: | |
| completed = subprocess.run( | |
| ["java", "-version"], capture_output=True, text=True, env=env, timeout=60 | |
| ) | |
| java_version = (completed.stderr or completed.stdout).strip().splitlines()[0] | |
| except (OSError, subprocess.SubprocessError, IndexError): | |
| pass | |
| info["java"] = java_version or "unknown" | |
| return info | |
| def latest_run_dir(runs_root): | |
| runs_root = Path(runs_root).expanduser() | |
| if not runs_root.is_dir(): | |
| return None | |
| candidates = sorted(path for path in runs_root.iterdir() if (path / "summary.json").is_file()) | |
| return candidates[-1] if candidates else None | |
| LICENSE_HEADER = """<!-- | |
| 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. | |
| --> | |
| """ | |
| def write_run_info(destination, summary, info, copied): | |
| git = summary.get("git", {}) | |
| lines = [ | |
| LICENSE_HEADER, | |
| "# Comet Micro Benchmark Run", | |
| "", | |
| "Generated by `benchmarks/micro/run.py collect`.", | |
| "", | |
| "## Environment", | |
| "", | |
| "| Field | Value |", | |
| "| --- | --- |", | |
| f"| Date (UTC) | {summary.get('started_utc', 'unknown')} |", | |
| f"| Instance type | {info['instance_type']} |", | |
| f"| Availability zone | {info['availability_zone']} |", | |
| f"| CPU | {info['cpu_model']} ({info['cpus']} vCPU) |", | |
| f"| Memory | {info['memory']} |", | |
| f"| OS | {info['os']} |", | |
| f"| Java | {info['java']} |", | |
| f"| Rust | {info['rustc']} |", | |
| f"| Maven profile | {summary.get('profile', 'default')} |", | |
| f"| Benchmark heap | {summary.get('heap', 'unknown')} |", | |
| f"| Comet branch | {git.get('branch', 'unknown')} |", | |
| f"| Comet commit | {git.get('commit', 'unknown')} |", | |
| f"| Total run time | {format_duration(summary.get('duration_seconds', 0))} |", | |
| "", | |
| "## Suites", | |
| "", | |
| "| Suite | Status | Duration | Results file |", | |
| "| --- | --- | --- | --- |", | |
| ] | |
| for entry in summary.get("suites", []): | |
| results_file = copied.get(entry["suite"], "-") | |
| lines.append( | |
| f"| {entry['suite']} | {entry['status']} | " | |
| f"{format_duration(entry['duration_seconds'])} | {results_file} |" | |
| ) | |
| lines.append("") | |
| (destination / "RUN-INFO.md").write_text("\n".join(lines)) | |
| def cmd_collect(args, comet_home=None, run_dir=None): | |
| comet_home = comet_home or require_comet_home(args) | |
| if run_dir is None: | |
| run_dir = ( | |
| Path(args.run_dir).expanduser() if args.run_dir else latest_run_dir(args.runs_root) | |
| ) | |
| if run_dir is None or not (run_dir / "summary.json").is_file(): | |
| fail("no benchmark run found, pass --run-dir or run `run` first") | |
| summary = json.loads((run_dir / "summary.json").read_text()) | |
| source = comet_home / "spark" / "benchmarks" | |
| if not source.is_dir(): | |
| fail(f"no results directory at {source}, did the suites run?") | |
| destination = comet_home / RESULTS_SUBDIR | |
| destination.mkdir(parents=True, exist_ok=True) | |
| started = datetime.fromisoformat(summary["started_utc"]).timestamp() | |
| copied = {} | |
| for path in sorted(source.glob("*.txt")): | |
| if not args.all_results and path.stat().st_mtime < started: | |
| continue | |
| shutil.copy2(path, destination / path.name) | |
| suite = path.name.split("-")[0] | |
| copied[suite] = path.name | |
| log(f"collected {path.name}") | |
| if not copied: | |
| log("no results files were produced by this run") | |
| info = machine_info(base_env(comet_home)) | |
| write_run_info(destination, summary, info, copied) | |
| log(f"wrote {destination / 'RUN-INFO.md'}") | |
| log(f"results are in {destination}") | |
| if args.publish: | |
| cmd_publish(args, comet_home=comet_home) | |
| return destination | |
| # --------------------------------------------------------------------------- | |
| # publish | |
| # --------------------------------------------------------------------------- | |
| PR_BODY_TEMPLATE = """## Which issue does this PR close? | |
| N/A | |
| ## Rationale for this change | |
| Refresh the checked-in micro benchmark results so that changes in expression and | |
| operator performance are visible over time. | |
| ## What changes are included in this PR? | |
| Micro benchmark results produced by `benchmarks/micro/run.py` on a {instance_type} | |
| instance, from Comet commit {commit}. | |
| ## How are these changes tested? | |
| These are benchmark result files only, produced by running the micro benchmark | |
| suites. See `benchmarks/results/micro/RUN-INFO.md` for the environment details. | |
| """ | |
| def cmd_publish(args, comet_home=None): | |
| comet_home = comet_home or require_comet_home(args) | |
| destination = comet_home / RESULTS_SUBDIR | |
| if not destination.is_dir(): | |
| fail(f"no collected results at {destination}, run `collect` first") | |
| run_info = destination / "RUN-INFO.md" | |
| instance_type = "unknown" | |
| if run_info.is_file(): | |
| for line in run_info.read_text().splitlines(): | |
| if line.startswith("| Instance type |"): | |
| instance_type = line.split("|")[2].strip() | |
| break | |
| commit = git_info(comet_home).get("commit_short") or "unknown" | |
| branch = args.branch or f"benchmark-results-{utc_now().strftime('%Y%m%d')}" | |
| title = "chore: add micro benchmark results" | |
| if not instance_type.startswith("unknown"): | |
| title = f"{title} from {instance_type}" | |
| run_command(["git", "checkout", "-b", branch], cwd=comet_home, dry_run=args.dry_run) | |
| run_command( | |
| ["git", "add", str(RESULTS_SUBDIR)], | |
| cwd=comet_home, | |
| dry_run=args.dry_run, | |
| ) | |
| run_command( | |
| ["git", "commit", "-m", title], | |
| cwd=comet_home, | |
| dry_run=args.dry_run, | |
| ) | |
| if not args.push: | |
| log("commit created, push it and open a pull request with:") | |
| print(f" git -C {comet_home} push -u {args.remote} {branch}") | |
| print(f' gh pr create --base {args.base} --title "{title}"') | |
| return | |
| run_command( | |
| ["git", "push", "-u", args.remote, branch], | |
| cwd=comet_home, | |
| dry_run=args.dry_run, | |
| ) | |
| if not args.open_pr: | |
| log("branch pushed, open the pull request when ready") | |
| return | |
| if shutil.which("gh") is None: | |
| log("gh is not installed, open the pull request manually") | |
| return | |
| body_path = comet_home / ".git" / "comet-benchmark-pr-body.md" | |
| body_path.write_text(PR_BODY_TEMPLATE.format(instance_type=instance_type, commit=commit)) | |
| run_command( | |
| [ | |
| "gh", | |
| "pr", | |
| "create", | |
| "--base", | |
| args.base, | |
| "--title", | |
| title, | |
| "--body-file", | |
| str(body_path), | |
| ], | |
| cwd=comet_home, | |
| dry_run=args.dry_run, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # all | |
| # --------------------------------------------------------------------------- | |
| def cmd_all(args): | |
| comet_home = cmd_setup(args) | |
| run_dir = cmd_run(args, comet_home=comet_home) | |
| if run_dir is None: | |
| return | |
| cmd_collect(args, comet_home=comet_home, run_dir=run_dir) | |
| # --------------------------------------------------------------------------- | |
| # argument parsing | |
| # --------------------------------------------------------------------------- | |
| def add_common_arguments(parser): | |
| parser.add_argument( | |
| "--comet-home", | |
| help="Comet checkout to use (default: the checkout this script lives in, " | |
| f"otherwise {DEFAULT_COMET_HOME})", | |
| ) | |
| parser.add_argument("--profile", help="Maven profile to build and run with, e.g. -Pspark-3.5") | |
| parser.add_argument( | |
| "--dry-run", action="store_true", help="print the commands without running them" | |
| ) | |
| def add_setup_arguments(parser): | |
| parser.add_argument("--repo", default=DEFAULT_REPO, help="repository to clone") | |
| parser.add_argument("--ref", help="branch, tag or commit to check out") | |
| parser.add_argument("--jdk", default=DEFAULT_JDK, help="JDK major version to install") | |
| parser.add_argument( | |
| "--skip-packages", action="store_true", help="do not install system packages or Rust" | |
| ) | |
| parser.add_argument("--skip-build", action="store_true", help="do not run `make release`") | |
| def add_run_arguments(parser): | |
| parser.add_argument("--suites", help="file listing suites to run, one per line") | |
| parser.add_argument( | |
| "--only", action="append", metavar="REGEX", help="only run suites matching this pattern" | |
| ) | |
| parser.add_argument( | |
| "--skip", action="append", metavar="REGEX", help="skip suites matching this pattern" | |
| ) | |
| parser.add_argument("--heap", default=DEFAULT_HEAP, help="JVM max heap for each suite") | |
| parser.add_argument( | |
| "--timeout", | |
| type=int, | |
| default=DEFAULT_TIMEOUT_MINUTES, | |
| help="per-suite timeout in minutes, 0 to disable", | |
| ) | |
| parser.add_argument("--list", action="store_true", help="list the selected suites and exit") | |
| def add_runs_root_argument(parser): | |
| parser.add_argument( | |
| "--runs-root", default=str(DEFAULT_RUNS_ROOT), help="directory holding per-run logs" | |
| ) | |
| def add_collect_arguments(parser): | |
| parser.add_argument("--run-dir", help="run directory to collect (default: most recent)") | |
| parser.add_argument( | |
| "--all-results", | |
| action="store_true", | |
| help="collect every results file, not only those written by this run", | |
| ) | |
| parser.add_argument( | |
| "--publish", action="store_true", help="commit the results after collecting them" | |
| ) | |
| def add_publish_arguments(parser): | |
| parser.add_argument("--branch", help="branch name to create (default: benchmark-results-DATE)") | |
| parser.add_argument("--remote", default="origin", help="git remote to push to") | |
| parser.add_argument("--base", default="main", help="base branch for the pull request") | |
| parser.add_argument("--push", action="store_true", help="push the branch to the remote") | |
| parser.add_argument( | |
| "--open-pr", action="store_true", help="open a pull request with gh after pushing" | |
| ) | |
| def parse_arguments(argv): | |
| parser = argparse.ArgumentParser( | |
| description="Run the Comet micro benchmark suites and publish the results.", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| ) | |
| subparsers = parser.add_subparsers(dest="command", required=True) | |
| setup = subparsers.add_parser("setup", help="install prerequisites and build Comet") | |
| add_common_arguments(setup) | |
| add_setup_arguments(setup) | |
| setup.set_defaults(func=cmd_setup) | |
| run = subparsers.add_parser("run", help="run the benchmark suites") | |
| add_common_arguments(run) | |
| add_run_arguments(run) | |
| add_runs_root_argument(run) | |
| run.set_defaults(func=cmd_run) | |
| collect = subparsers.add_parser("collect", help="copy results into benchmarks/results/micro") | |
| add_common_arguments(collect) | |
| add_collect_arguments(collect) | |
| add_runs_root_argument(collect) | |
| add_publish_arguments(collect) | |
| collect.set_defaults(func=cmd_collect) | |
| publish = subparsers.add_parser("publish", help="commit and optionally open a pull request") | |
| add_common_arguments(publish) | |
| add_publish_arguments(publish) | |
| publish.set_defaults(func=cmd_publish) | |
| every = subparsers.add_parser("all", help="setup, run and collect in one go") | |
| add_common_arguments(every) | |
| add_setup_arguments(every) | |
| add_run_arguments(every) | |
| add_collect_arguments(every) | |
| add_runs_root_argument(every) | |
| add_publish_arguments(every) | |
| every.set_defaults(func=cmd_all) | |
| return parser.parse_args(argv) | |
| def main(argv=None): | |
| args = parse_arguments(argv if argv is not None else sys.argv[1:]) | |
| if getattr(args, "timeout", None) == 0: | |
| args.timeout = None | |
| args.func(args) | |
| if __name__ == "__main__": | |
| main() |