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 4e99a136 fix(benchmark): stop the parquet run overwriting the Hudi 
benchmark results (#747)
4e99a136 is described below

commit 4e99a13601405a154250ff46b560ed0f805aa449
Author: Y Ethan Guo <[email protected]>
AuthorDate: Fri Sep 4 18:16:58 2026 -0700

    fix(benchmark): stop the parquet run overwriting the Hudi benchmark results 
(#747)
---
 benchmark/tpch/README.md   |  55 +++++++++++++
 benchmark/tpch/run.sh      | 199 +++++++++++++++++++++++++++++++++++++--------
 benchmark/tpch/src/main.rs |  28 ++++++-
 3 files changed, 242 insertions(+), 40 deletions(-)

diff --git a/benchmark/tpch/README.md b/benchmark/tpch/README.md
index f47eb6a5..d79df603 100644
--- a/benchmark/tpch/README.md
+++ b/benchmark/tpch/README.md
@@ -43,6 +43,16 @@ make bench-tpch ENGINE=spark SF=1
 make tpch-compare ENGINES=datafusion,spark SF=1
 ```
 
+`create-tables` reuses tables that already exist and takes `--recreate` to
+rebuild them, so step 2 is cheap to repeat. Before publishing timings, check
+that the Hudi read path returns what the parquet it was built from returns:
+
+```bash
+benchmark/tpch/run.sh validate --scale-factor 1
+```
+
+It reports per query and exits non-zero if any of them differ.
+
 ### Options
 
 | Variable  | Values                                            | Default      
|
@@ -52,6 +62,10 @@ make tpch-compare ENGINES=datafusion,spark SF=1
 | `QUERIES` | Comma-separated query numbers                     | all 22       
|
 | `ENGINES` | Comma-separated engine names (for `tpch-compare`) |              
|
 
+`run.sh` takes the same options plus `--recreate`, `--runs`, `--memory-limit`
+and the `--hudi-dir` / `--parquet-dir` overrides; run it with no arguments for
+the full list.
+
 ### More examples
 
 ```bash
@@ -63,6 +77,40 @@ 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
 ```
 
+`bench-datafusion` benchmarks both formats when it finds data for both, writing
+one result file per format. Pass `--format hudi` to run only the Hudi leg.
+
+`compare --engines` pairs every engine with a single format. To chart one
+engine's two formats against each other, name the result files instead:
+
+```bash
+benchmark/tpch/run.sh compare --scale-factor 100 \
+  --runs datafusion_hudi_sf100,datafusion_parquet_sf100
+```
+
+Keep both sides of any such comparison on the same storage. Hudi tables on
+object storage against parquet on a local disk measures the storage far more
+than it measures the format, so keep the parquet alongside the tables and
+point `--parquet-dir` at it.
+
+Every data directory, `--hudi-dir` and `--parquet-dir` alike, takes either a
+local path or a cloud URL, on every command that reads or writes one. So the
+whole run can stay in object storage:
+
+```bash
+S3=s3://bucket/sf100
+benchmark/tpch/run.sh generate --scale-factor 100 --parquet-dir $S3-parquet
+benchmark/tpch/run.sh create-tables --scale-factor 100 \
+  --parquet-dir $S3-parquet --hudi-dir $S3-hudi
+```
+
+Pointing `--parquet-dir` at the Hudi table itself is a further option: a
+parquet scan of that directory reads the same files the Hudi tables are made
+of, so file sizing and sort order are held constant and only the read path
+differs. That is sound only for a table with a single commit, as here, since a
+plain scan has no notion of file slices and would read superseded versions of
+an updated table.
+
 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
@@ -221,6 +269,13 @@ 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`.
 
+Alongside the results the `bench-*` commands write an environment report, which
+`compare` prints under the chart so a copied result carries the hardware, build
+and storage that produced it. `env-report` rewrites it for results already
+collected, for when a run predates a change to the report; re-running a
+benchmark leg to refresh it would overwrite that leg's results with whatever
+subset of queries the rerun covered.
+
 Re-run `sync.sh` from your local checkout whenever local code changes; it
 rebuilds the binary on the instance.
 
diff --git a/benchmark/tpch/run.sh b/benchmark/tpch/run.sh
index 6a861a4e..1ee83c03 100755
--- a/benchmark/tpch/run.sh
+++ b/benchmark/tpch/run.sh
@@ -27,6 +27,15 @@ 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"
 
+# Version of one crate as resolved in a Cargo.lock, ignoring any later package
+# whose name merely starts with it (datafusion-common, arrow-array).
+locked_version() {
+  awk -v pkg="$1" '
+    $0 == "name = \"" pkg "\"" { found = 1; next }
+    found && /^version = / { gsub(/"/, "", $3); print $3; exit }
+  ' "$2" 2>/dev/null
+}
+
 # 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.
@@ -37,8 +46,11 @@ write_env_report() {
 
   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}')
+    # lscpu first: aarch64 /proc/cpuinfo carries no model name at all, only
+    # implementer and part numbers.
+    cpu_model=$(lscpu 2>/dev/null | awk -F': +' '/^Model name/ {print $2; 
exit}')
+    [ -z "$cpu_model" ] && cpu_model=$(awk -F': +' '/^model name/ {print $2; 
exit}' /proc/cpuinfo)
+    [ -z "$cpu_model" ] && cpu_model=$(uname -m)
     cpu_count=$(nproc)
     mem_total=$(awk '/^MemTotal/ {printf "%.0f GiB", $2/1048576}' 
/proc/meminfo)
   else
@@ -65,27 +77,45 @@ write_env_report() {
   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)"
 
+  # The versions the DataFusion leg is built against, read from the lock file 
so
+  # they are what was resolved rather than what was requested.
+  local lock="$REPO_ROOT/Cargo.lock"
+  local df_version arrow_version
+  df_version=$(locked_version datafusion "$lock")
+  arrow_version=$(locked_version arrow "$lock")
+
   # 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}')
+  # Report the scheme without the bucket: the results are meant to be pasted
+  # somewhere public, and the bucket name identifies private infrastructure.
+  local data_location="$data_dir"
+  local data_backing="n/a (object storage)"
+  if is_cloud_url "$data_dir"; then
+    data_location="${data_dir%%://*}:// (bucket omitted)"
+  else
+    # df fails on a path that does not exist, and pipefail would make that
+    # failure abort the benchmark rather than the report.
+    data_backing=$(df -h "$data_dir" 2>/dev/null | awk 'NR==2 {print $1" "$2}' 
|| true)
+    [ -n "$data_backing" ] || data_backing="unknown"
   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 location:   $data_location"
     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 "spark master:    local[*] on ${cpu_count:-?} cores"
     echo "hudi-rs commit:  ${git_rev}${git_dirty}"
     echo "rustc:           $(rustc --version 2>/dev/null || echo unknown)"
-    echo "RUSTFLAGS:       ${RUSTFLAGS:-<from cargo config>}"
+    echo "RUSTFLAGS:       ${RUSTFLAGS:-(unset, see ~/.cargo/config.toml)}"
     echo "cargo build:     release"
+    echo "datafusion:      ${df_version:-unknown}"
+    echo "arrow:           ${arrow_version:-unknown}"
     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"
@@ -163,18 +193,26 @@ Commands:
   bench-spark       Run TPC-H queries against Hudi tables via Spark SQL
   bench-datafusion  Run TPC-H queries against Hudi tables via DataFusion
   compare           Compare persisted benchmark results with bar charts
+  validate          Check Hudi query results against the same queries on 
parquet
+  env-report        Rewrite the environment report for results already 
collected
 
 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)
   --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)
+  --parquet-dir D   Parquet data directory or cloud URL [generate, 
create-tables, bench-*,
+                    validate] (default: data/sf{N}-parquet)
+  --queries Q       Comma-separated query numbers [bench-*, validate] 
(default: all 22)
+  --memory-limit M  DataFusion memory limit [validate] (default: from config; 
validate
+                    holds two sessions at once, so a lower value may be needed)
   --iterations N    Number of measured iterations per query [bench-*] (from 
config)
   --warmup N        Number of unmeasured warmup iterations per query [bench-*] 
(from config)
   --output-dir D    Directory to persist results as JSON [bench-*]
   --engines E       Comma-separated engine names to compare [compare]
+  --runs R          Comma-separated result file stems to compare [compare], 
e.g.
+                    datafusion_hudi_sf100,datafusion_parquet_sf100; overrides 
--engines
+                    and is the way to compare two formats
 
 Examples:
   $0 generate --scale-factor 1
@@ -191,23 +229,28 @@ EOF
 
 cmd_generate() {
   local sf="$DEFAULT_SCALE_FACTOR"
+  local custom_parquet_dir=""
   while [[ $# -gt 0 ]]; do
     case "$1" in
       --scale-factor) sf="$2"; shift 2 ;;
+      --parquet-dir) custom_parquet_dir="$2"; shift 2 ;;
       *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
     esac
   done
 
-  require_usable_data_dir
+  local parquet_dir="${custom_parquet_dir:-$SCRIPT_DIR/data/sf$sf-parquet}"
 
-  local parquet_dir="$SCRIPT_DIR/data/sf$sf-parquet"
-  if [ -d "$parquet_dir" ]; then
-    echo "Removing existing parquet data at $parquet_dir..."
-    rm -rf "$parquet_dir"
+  # Only a local target can be cleared, or be a symlink into a vanished mount.
+  if ! is_cloud_url "$parquet_dir"; then
+    require_usable_data_dir
+    if [ -d "$parquet_dir" ]; then
+      echo "Removing existing parquet data at $parquet_dir..."
+      rm -rf "$parquet_dir"
+    fi
   fi
 
   build_tpch
-  "$TPCH_BIN" generate --scale-factor "$sf"
+  "$TPCH_BIN" generate --scale-factor "$sf" --output-dir "$parquet_dir"
 }
 
 cmd_create_tables() {
@@ -437,52 +480,134 @@ cmd_bench_datafusion() {
     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")
-  [ -n "$queries" ] && bench_args+=(--queries "$queries")
-  [ -n "$iterations" ] && bench_args+=(--iterations "$iterations")
-  [ -n "$warmup" ] && bench_args+=(--warmup "$warmup")
+  local base_args=(bench --scale-factor "$sf")
+  [ -n "$queries" ] && base_args+=(--queries "$queries")
+  [ -n "$iterations" ] && base_args+=(--iterations "$iterations")
+  [ -n "$warmup" ] && base_args+=(--warmup "$warmup")
 
+  local persisting=false
   if [ -n "$output_dir" ]; then
     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"
+    persisting=true
+    # Describe the data actually being read, which is not the Hudi directory
+    # when only the parquet leg runs.
+    local reported_dir="$hudi_dir"
+    [ "$use_hudi" = false ] && reported_dir="$parquet_dir"
+    write_env_report "$output_dir/environment.txt" "$sf" "$reported_dir"
   fi
 
-  echo "Running DataFusion benchmark..."
-  TPCH_CONFIG_DIR="$SCRIPT_DIR/config" \
-  TPCH_QUERY_DIR="$SCRIPT_DIR/queries" \
-  RUST_LOG="${RUST_LOG:-warn}" \
-  "$TPCH_BIN" "${bench_args[@]}"
+  # One invocation per format. Results are keyed by engine and format label, so
+  # labelling a parquet run "hudi" writes it over the Hudi run's results.
+  run_datafusion_bench() {
+    local fmt="$1" data_flag="$2" data_dir="$3" display="$4"
+    local args=("${base_args[@]}" "$data_flag" "$data_dir")
+    if [ "$persisting" = true ]; then
+      args+=(--output-dir "$output_dir" --engine-label datafusion \
+             --format-label "$fmt" --display-name "$display")
+    fi
+    echo "Running DataFusion benchmark ($fmt)..."
+    TPCH_CONFIG_DIR="$SCRIPT_DIR/config" \
+    TPCH_QUERY_DIR="$SCRIPT_DIR/queries" \
+    RUST_LOG="${RUST_LOG:-warn}" \
+    "$TPCH_BIN" "${args[@]}"
+  }
+
+  if [ "$use_hudi" = true ]; then
+    run_datafusion_bench hudi --hudi-dir "$hudi_dir" "datafusion+hudi-rs"
+  fi
+  if [ "$use_parquet" = true ]; then
+    run_datafusion_bench parquet --parquet-dir "$parquet_dir" 
"datafusion+parquet"
+  fi
+}
+
+# Check the Hudi read path against the parquet the tables were built from, so a
+# timing cannot come from a read that returned less than it should have.
+cmd_validate() {
+  local sf="$DEFAULT_SCALE_FACTOR"
+  local custom_hudi_dir=""
+  local custom_parquet_dir=""
+  local queries=""
+  local memory_limit=""
+  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 ;;
+      --queries) queries="$2"; shift 2 ;;
+      --memory-limit) memory_limit="$2"; shift 2 ;;
+      *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
+    esac
+  done
+
+  local hudi_dir="${custom_hudi_dir:-$SCRIPT_DIR/data/sf$sf-hudi}"
+  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." >&2
+    echo "Validation compares the Hudi tables against it, so it cannot run 
without it." >&2
+    exit 1
+  fi
+
+  build_tpch
+  require_hudi_tables "$hudi_dir"
+
+  local validate_args=(validate --scale-factor "$sf" --hudi-dir "$hudi_dir" 
--parquet-dir "$parquet_dir")
+  [ -n "$queries" ] && validate_args+=(--queries "$queries")
+  [ -n "$memory_limit" ] && validate_args+=(--memory-limit "$memory_limit")
+
+  "$TPCH_BIN" "${validate_args[@]}"
+}
+
+# Rewrite the environment report against results that already exist, so a run
+# whose report is wrong or missing does not have to be repeated to correct it.
+cmd_env_report() {
+  local sf="$DEFAULT_SCALE_FACTOR"
+  local custom_hudi_dir=""
+  while [[ $# -gt 0 ]]; do
+    case "$1" in
+      --scale-factor) sf="$2"; shift 2 ;;
+      --hudi-dir) custom_hudi_dir="$2"; shift 2 ;;
+      *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
+    esac
+  done
+
+  local results_dir="$SCRIPT_DIR/results"
+  mkdir -p "$results_dir"
+  write_env_report "$results_dir/environment.txt" "$sf" \
+    "${custom_hudi_dir:-$SCRIPT_DIR/data/sf$sf-hudi}"
+  cat "$results_dir/environment.txt"
 }
 
 cmd_compare() {
   local sf="$DEFAULT_SCALE_FACTOR"
   local engines=""
   local format="hudi"
+  local runs=""
   while [[ $# -gt 0 ]]; do
     case "$1" in
       --scale-factor) sf="$2"; shift 2 ;;
       --engines) engines="$2"; shift 2 ;;
       --format) format="$2"; shift 2 ;;
+      --runs) runs="$2"; shift 2 ;;
       *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
     esac
   done
 
