This is an automated email from the ASF dual-hosted git repository.
yihua pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hudi-rs.git
The following commit(s) were added to refs/heads/main by this push:
new b86c9be5 feat(benchmark): add AWS EC2 setup for the TPC-H harness
(#718)
b86c9be5 is described below
commit b86c9be5755398972af130939ee6dcf143904c25
Author: Y Ethan Guo <[email protected]>
AuthorDate: Fri Sep 4 16:16:03 2026 -0700
feat(benchmark): add AWS EC2 setup for the TPC-H harness (#718)
---
benchmark/tpch/README.md | 112 +++++++++++++++++
benchmark/tpch/infra/aws/bootstrap.sh | 120 +++++++++++++++++++
benchmark/tpch/infra/aws/sync.sh | 71 +++++++++++
benchmark/tpch/infra/gcp/bootstrap.sh | 6 +-
benchmark/tpch/infra/spark/spark-defaults.conf | 3 +
benchmark/tpch/run.sh | 160 ++++++++++++++++++++++---
benchmark/tpch/src/config.rs | 4 +
benchmark/tpch/src/main.rs | 69 +++++++++--
8 files changed, 516 insertions(+), 29 deletions(-)
diff --git a/benchmark/tpch/README.md b/benchmark/tpch/README.md
index 7f8aa995..f47eb6a5 100644
--- a/benchmark/tpch/README.md
+++ b/benchmark/tpch/README.md
@@ -60,8 +60,18 @@ make bench-tpch QUERIES=1,6,17 SF=10
# Run against cloud-hosted data
make bench-tpch ENGINE=datafusion SF=100 HUDI_DIR=gs://bucket/sf100-hudi
+make bench-tpch ENGINE=datafusion SF=100 HUDI_DIR=s3://bucket/sf100-hudi
```
+Credentials come from the environment for both engines: DataFusion reads the
+`AWS_*` / `GOOGLE_*` / `AZURE_*` variables, and on a cloud VM with an attached
+instance role or service account neither engine needs any variable set. For S3
+outside `us-east-1`, set `AWS_REGION` (`object_store` defaults to `us-east-1`
+when the region is neither configured nor derivable from the URL).
+
+The two sections below cover running on a cloud VM, one per provider; they are
+alternatives, so follow whichever matches your setup.
+
## GCP VM
### One-time setup
@@ -119,3 +129,105 @@ gcloud compute instances start bench-vm
--zone=us-central1-a
The bootstrap script only runs once (guarded by a sentinel file),
so restarting the VM is fast.
+
+## AWS EC2
+
+### One-time setup
+
+Launch an Amazon Linux 2023 instance with an instance profile that grants
+S3 access to the benchmark bucket and includes the
+`AmazonSSMManagedInstanceCore` policy (for Session Manager access). The
+instance needs outbound internet access for the package, crate, PyPI and
+Maven downloads.
+
+Get the repo onto the instance first. A fresh Amazon Linux 2023 image has no
+`git` (bootstrap is what installs it), so either sync from a local checkout,
+which needs nothing preinstalled on the instance:
+
+```bash
+bash benchmark/tpch/infra/aws/sync.sh i-0123456789abcdef0 us-west-2
~/.ssh/key.pem
+```
+
+or install `git` on the instance and clone there:
+
+```bash
+sudo dnf install -y git && git clone <repo-url> ~/hudi-rs
+```
+
+Then run the bootstrap script as the login user (not as user data, which would
+install the toolchain into root's home). It installs Rust, Java, PySpark, and
+the S3A connector, and mounts a local NVMe instance store at `/mnt/nvme` when
+the instance type has one (e.g. `r8gd.4xlarge` for SF100). The packages are
+architecture-neutral, so Graviton and x86 instances both work.
+
+```bash
+cd ~/hudi-rs
+bash benchmark/tpch/infra/aws/bootstrap.sh
+
+exec bash -l # pick up the variables it appended
+echo "$SPARK_HOME" "$AWS_REGION" # both must be non-empty
+```
+
+The new shell matters: the one that ran bootstrap does not yet have those
+variables, and an unset `AWS_REGION` sends DataFusion to `us-east-1` rather
+than the bucket's region.
+
+Both engines resolve instance-profile credentials automatically: DataFusion
+through `object_store`, Spark through the S3A default credential chain, so no
+keys need to be configured. The region is the one thing neither infers, which
+is why bootstrap persists it from the instance metadata.
+
+### Choose where the generated data lands
+
+`generate` writes to `benchmark/tpch/data`, so size the root volume for the
+scale factor (SF100 parquet is roughly 40 GB). Instance types ending in `d`
+(`r8gd`, `r7gd`, `m7gd`) carry a local NVMe instance store, which bootstrap
+mounts at `/mnt/nvme`; where one is present, keeping the generated parquet on
+it is faster than EBS and leaves the root volume alone:
+
+```bash
+# optional, and only before generating
+mountpoint -q /mnt/nvme && ln -sfn /mnt/nvme/tpch-data benchmark/tpch/data
+```
+
+Instance types without a local disk work unchanged; everything simply stays on
+the root volume, so provision it accordingly.
+
+### Run benchmarks on the instance
+
+Same commands as on GCP, with `s3://` data URLs. `create-tables` writes the
+Hudi tables straight to the bucket, so the instance only holds the generated
+parquet. Run these under `tmux`: at SF100 they take hours, and a dropped SSM
+session would otherwise kill them.
+
+```bash
+S3=s3://bucket/sf100-hudi
+
+benchmark/tpch/run.sh generate --scale-factor 100
+# reuses the tables if they are already there; --recreate rebuilds them
+benchmark/tpch/run.sh create-tables --scale-factor 100 --hudi-dir $S3
+
+benchmark/tpch/run.sh bench-datafusion --scale-factor 100 --hudi-dir $S3 \
+ --output-dir benchmark/tpch/results
+benchmark/tpch/run.sh bench-spark --scale-factor 100 --hudi-dir $S3 \
+ --output-dir benchmark/tpch/results
+
+benchmark/tpch/run.sh compare --scale-factor 100 --engines datafusion,spark
+```
+
+`--output-dir` is what makes the `bench-*` commands persist their results, and
+`compare` reads them from there, so omitting it leaves nothing to compare once
+the runs have finished. To check credentials, region and the S3 path before
+committing hours to a full run, benchmark a couple of queries first with
+`--queries 1,6`.
+
+Re-run `sync.sh` from your local checkout whenever local code changes; it
+rebuilds the binary on the instance.
+
+### Stop/start the instance
+
+Stopping wipes the NVMe instance store, taking the mount, any generated data on
+it, and the shuffle directory with it. Re-run the bootstrap script after
+starting again to restore them, then regenerate. The package installs are
+sentinel-guarded and skipped, so that is fast. Tables already written to the
+bucket are unaffected, and `create-tables` reuses them.
diff --git a/benchmark/tpch/infra/aws/bootstrap.sh
b/benchmark/tpch/infra/aws/bootstrap.sh
new file mode 100755
index 00000000..eea872c9
--- /dev/null
+++ b/benchmark/tpch/infra/aws/bootstrap.sh
@@ -0,0 +1,120 @@
+#!/usr/bin/env bash
+#
+# 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.
+#
+# Bootstrap an AWS EC2 Amazon Linux 2023 instance with system-level
+# dependencies for TPC-H benchmarks. This script is repo-agnostic.
+#
+# Run it as the login user, not as EC2 user data: it installs into $HOME
+# (rustup, pip --user, SPARK_HOME in .bashrc) and takes ownership of the
+# instance-store mount, so a root-owned install from user data would leave the
+# login user without a toolchain.
+#
+# Re-run it after a stop/start: the instance store is wiped, and the mount
+# section below is deliberately outside the sentinel guard so it is restored.
+#
+# Prerequisites:
+# - Instance profile granting S3 access to the benchmark bucket
+# (object_store and the S3A connector both resolve instance credentials
+# automatically; no keys need to be configured)
+# - Outbound internet access, for the package, crate, PyPI and Maven
+# downloads here and for the Hudi Spark bundle that run.sh fetches
+#
+set -euo pipefail
+
+if [[ $EUID -eq 0 ]]; then
+ echo "Error: run this as the login user (e.g. ec2-user), not as root." >&2
+ exit 1
+fi
+
+# Spark local dirs for shuffle and event logs. This section runs on every
+# invocation (not sentinel-guarded): the instance store is wiped on every
+# stop/start, so re-running the script restores the mount.
+# If the instance has a local NVMe instance store, format and mount it at
+# /mnt/nvme and put the shuffle dir there for faster I/O.
+NVME_DEV=$(lsblk -dno NAME,MODEL | awk '/Instance Storage/ {print "/dev/" $1;
exit}')
+if [[ -n "${NVME_DEV:-}" ]]; then
+ if ! sudo blkid "$NVME_DEV" >/dev/null 2>&1; then
+ sudo mkfs -t xfs -q "$NVME_DEV"
+ fi
+ sudo mkdir -p /mnt/nvme
+ mountpoint -q /mnt/nvme || sudo mount "$NVME_DEV" /mnt/nvme
+ sudo chown "$(id -un):$(id -gn)" /mnt/nvme
+ mkdir -p /mnt/nvme/spark-local
+ ln -sfn /mnt/nvme/spark-local /tmp/spark-local
+ # Target for an optional benchmark/tpch/data symlink. Created here so that
+ # linking to it cannot leave a dangling symlink, which surfaces much later as
+ # an EEXIST from the generator's create_dir_all.
+ mkdir -p /mnt/nvme/tpch-data
+else
+ # No instance store on this type; shuffle stays on the root volume. Clear any
+ # symlink left by a run on an instance that had one, which would otherwise
+ # point at an empty mount point.
+ [[ -L /tmp/spark-local ]] && rm -f /tmp/spark-local
+ mkdir -p /tmp/spark-local
+fi
+mkdir -p /tmp/spark-events
+
+SENTINEL="/var/lib/bootstrap-done"
+[[ -f "$SENTINEL" ]] && exit 0
+
+# System packages
+# clang-devel supplies the libclang that bindgen loads: hudi enables
+# spill-rocksdb by default, and rocksdb generates its bindings at build time.
+sudo dnf install -y gcc gcc-c++ clang-devel make git pkgconfig openssl-devel \
+ protobuf-compiler protobuf-devel java-17-amazon-corretto-headless \
+ python3-pip rsync tmux sysstat
+
+# Rust
+curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
+. "$HOME/.cargo/env"
+
+# arrow/DataFusion kernels rely on LLVM auto-vectorization, and rustc targets a
+# conservative baseline for each architecture (SSE2 on x86-64, plain NEON on
+# aarch64); target the local CPU so the wider vector units are actually used.
+mkdir -p "$HOME/.cargo"
+cat > "$HOME/.cargo/config.toml" <<'EOF'
+[build]
+rustflags = ["-C", "target-cpu=native"]
+EOF
+
+# PySpark
+pip3 install --user pyspark==3.5.8
+
+# S3A connector jars matching the Hadoop 3.3.4 client PySpark 3.5.x bundles
+SPARK_HOME=$(python3 -c "import pyspark; print(pyspark.__path__[0])")
+mkdir -p "$SPARK_HOME/conf" "$SPARK_HOME/jars"
+curl -fL -o "$SPARK_HOME/jars/hadoop-aws-3.3.4.jar" \
+
"https://repo1.maven.org/maven2/org/apache/hadoop/hadoop-aws/3.3.4/hadoop-aws-3.3.4.jar"
+curl -fL -o "$SPARK_HOME/jars/aws-java-sdk-bundle-1.12.262.jar" \
+
"https://repo1.maven.org/maven2/com/amazonaws/aws-java-sdk-bundle/1.12.262/aws-java-sdk-bundle-1.12.262.jar"
+
+# Persist SPARK_HOME for future sessions, plus the instance's region:
+# object_store takes no region from the instance metadata and falls back to
+# us-east-1, which misroutes reads of a bucket in any other region.
+AWS_REGION=$(curl -fsS -H "X-aws-ec2-metadata-token: $(curl -fsS -X PUT \
+ -H 'X-aws-ec2-metadata-token-ttl-seconds: 60' \
+ http://169.254.169.254/latest/api/token)" \
+ http://169.254.169.254/latest/meta-data/placement/region)
+{
+ echo "export SPARK_HOME=$SPARK_HOME"
+ echo "export AWS_REGION=$AWS_REGION"
+} >> ~/.bashrc
+
+sudo touch "$SENTINEL"
+echo "Bootstrap complete."
diff --git a/benchmark/tpch/infra/aws/sync.sh b/benchmark/tpch/infra/aws/sync.sh
new file mode 100755
index 00000000..2a5ce77f
--- /dev/null
+++ b/benchmark/tpch/infra/aws/sync.sh
@@ -0,0 +1,71 @@
+#!/usr/bin/env bash
+#
+# 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.
+#
+# Sync local code to an AWS EC2 instance over SSM and rebuild the benchmark
+# binary. Connects through Session Manager, so the instance needs no public
+# IP or open SSH port; it does need the AmazonSSMManagedInstanceCore policy
+# and an SSH key authorized for the login user (e.g. the instance's key pair).
+#
+# Requires locally: AWS CLI with the Session Manager plugin, and credentials
+# for the instance's account.
+#
+# Usage:
+# bash benchmark/tpch/infra/aws/sync.sh <instance-id> <region> [ssh-key-path]
+#
+# Example:
+# bash benchmark/tpch/infra/aws/sync.sh i-0123456789abcdef0 us-west-2
~/.ssh/bench.pem
+#
+set -euo pipefail
+
+INSTANCE_ID="${1:?Usage: sync.sh <instance-id> <region> [ssh-key-path]}"
+REGION="${2:?Usage: sync.sh <instance-id> <region> [ssh-key-path]}"
+SSH_KEY="${3:-}"
+SSH_USER="${SSH_USER:-ec2-user}"
+
+REPO_ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)"
+
+PROXY_CMD="aws ssm start-session --target %h --region $REGION --document-name
AWS-StartSSHSession --parameters portNumber=%p"
+
+SSH_OPTS=(-o StrictHostKeyChecking=no -o "ProxyCommand=$PROXY_CMD")
+if [[ -n "$SSH_KEY" ]]; then
+ SSH_OPTS+=(-i "$SSH_KEY")
+fi
+
+RSYNC_SSH="ssh -o StrictHostKeyChecking=no -o 'ProxyCommand=$PROXY_CMD'"
+if [[ -n "$SSH_KEY" ]]; then
+ RSYNC_SSH+=" -i '$SSH_KEY'"
+fi
+
+echo "==> Syncing code to $INSTANCE_ID..."
+rsync -az --progress \
+ --exclude='target/' \
+ --exclude='.git/' \
+ --exclude='.context/' \
+ -e "$RSYNC_SSH" \
+ "$REPO_ROOT/" "$SSH_USER@$INSTANCE_ID":~/hudi-rs/
+
+echo "==> Building on the instance..."
+ssh "${SSH_OPTS[@]}" "$SSH_USER@$INSTANCE_ID" \
+ "cd ~/hudi-rs && . \$HOME/.cargo/env && cargo build -p tpch --release"
+
+echo ""
+echo "Ready. Connect with:"
+printf ' ssh'
+printf ' %q' "${SSH_OPTS[@]}"
+printf ' %s\n' "$SSH_USER@$INSTANCE_ID"
diff --git a/benchmark/tpch/infra/gcp/bootstrap.sh
b/benchmark/tpch/infra/gcp/bootstrap.sh
index 874b5692..2a9f52bb 100755
--- a/benchmark/tpch/infra/gcp/bootstrap.sh
+++ b/benchmark/tpch/infra/gcp/bootstrap.sh
@@ -31,8 +31,10 @@ SENTINEL="/var/lib/bootstrap-done"
# System packages
sudo apt-get update
-sudo apt-get install -y build-essential protobuf-compiler pkg-config git curl \
- openjdk-17-jdk-headless python3-pip sysstat tmux glances
+# libclang-dev is what bindgen loads: hudi enables spill-rocksdb by default,
+# and rocksdb generates its bindings at build time.
+sudo apt-get install -y build-essential clang libclang-dev protobuf-compiler \
+ pkg-config git curl openjdk-17-jdk-headless python3-pip sysstat tmux glances
# Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
diff --git a/benchmark/tpch/infra/spark/spark-defaults.conf
b/benchmark/tpch/infra/spark/spark-defaults.conf
index c1d27902..3564c601 100644
--- a/benchmark/tpch/infra/spark/spark-defaults.conf
+++ b/benchmark/tpch/infra/spark/spark-defaults.conf
@@ -26,3 +26,6 @@ spark.local.dir /tmp/spark-local
spark.eventLog.dir /tmp/spark-events
spark.hadoop.fs.gs.impl
com.google.cloud.hadoop.fs.gcs.GoogleHadoopFileSystem
spark.hadoop.google.cloud.auth.type COMPUTE_ENGINE
+# Hadoop 3 dropped the s3:// filesystem, so route it to S3A as well: the
+# harness accepts both s3:// and s3a:// URLs, and object_store maps them alike.
+spark.hadoop.fs.s3.impl
org.apache.hadoop.fs.s3a.S3AFileSystem
diff --git a/benchmark/tpch/run.sh b/benchmark/tpch/run.sh
index 3bd1e454..6a861a4e 100755
--- a/benchmark/tpch/run.sh
+++ b/benchmark/tpch/run.sh
@@ -25,6 +25,73 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
DEFAULT_SCALE_FACTOR=1
TPCH_BIN="$REPO_ROOT/target/release/tpch"
+HUDI_SPARK_BUNDLE="org.apache.hudi:hudi-spark3.5-bundle_2.12:1.1.1"
+
+# Record what the numbers were produced on. A timing is only comparable against
+# another run on the same hardware, build and data, and none of that is
+# recoverable from the results afterwards.
+write_env_report() {
+ local out_file="$1"
+ local sf="$2"
+ local data_dir="$3"
+
+ local cpu_model cpu_count mem_total
+ if [ -r /proc/cpuinfo ]; then
+ cpu_model=$(awk -F': ' '/^model name|^Model name/ {print $2; exit}'
/proc/cpuinfo)
+ [ -z "$cpu_model" ] && cpu_model=$(lscpu 2>/dev/null | awk -F': +'
'/^Model name/ {print $2; exit}')
+ cpu_count=$(nproc)
+ mem_total=$(awk '/^MemTotal/ {printf "%.0f GiB", $2/1048576}'
/proc/meminfo)
+ else
+ cpu_model=$(sysctl -n machdep.cpu.brand_string 2>/dev/null)
+ cpu_count=$(sysctl -n hw.ncpu 2>/dev/null)
+ mem_total=$(sysctl -n hw.memsize 2>/dev/null | awk '{printf "%.0f GiB",
$1/1073741824}')
+ fi
+
+ # Instance identity, when this is an EC2 box. IMDSv2, and short timeouts so a
+ # non-EC2 machine costs nothing.
+ local instance_type="" instance_region=""
+ local imds_token
+ imds_token=$(curl -fsS --max-time 1 -X PUT \
+ -H 'X-aws-ec2-metadata-token-ttl-seconds: 60' \
+ http://169.254.169.254/latest/api/token 2>/dev/null) || true
+ if [ -n "$imds_token" ]; then
+ instance_type=$(curl -fsS --max-time 1 -H "X-aws-ec2-metadata-token:
$imds_token" \
+ http://169.254.169.254/latest/meta-data/instance-type 2>/dev/null) ||
true
+ instance_region=$(curl -fsS --max-time 1 -H "X-aws-ec2-metadata-token:
$imds_token" \
+ http://169.254.169.254/latest/meta-data/placement/region 2>/dev/null) ||
true
+ fi
+
+ local git_rev git_dirty=""
+ git_rev=$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo
unknown)
+ git -C "$REPO_ROOT" diff --quiet 2>/dev/null || git_dirty=" (modified)"
+
+ # Where the data physically sits: an EBS root and an instance store give very
+ # different read numbers for the same command.
+ local data_backing="n/a (cloud storage)"
+ if ! is_cloud_url "$data_dir"; then
+ data_backing=$(df -h "$data_dir" 2>/dev/null | awk 'NR==2 {print $1" "$2}')
+ fi
+
+ {
+ echo "# Benchmark environment"
+ echo "captured: $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
+ echo "scale factor: $sf"
+ echo "data location: $data_dir"
+ echo "data backing: $data_backing"
+ [ -n "$instance_type" ] && echo "instance: $instance_type
($instance_region)"
+ echo "cpu: ${cpu_model:-unknown} x ${cpu_count:-?}"
+ echo "memory: ${mem_total:-unknown}"
+ echo "os: $(uname -srm)"
+ echo "hudi-rs commit: ${git_rev}${git_dirty}"
+ echo "rustc: $(rustc --version 2>/dev/null || echo unknown)"
+ echo "RUSTFLAGS: ${RUSTFLAGS:-<from cargo config>}"
+ echo "cargo build: release"
+ echo "java: $(java -version 2>&1 | head -1)"
+ echo "spark: $("$SPARK_HOME/bin/spark-submit" --version 2>&1 |
awk '/version/ {print $NF; exit}')"
+ echo "hudi bundle: $HUDI_SPARK_BUNDLE"
+ echo "config: config/sf$sf.yaml"
+ } > "$out_file"
+}
build_tpch() {
echo "Building TPC-H tool..."
@@ -51,6 +118,8 @@ setup_spark() {
fi
echo "Configuring Spark at $SPARK_HOME..."
+ # A pip-installed PySpark ships without a conf directory.
+ mkdir -p "$SPARK_HOME/conf"
cp "$SCRIPT_DIR/infra/spark/spark-defaults.conf"
"$SPARK_HOME/conf/spark-defaults.conf"
cp "$SCRIPT_DIR/infra/spark/log4j2.properties"
"$SPARK_HOME/conf/log4j2.properties"
}
@@ -62,6 +131,28 @@ is_cloud_url() {
esac
}
+# A data dir symlinked to an instance store dangles once a stop/start wipes it.
+# Name that, because the failure it otherwise produces is create_dir_all
+# reporting EEXIST for a directory that does not exist.
+require_usable_data_dir() {
+ local data_root="$SCRIPT_DIR/data"
+ if [ -L "$data_root" ] && [ ! -d "$data_root" ]; then
+ echo "Error: $data_root links to $(readlink "$data_root"), which is
missing." >&2
+ echo "Recreate that directory, or remove the symlink to use local
storage." >&2
+ exit 1
+ fi
+}
+
+# Fail with the missing table names rather than letting the engine report a
+# missing path once the run is already under way.
+require_hudi_tables() {
+ local hudi_dir="$1"
+ if ! "$TPCH_BIN" check-tables --hudi-base "$hudi_dir"; then
+ echo "Error: run 'create-tables' first, or point --hudi-dir at existing
tables." >&2
+ exit 1
+ fi
+}
+
usage() {
cat <<EOF
Usage: $0 <command> [options]
@@ -76,8 +167,9 @@ Commands:
Options (per command):
--scale-factor N TPC-H scale factor [all commands] (default:
$DEFAULT_SCALE_FACTOR)
--format F Table format: hudi or parquet [bench-*, compare] (default:
auto)
- --hudi-dir D Hudi data directory or cloud URL [bench-*] (default:
data/sf{N}-hudi)
- --parquet-dir D Parquet data directory or cloud URL [bench-*] (default:
data/sf{N}-parquet)
+ --recreate Rebuild tables that already exist [create-tables]
(default: reuse them)
+ --hudi-dir D Hudi data directory or cloud URL [create-tables, bench-*]
(default: data/sf{N}-hudi)
+ --parquet-dir D Parquet data directory or cloud URL [create-tables,
bench-*] (default: data/sf{N}-parquet)
--queries Q Comma-separated query numbers [bench-*] (default: all 22)
--iterations N Number of measured iterations per query [bench-*] (from
config)
--warmup N Number of unmeasured warmup iterations per query [bench-*]
(from config)
@@ -87,6 +179,7 @@ Options (per command):
Examples:
$0 generate --scale-factor 1
$0 create-tables --scale-factor 1
+ $0 create-tables --scale-factor 100 --hudi-dir s3://bucket/sf100-hudi
$0 bench-spark --scale-factor 1 --queries 1,3,6
$0 bench-datafusion --scale-factor 1 --queries 1,3,6
$0 bench-datafusion --scale-factor 100 --hudi-dir gs://bucket/sf100-hudi
@@ -105,6 +198,8 @@ cmd_generate() {
esac
done
+ require_usable_data_dir
+
local parquet_dir="$SCRIPT_DIR/data/sf$sf-parquet"
if [ -d "$parquet_dir" ]; then
echo "Removing existing parquet data at $parquet_dir..."
@@ -117,28 +212,48 @@ cmd_generate() {
cmd_create_tables() {
local sf="$DEFAULT_SCALE_FACTOR"
+ local custom_hudi_dir=""
+ local custom_parquet_dir=""
+ local recreate=0
while [[ $# -gt 0 ]]; do
case "$1" in
--scale-factor) sf="$2"; shift 2 ;;
+ --hudi-dir) custom_hudi_dir="$2"; shift 2 ;;
+ --parquet-dir) custom_parquet_dir="$2"; shift 2 ;;
+ --recreate) recreate=1; shift ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
- local parquet_dir="$SCRIPT_DIR/data/sf$sf-parquet"
- if [ ! -d "$parquet_dir" ]; then
- echo "Error: parquet data not found at $parquet_dir. Run 'generate'
first." >&2
- exit 1
+ local hudi_dir="${custom_hudi_dir:-$SCRIPT_DIR/data/sf$sf-hudi}"
+
+ build_tpch
+
+ # Rebuilding is the expensive step at scale, so existing tables are reused
+ # unless --recreate says otherwise.
+ if [ "$recreate" -eq 0 ] && "$TPCH_BIN" check-tables --hudi-base "$hudi_dir"
2>/dev/null; then
+ echo "Reusing existing Hudi tables at: $hudi_dir"
+ echo "Pass --recreate to rebuild them."
+ return 0
fi
- local hudi_dir="$SCRIPT_DIR/data/sf$sf-hudi"
- if [ -d "$hudi_dir" ]; then
- echo "Removing existing Hudi data at $hudi_dir..."
- rm -rf "$hudi_dir"
+ local parquet_dir="${custom_parquet_dir:-$SCRIPT_DIR/data/sf$sf-parquet}"
+ if ! is_cloud_url "$parquet_dir" && [ ! -d "$parquet_dir" ]; then
+ echo "Error: parquet data not found at $parquet_dir. Run 'generate'
first." >&2
+ exit 1
fi
- build_tpch
setup_spark
- mkdir -p "$hudi_dir"
+
+ # Spark creates the cloud prefix itself; only a local target needs clearing
+ # and pre-creating, and only there would stale files survive a rerun.
+ if ! is_cloud_url "$hudi_dir"; then
+ if [ -d "$hudi_dir" ]; then
+ echo "Removing existing Hudi data at $hudi_dir..."
+ rm -rf "$hudi_dir"
+ fi
+ mkdir -p "$hudi_dir"
+ fi
local sql_file
sql_file="$(mktemp)"
@@ -149,7 +264,7 @@ cmd_create_tables() {
echo "Creating Hudi COW tables from parquet (sf$sf)..."
"$SPARK_HOME/bin/spark-sql" \
- --packages org.apache.hudi:hudi-spark3.5-bundle_2.12:1.1.1 \
+ --packages "$HUDI_SPARK_BUNDLE" \
"${SPARK_ARGS[@]}" \
-f "$sql_file"
@@ -214,6 +329,9 @@ cmd_bench_spark() {
echo "Error: $format data not found at $data_dir." >&2
exit 1
fi
+ if [ "$format" = "hudi" ]; then
+ require_hudi_tables "$data_dir"
+ fi
read_spark_args --scale-factor "$sf" --command bench
setup_spark
@@ -236,7 +354,7 @@ cmd_bench_spark() {
echo "Running Spark SQL benchmark ($format)..."
"$SPARK_HOME/bin/spark-submit" \
- --packages org.apache.hudi:hudi-spark3.5-bundle_2.12:1.1.1 \
+ --packages "$HUDI_SPARK_BUNDLE" \
"${SPARK_ARGS[@]}" \
"$SCRIPT_DIR/infra/spark/bench.py" \
"${bench_args[@]}"
@@ -246,6 +364,7 @@ cmd_bench_spark() {
if [ -n "$output_dir" ]; then
mkdir -p "$output_dir"
parse_args+=(--output-dir "$output_dir" --engine-label spark
--format-label "$format" --display-name "spark+hudi" --scale-factor "$sf")
+ write_env_report "$output_dir/environment.txt" "$sf" "$data_dir"
fi
"$TPCH_BIN" "${parse_args[@]}"
rm -rf "$tmp_dir"
@@ -314,6 +433,10 @@ cmd_bench_datafusion() {
build_tpch
+ if [ "$use_hudi" = true ]; then
+ require_hudi_tables "$hudi_dir"
+ fi
+
local bench_args=(bench --scale-factor "$sf")
[ "$use_hudi" = true ] && bench_args+=(--hudi-dir "$hudi_dir")
[ "$use_parquet" = true ] && bench_args+=(--parquet-dir "$parquet_dir")
@@ -325,6 +448,7 @@ cmd_bench_datafusion() {
mkdir -p "$output_dir"
output_dir="$(cd "$output_dir" && pwd)"
bench_args+=(--output-dir "$output_dir" --engine-label datafusion
--format-label "${format:-hudi}" --display-name "datafusion+hudi-rs")
+ write_env_report "$output_dir/environment.txt" "$sf" "$hudi_dir"
fi
echo "Running DataFusion benchmark..."
@@ -364,6 +488,14 @@ cmd_compare() {
"$TPCH_BIN" compare \
--results-dir "$SCRIPT_DIR/results" \
--runs "$runs"
+
+ # Print alongside the chart so a copied result carries the conditions that
+ # produced it.
+ local env_file="$SCRIPT_DIR/results/environment.txt"
+ if [ -f "$env_file" ]; then
+ echo ""
+ cat "$env_file"
+ fi
}
# --- Main ---
diff --git a/benchmark/tpch/src/config.rs b/benchmark/tpch/src/config.rs
index e446fb1f..24186651 100644
--- a/benchmark/tpch/src/config.rs
+++ b/benchmark/tpch/src/config.rs
@@ -170,6 +170,9 @@ impl ScaleFactorConfig {
let Some(table) = self.tables.get(name) else {
continue;
};
+ // The catalog registration outlives the data it points at, so
+ // without this a rerun fails on the previous run's entry.
+ writeln!(sql, "DROP TABLE IF EXISTS {name};").unwrap();
writeln!(sql, "CREATE TABLE {name} USING hudi").unwrap();
writeln!(sql, "LOCATION '{hudi_base}/{name}'").unwrap();
writeln!(sql, "TBLPROPERTIES (").unwrap();
@@ -208,6 +211,7 @@ impl ScaleFactorConfig {
// Register Hudi tables
for &name in TABLE_ORDER {
if self.tables.contains_key(name) {
+ writeln!(sql, "DROP TABLE IF EXISTS {name};").unwrap();
writeln!(
sql,
"CREATE TABLE {name} USING hudi LOCATION
'{hudi_base}/{name}';"
diff --git a/benchmark/tpch/src/main.rs b/benchmark/tpch/src/main.rs
index 73362a41..c02285a6 100644
--- a/benchmark/tpch/src/main.rs
+++ b/benchmark/tpch/src/main.rs
@@ -86,6 +86,12 @@ enum Commands {
#[arg(long)]
hudi_base: String,
},
+ /// Verify every TPC-H Hudi table is readable at a base path
+ CheckTables {
+ /// Hudi tables base path (e.g., /opt/hudi or s3://bucket/path)
+ #[arg(long)]
+ hudi_base: String,
+ },
/// Render benchmark SQL (table registrations + query iterations)
RenderBenchSql {
/// TPC-H scale factor (loads config/sf{N}.yaml)
@@ -354,6 +360,17 @@ async fn main() -> Result<()> {
print!("{}", cfg.render_ctas_sql(&parquet_base, &hudi_base));
Ok(())
}
+ Commands::CheckTables { hudi_base } => {
+ let missing = missing_hudi_tables(&hudi_base).await?;
+ if missing.is_empty() {
+ Ok(())
+ } else {
+ Err(datafusion::error::DataFusionError::Plan(format!(
+ "Hudi tables not found at {hudi_base}: {}",
+ missing.join(", ")
+ )))
+ }
+ }
Commands::RenderBenchSql {
scale_factor,
hudi_base,
@@ -484,24 +501,50 @@ fn load_query(query_num: usize, scale_factor: f64) ->
std::result::Result<String
Ok(sql.replace("${Q11_FRACTION}", &q11_fraction))
}
+/// Names of the TPC-H tables that cannot be opened under `base_dir`.
+///
+/// Opens each through the same `HudiDataSource` the benchmark uses, so a table
+/// that passes here is one the benchmark can actually read, not merely a
+/// directory that exists.
+async fn missing_hudi_tables(base_dir: &str) -> Result<Vec<String>> {
+ let resolved =
resolve_path(base_dir).map_err(datafusion::error::DataFusionError::Plan)?;
+
+ let mut missing = Vec::new();
+ for table_name in TPCH_TABLES {
+ let table_uri = hudi_table_uri(&resolved, table_name)?;
+ if HudiDataSource::new(&table_uri).await.is_err() {
+ missing.push((*table_name).to_string());
+ }
+ }
+ Ok(missing)
+}
+
+/// Build the URI for one Hudi table under a local path or cloud URL.
+fn hudi_table_uri(resolved_base: &str, table_name: &str) -> Result<String> {
+ if is_cloud_url(resolved_base) {
+ Ok(format!(
+ "{}/{table_name}",
+ resolved_base.trim_end_matches('/')
+ ))
+ } else {
+ let table_path = Path::new(resolved_base).join(table_name);
+ Ok(url::Url::from_file_path(&table_path)
+ .map_err(|_| {
+ datafusion::error::DataFusionError::Plan(format!(
+ "Failed to create file URL for {}",
+ table_path.display()
+ ))
+ })?
+ .to_string())
+ }
+}
+
/// Register all 8 TPC-H Hudi tables. Supports local paths and cloud URLs.
async fn register_hudi_tables(ctx: &SessionContext, base_dir: &str) ->
Result<()> {
let resolved =
resolve_path(base_dir).map_err(datafusion::error::DataFusionError::Plan)?;
for table_name in TPCH_TABLES {
- let table_uri = if is_cloud_url(&resolved) {
- format!("{}/{table_name}", resolved.trim_end_matches('/'))
- } else {
- let table_path = Path::new(&resolved).join(table_name);
- url::Url::from_file_path(&table_path)
- .map_err(|_| {
- datafusion::error::DataFusionError::Plan(format!(
- "Failed to create file URL for {}",
- table_path.display()
- ))
- })?
- .to_string()
- };
+ let table_uri = hudi_table_uri(&resolved, table_name)?;
let hudi = HudiDataSource::new(&table_uri).await?;
ctx.register_table(*table_name, Arc::new(hudi))?;
}