| #!/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() |