-  if [ -z "$engines" ]; then
+  if [ -z "$engines" ] && [ -z "$runs" ]; then
     echo "Error: --engines is required (e.g., --engines datafusion,spark)" >&2
     exit 1
   fi
 
-  # Convert "datafusion,spark" → "datafusion_hudi_sf1,spark_hudi_sf1"
-  local runs=""
-  IFS=',' read -ra engine_arr <<< "$engines"
-  for e in "${engine_arr[@]}"; do
-    [ -n "$runs" ] && runs+=","
-    runs+="${e}_${format}_sf${sf}"
-  done
+  # --engines pairs each engine with one format; --runs names result files
+  # directly, which is how two formats get compared against each other.
+  if [ -z "$runs" ]; then
+    IFS=',' read -ra engine_arr <<< "$engines"
+    for e in "${engine_arr[@]}"; do
+      [ -n "$runs" ] && runs+=","
+      runs+="${e}_${format}_sf${sf}"
+    done
+  fi
 
   build_tpch
   "$TPCH_BIN" compare \
@@ -514,6 +639,8 @@ case "$COMMAND" in
   bench-spark)      cmd_bench_spark "$@" ;;
   bench-datafusion) cmd_bench_datafusion "$@" ;;
   compare)          cmd_compare "$@" ;;
+  validate)         cmd_validate "$@" ;;
+  env-report)       cmd_env_report "$@" ;;
   *)
     echo "Unknown command: $COMMAND" >&2
     usage
diff --git a/benchmark/tpch/src/main.rs b/benchmark/tpch/src/main.rs
index c02285a6..064fa4c9 100644
--- a/benchmark/tpch/src/main.rs
+++ b/benchmark/tpch/src/main.rs
@@ -560,8 +560,12 @@ async fn register_parquet_tables(ctx: &SessionContext, 
base_dir: &str) -> Result
     }
 
     for table_name in TPCH_TABLES {
+        // The trailing slash is what marks the path as a directory to list. A
+        // local path can be stat'ed, but an object store prefix cannot, so
+        // without it each table reads as a single file and is rejected for not
+        // ending in the parquet extension.
         let table_path = if is_cloud_url(&resolved) {
-            format!("{}/{table_name}", resolved.trim_end_matches('/'))
+            format!("{}/{table_name}/", resolved.trim_end_matches('/'))
         } else {
             Path::new(&resolved)
                 .join(table_name)
@@ -911,9 +915,17 @@ async fn run_validate(
     println!("Running Parquet queries...");
     let parquet_results = bench_source(&parquet_ctx, &query_nums, 0, 1, 
scale_factor).await;
 
-    print_validation_table(&query_nums, &hudi_results, &parquet_results);
+    let failed = print_validation_table(&query_nums, &hudi_results, 
&parquet_results);
 
-    Ok(())
+    if failed.is_empty() {
+        Ok(())
+    } else {
+        let names: Vec<String> = failed.iter().map(|qn| 
format!("Q{qn:02}")).collect();
+        Err(datafusion::error::DataFusionError::Plan(format!(
+            "Hudi results differ from parquet for {}",
+            names.join(", ")
+        )))
+    }
 }
 
 /// Parse Spark benchmark JSON output into a timing table.
@@ -1157,11 +1169,14 @@ fn print_single_table(label: &str, results: 
&[QueryResult]) {
     println!("{table}");
 }
 
+/// Returns the queries that did not match, so the caller can fail the run
+/// rather than leave a mismatch to be noticed in the output.
 fn print_validation_table(
     query_nums: &[usize],
     hudi_results: &[QueryResult],
     parquet_results: &[QueryResult],
-) {
+) -> Vec<usize> {
+    let mut failed = Vec::new();
     let mut table = Table::new();
     table.set_header(vec![
         Cell::new("Query"),
@@ -1179,6 +1194,7 @@ fn print_validation_table(
 
         if h_err.is_some() || p_err.is_some() {
             let err_msg = h_err.or(p_err).unwrap_or("unknown error");
+            failed.push(*qn);
             table.add_row(vec![
                 Cell::new(format!("Q{qn:02}")),
                 Cell::new(if h_err.is_some() { "-" } else { "OK" }),
@@ -1199,6 +1215,9 @@ fn print_validation_table(
             .map(|t| format!("{t:.1}"))
             .unwrap_or("-".into());
         let validation = compare_batches(&hr.last_batches, &pr.last_batches);
+        if validation != "PASS" {
+            failed.push(*qn);
+        }
 
         table.add_row(vec![
             Cell::new(format!("Q{qn:02}")),
@@ -1209,6 +1228,7 @@ fn print_validation_table(
     }
 
     println!("{table}");
+    failed
 }
 
 /// Compare two sets of record batches for correctness validation.

Reply via email to