This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 054ce25d [benchmark] Add DataFusion TPC-DS benchmark harness (#542)
054ce25d is described below

commit 054ce25d984f9f937365192169038ef45cec50d1
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Jul 19 09:47:53 2026 +0800

    [benchmark] Add DataFusion TPC-DS benchmark harness (#542)
---
 Cargo.toml                      |   2 +-
 benchmarks/tpcds/Cargo.toml     |  38 ++++++
 benchmarks/tpcds/README.md      | 212 +++++++++++++++++++++++++++++
 benchmarks/tpcds/src/cli.rs     | 166 +++++++++++++++++++++++
 benchmarks/tpcds/src/command.rs | 141 +++++++++++++++++++
 benchmarks/tpcds/src/context.rs | 101 ++++++++++++++
 benchmarks/tpcds/src/lib.rs     | 292 ++++++++++++++++++++++++++++++++++++++++
 benchmarks/tpcds/src/load.rs    | 169 +++++++++++++++++++++++
 benchmarks/tpcds/src/main.rs    |  24 ++++
 benchmarks/tpcds/src/report.rs  | 109 +++++++++++++++
 benchmarks/tpcds/src/run.rs     | 273 +++++++++++++++++++++++++++++++++++++
 benchmarks/tpcds/tests/smoke.rs | 225 +++++++++++++++++++++++++++++++
 12 files changed, 1751 insertions(+), 1 deletion(-)

diff --git a/Cargo.toml b/Cargo.toml
index b8d4edc3..168a5890 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -17,7 +17,7 @@
 
 [workspace]
 resolver = "2"
-members = ["crates/paimon", "crates/paimon-rest-server", 
"crates/integration_tests", "bindings/c", "bindings/python", 
"crates/integrations/datafusion"]
+members = ["crates/paimon", "crates/paimon-rest-server", 
"crates/integration_tests", "bindings/c", "bindings/python", 
"crates/integrations/datafusion", "benchmarks/tpcds"]
 
 [workspace.package]
 version = "0.3.0"
diff --git a/benchmarks/tpcds/Cargo.toml b/benchmarks/tpcds/Cargo.toml
new file mode 100644
index 00000000..9765b19a
--- /dev/null
+++ b/benchmarks/tpcds/Cargo.toml
@@ -0,0 +1,38 @@
+# 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.
+
+[package]
+name = "paimon-tpcds-bench"
+edition.workspace = true
+version.workspace = true
+publish = false
+license.workspace = true
+
+[dependencies]
+clap = { version = "4", features = ["derive"] }
+datafusion = { workspace = true }
+paimon = { workspace = true }
+paimon-datafusion = { path = "../../crates/integrations/datafusion" }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+
+[dev-dependencies]
+arrow-array = { workspace = true }
+arrow-schema = { workspace = true }
+parquet = { workspace = true }
+tempfile = "3"
diff --git a/benchmarks/tpcds/README.md b/benchmarks/tpcds/README.md
new file mode 100644
index 00000000..f5872214
--- /dev/null
+++ b/benchmarks/tpcds/README.md
@@ -0,0 +1,212 @@
+<!--
+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.
+-->
+
+# DataFusion + Paimon TPC-DS-Derived Benchmark
+
+This crate loads generated TPC-DS Parquet data into Apache Paimon and runs the
+same external query files against Paimon or Parquet through Apache DataFusion.
+
+> **Disclosure:** This is a TPC-DS-derived non-TPC benchmark. Its results are
+> not official TPC results and must not be compared with official TPC results.
+
+The crate does not include or download TPC tools, generated data, or query
+text. Obtain those materials separately and follow their licenses.
+
+## Prerequisites
+
+- Build and run on the machine being measured; do not benchmark a debug build.
+- Use the TPC-DS data generator and conversion instructions from
+  
[`apache/datafusion-benchmarks`](https://github.com/apache/datafusion-benchmarks/tree/main/tpcds).
+- Keep the upstream query directory available, normally
+  `datafusion-benchmarks/tpcds/queries`.
+- Provide enough storage for the source data, Paimon copy, and DataFusion
+  spill files. For SF1000, fast local NVMe is strongly recommended.
+
+The expected generated-data layout is:
+
+```text
+/data/tpcds-sf1000/
+  call_center.parquet/
+  catalog_page.parquet/
+  ...
+  web_site.parquet/
+```
+
+The upstream generation flow uses scale factor 1000 for approximately 1 TB of
+uncompressed generated data. Choose the generator partition count for the
+target machine and retain it in the test notes. For example:
+
+```bash
+tpctools generate --benchmark tpcds \
+  --scale 1000 \
+  --partitions 64 \
+  --generator-path /path/to/DSGen-software-code/tools \
+  --output /data/tpcds
+
+python3 tpcdsgen.py convert --scale-factor 1000 --partitions 64
+```
+
+The upstream conversion script currently contains environment-specific paths;
+inspect and update them before conversion.
+
+## Build
+
+```bash
+cargo build --release -p paimon-tpcds-bench
+target/release/paimon-tpcds-bench --help
+```
+
+Keep the exact `paimon-rust` commit, generated-data scale, generator version,
+file counts, physical bytes, operating system, CPU, memory, and storage model
+with every published report.
+
+## Compatibility Pass
+
+Do not start with SF1000. Use progressively larger datasets:
+
+1. SF10: validate table schemas and all query files.
+2. SF100: validate correctness, spill configuration, and stable timings.
+3. SF1000: run the final measurement without changing the validated SQL.
+
+Use `--tables` and `--query` to isolate failures:
+
+```bash
+target/release/paimon-tpcds-bench load \
+  --data /data/tpcds-sf10 \
+  --warehouse /data/paimon-sf10 \
+  --tables store_sales,date_dim,item
+
+target/release/paimon-tpcds-bench run \
+  --source paimon \
+  --warehouse /data/paimon-sf10 \
+  --queries /src/datafusion-benchmarks/tpcds/queries \
+  --query 1,3-5 \
+  --output results/paimon-sf10.json
+```
+
+Query selection accepts comma-separated numbers and inclusive ranges. Query
+files may contain multiple SQL statements; their statements execute
+sequentially and the file remains one timed benchmark unit.
+
+## Load Paimon Tables
+
+The loader infers each Parquet schema and creates an unpartitioned append-only
+Paimon table. It processes and commits one table at a time.
+
+```bash
+target/release/paimon-tpcds-bench load \
+  --data /data/tpcds-sf1000 \
+  --warehouse /data/paimon-sf1000 \
+  --database tpcds \
+  --if-exists error \
+  --target-partitions 64 \
+  --memory-limit-gib 192 \
+  --spill-dir /nvme/datafusion-spill \
+  --max-spill-gib 1024
+```
+
+`--if-exists` is deliberately explicit:
+
+- `error` stops before changing an existing table;
+- `skip` leaves the existing table untouched;
+- `overwrite` runs `INSERT OVERWRITE` from the Parquet source.
+
+The loader's elapsed time is operational information, not part of query
+performance results.
+
+## Run the Paimon Benchmark
+
+```bash
+target/release/paimon-tpcds-bench run \
+  --source paimon \
+  --warehouse /data/paimon-sf1000 \
+  --database tpcds \
+  --queries /src/datafusion-benchmarks/tpcds/queries \
+  --output results/datafusion-paimon-sf1000.json \
+  --warmup 1 \
+  --iterations 3 \
+  --target-partitions 64 \
+  --memory-limit-gib 192 \
+  --spill-dir /nvme/datafusion-spill \
+  --max-spill-gib 1024
+```
+
+## Run the Parquet Baseline
+
+Use the same binary, runtime settings, query files, and generated data. The
+`--warehouse` path is only a lightweight catalog location for session-scoped
+Parquet tables; use an empty location separate from the measured data.
+
+```bash
+target/release/paimon-tpcds-bench run \
+  --source parquet \
+  --data /data/tpcds-sf1000 \
+  --warehouse /data/parquet-benchmark-catalog \
+  --database tpcds \
+  --queries /src/datafusion-benchmarks/tpcds/queries \
+  --output results/datafusion-parquet-sf1000.json \
+  --warmup 1 \
+  --iterations 3 \
+  --target-partitions 64 \
+  --memory-limit-gib 192 \
+  --spill-dir /nvme/datafusion-spill \
+  --max-spill-gib 1024
+```
+
+This is an end-to-end source comparison. Loading the data into Paimon rewrites
+the physical files, so it is not a pure measurement of catalog or manifest
+overhead.
+
+## Cache Protocol
+
+Run and label cold and warm experiments separately:
+
+- Warm: use `--warmup 1` or more and report only measured iterations.
+- Cold: use `--warmup 0`, start from a documented cache state, and perform OS
+  page-cache eviction outside this tool only when the test operator can do so
+  safely.
+
+The runner does not drop the OS page cache. Do not mix cold and warm timings in
+one aggregate.
+
+## JSON Report
+
+The versioned report records:
+
+- source, paths, versions, runtime limits, and query iteration counts;
+- logical planning, physical planning, execution, and total wall-clock time;
+- output rows and errors;
+- spill count, spilled rows, spilled bytes, bytes scanned, and summed operator
+  peak-memory metrics when DataFusion exposes them.
+
+`operator_peak_memory_bytes` is the sum of available operator metrics, not a
+process-wide peak RSS. A zero value can mean that the physical operators did
+not publish that metric. Paimon scans currently do not publish a precise
+`bytes_scanned` execution metric, so that field may also be zero.
+
+The report is written even when a measured query fails; the command then exits
+non-zero. Preserve failed queries in comparisons instead of silently excluding
+them.
+
+## Interpreting Results
+
+Report per-query distributions and failure counts. Useful summaries include
+median, p95, total elapsed time, and geometric mean over the same successful
+query set. Never hide OOM, timeout, unsupported SQL, or correctness failures by
+computing an aggregate only from the remaining queries.
diff --git a/benchmarks/tpcds/src/cli.rs b/benchmarks/tpcds/src/cli.rs
new file mode 100644
index 00000000..878819f2
--- /dev/null
+++ b/benchmarks/tpcds/src/cli.rs
@@ -0,0 +1,166 @@
+// 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.
+
+use std::path::PathBuf;
+
+use clap::{Args, Parser, Subcommand, ValueEnum};
+
+use crate::{BenchmarkRuntimeConfig, ExistingTablePolicy, SourceKind};
+
+#[derive(Debug, Parser)]
+#[command(
+    name = "paimon-tpcds-bench",
+    about = "TPC-DS-derived non-TPC benchmark for DataFusion and Paimon"
+)]
+pub struct Cli {
+    #[command(subcommand)]
+    pub command: Command,
+}
+
+#[derive(Debug, Subcommand)]
+pub enum Command {
+    /// Load generated TPC-DS Parquet data into append-only Paimon tables.
+    Load(LoadArgs),
+    /// Run external TPC-DS-derived query files against Paimon or Parquet.
+    Run(RunArgs),
+}
+
+#[derive(Debug, Args)]
+pub struct LoadArgs {
+    /// Directory containing <table>.parquet paths.
+    #[arg(long)]
+    pub data: PathBuf,
+    /// Local Paimon filesystem warehouse directory.
+    #[arg(long)]
+    pub warehouse: PathBuf,
+    /// Paimon database name.
+    #[arg(long, default_value = "tpcds")]
+    pub database: String,
+    /// Comma-separated table names; defaults to all 24 tables.
+    #[arg(long)]
+    pub tables: Option<String>,
+    /// Behavior when a target table already exists.
+    #[arg(long, value_enum, default_value_t = ExistingPolicyArg::Error)]
+    pub if_exists: ExistingPolicyArg,
+    #[command(flatten)]
+    pub runtime: RuntimeArgs,
+}
+
+#[derive(Debug, Args)]
+pub struct RunArgs {
+    /// Source being measured.
+    #[arg(long, value_enum)]
+    pub source: SourceKind,
+    /// Parquet data directory. Required when --source=parquet.
+    #[arg(long)]
+    pub data: Option<PathBuf>,
+    /// Local Paimon warehouse, or a temporary catalog directory for Parquet.
+    #[arg(long)]
+    pub warehouse: PathBuf,
+    /// Directory containing q1.sql through q99.sql.
+    #[arg(long)]
+    pub queries: PathBuf,
+    /// JSON output path.
+    #[arg(long)]
+    pub output: PathBuf,
+    /// Catalog database containing the Paimon tables.
+    #[arg(long, default_value = "tpcds")]
+    pub database: String,
+    /// Query numbers, comma lists, or inclusive ranges (for example 1,3-5).
+    #[arg(long)]
+    pub query: Option<String>,
+    /// Parquet tables to register; defaults to all 24 tables.
+    #[arg(long)]
+    pub tables: Option<String>,
+    /// Warmup executions per query file.
+    #[arg(long, default_value_t = 1)]
+    pub warmup: usize,
+    /// Measured executions per query file.
+    #[arg(long, default_value_t = 3)]
+    pub iterations: usize,
+    #[command(flatten)]
+    pub runtime: RuntimeArgs,
+}
+
+impl RunArgs {
+    pub fn validate(&self) -> Result<(), String> {
+        if self.source == SourceKind::Parquet && self.data.is_none() {
+            return Err("--data is required when --source=parquet".to_string());
+        }
+        if self.iterations == 0 {
+            return Err("--iterations must be greater than zero".to_string());
+        }
+        Ok(())
+    }
+}
+
+#[derive(Debug, Clone, Args)]
+pub struct RuntimeArgs {
+    /// DataFusion execution partitions. Defaults to available CPUs.
+    #[arg(long)]
+    pub target_partitions: Option<usize>,
+    /// DataFusion memory limit in GiB. Omit for an unbounded pool.
+    #[arg(long)]
+    pub memory_limit_gib: Option<u64>,
+    /// Directory used for DataFusion spill files.
+    #[arg(long)]
+    pub spill_dir: Option<PathBuf>,
+    /// Maximum spill-directory usage in GiB.
+    #[arg(long)]
+    pub max_spill_gib: Option<u64>,
+}
+
+impl RuntimeArgs {
+    pub fn to_config(&self) -> Result<BenchmarkRuntimeConfig, String> {
+        let defaults = BenchmarkRuntimeConfig::default();
+        Ok(BenchmarkRuntimeConfig {
+            target_partitions: self
+                .target_partitions
+                .unwrap_or(defaults.target_partitions)
+                .max(1),
+            memory_limit_bytes: 
self.memory_limit_gib.map(gib_to_usize).transpose()?,
+            spill_dir: self.spill_dir.clone(),
+            max_spill_bytes: self.max_spill_gib.map(gib_to_u64).transpose()?,
+        })
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
+pub enum ExistingPolicyArg {
+    Error,
+    Skip,
+    Overwrite,
+}
+
+impl From<ExistingPolicyArg> for ExistingTablePolicy {
+    fn from(value: ExistingPolicyArg) -> Self {
+        match value {
+            ExistingPolicyArg::Error => ExistingTablePolicy::Error,
+            ExistingPolicyArg::Skip => ExistingTablePolicy::Skip,
+            ExistingPolicyArg::Overwrite => ExistingTablePolicy::Overwrite,
+        }
+    }
+}
+
+fn gib_to_usize(gib: u64) -> Result<usize, String> {
+    usize::try_from(gib_to_u64(gib)?).map_err(|_| format!("{gib} GiB exceeds 
usize"))
+}
+
+fn gib_to_u64(gib: u64) -> Result<u64, String> {
+    gib.checked_mul(1024 * 1024 * 1024)
+        .ok_or_else(|| format!("{gib} GiB exceeds u64"))
+}
diff --git a/benchmarks/tpcds/src/command.rs b/benchmarks/tpcds/src/command.rs
new file mode 100644
index 00000000..82febf8b
--- /dev/null
+++ b/benchmarks/tpcds/src/command.rs
@@ -0,0 +1,141 @@
+// 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.
+
+use std::collections::HashSet;
+use std::io::{Error as IoError, ErrorKind};
+
+use crate::{
+    load_parquet_table, load_query_files, open_catalog_session, 
parse_number_selection,
+    register_parquet_tables, run_query_file, write_report, BenchmarkReport, 
Command,
+    ExistingTablePolicy, QueryRunConfig, SourceKind, TPCDS_TABLES,
+};
+
+type BoxError = Box<dyn std::error::Error + Send + Sync>;
+
+pub async fn execute_command(command: Command) -> Result<(), BoxError> {
+    match command {
+        Command::Load(args) => {
+            let runtime = args.runtime.to_config().map_err(invalid_input)?;
+            let tables = select_tables(args.tables.as_deref())?;
+            let session = open_catalog_session(&runtime, &args.warehouse, 
&args.database).await?;
+            let policy: ExistingTablePolicy = args.if_exists.into();
+            for table in tables {
+                println!("Loading {table} ...");
+                let result = load_parquet_table(&session, &args.data, table, 
policy).await?;
+                println!(
+                    "  status={:?} rows={} elapsed_ms={}",
+                    result.status, result.rows, result.elapsed_ms
+                );
+            }
+            Ok(())
+        }
+        Command::Run(args) => {
+            args.validate().map_err(invalid_input)?;
+            let runtime = args.runtime.to_config().map_err(invalid_input)?;
+            let session = open_catalog_session(&runtime, &args.warehouse, 
&args.database).await?;
+            if args.source == SourceKind::Parquet {
+                let tables = select_tables(args.tables.as_deref())?;
+                let data = args
+                    .data
+                    .as_deref()
+                    .ok_or_else(|| invalid_input("--data is required when 
--source=parquet"))?;
+                register_parquet_tables(&session, data, &tables).await?;
+            }
+
+            let query_numbers =
+                parse_number_selection(args.query.as_deref(), 1, 
99).map_err(invalid_input)?;
+            let query_files =
+                load_query_files(&args.queries, 
&query_numbers).map_err(invalid_input)?;
+            let query_run = QueryRunConfig {
+                warmup_iterations: args.warmup,
+                measured_iterations: args.iterations,
+            };
+            let mut query_results = Vec::with_capacity(query_files.len());
+            for query in &query_files {
+                println!("Running q{} ...", query.number);
+                let result = run_query_file(&session, query, &query_run).await;
+                for iteration in &result.iterations {
+                    if let Some(error) = &iteration.error {
+                        println!(
+                            "  iteration={} failed after {:.3} ms: {error}",
+                            iteration.iteration, iteration.total_ms
+                        );
+                    } else {
+                        println!(
+                            "  iteration={} total_ms={:.3} rows={} 
spilled_bytes={}",
+                            iteration.iteration,
+                            iteration.total_ms,
+                            iteration.output_rows,
+                            iteration.metrics.spilled_bytes
+                        );
+                    }
+                }
+                query_results.push(result);
+            }
+
+            let report = BenchmarkReport::new(
+                args.source,
+                runtime,
+                query_run,
+                args.warehouse.display().to_string(),
+                args.data
+                    .as_ref()
+                    .map(|path| path.display().to_string())
+                    .unwrap_or_default(),
+                args.queries.display().to_string(),
+                args.database,
+                query_results,
+            );
+            write_report(&report, &args.output)?;
+            println!("Report written to {}", args.output.display());
+            if report.has_failures() {
+                return Err(IoError::other(format!(
+                    "one or more queries failed; see {}",
+                    args.output.display()
+                ))
+                .into());
+            }
+            Ok(())
+        }
+    }
+}
+
+fn select_tables(selection: Option<&str>) -> Result<Vec<&'static str>, 
BoxError> {
+    let Some(selection) = selection else {
+        return Ok(TPCDS_TABLES.to_vec());
+    };
+    let selected = selection
+        .split(',')
+        .map(str::trim)
+        .filter(|name| !name.is_empty())
+        .collect::<HashSet<_>>();
+    if selected.is_empty() {
+        return Ok(TPCDS_TABLES.to_vec());
+    }
+    if let Some(unknown) = selected.iter().find(|name| 
!TPCDS_TABLES.contains(name)) {
+        return Err(invalid_input(format!("unknown TPC-DS table '{unknown}'")));
+    }
+    Ok(TPCDS_TABLES
+        .iter()
+        .copied()
+        .filter(|name| selected.contains(name))
+        .collect())
+}
+
+fn invalid_input(message: impl Into<String>) -> BoxError {
+    IoError::new(ErrorKind::InvalidInput, message.into()).into()
+}
diff --git a/benchmarks/tpcds/src/context.rs b/benchmarks/tpcds/src/context.rs
new file mode 100644
index 00000000..a4a4cd4e
--- /dev/null
+++ b/benchmarks/tpcds/src/context.rs
@@ -0,0 +1,101 @@
+// 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.
+
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+use datafusion::error::Result as DataFusionResult;
+use datafusion::execution::runtime_env::RuntimeEnvBuilder;
+use datafusion::execution::SessionStateBuilder;
+use paimon::{CatalogOptions, FileSystemCatalog, Options};
+use paimon_datafusion::SQLContext;
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct BenchmarkRuntimeConfig {
+    pub target_partitions: usize,
+    pub memory_limit_bytes: Option<usize>,
+    pub spill_dir: Option<PathBuf>,
+    pub max_spill_bytes: Option<u64>,
+}
+
+impl Default for BenchmarkRuntimeConfig {
+    fn default() -> Self {
+        Self {
+            target_partitions: std::thread::available_parallelism()
+                .map(usize::from)
+                .unwrap_or(1),
+            memory_limit_bytes: None,
+            spill_dir: None,
+            max_spill_bytes: None,
+        }
+    }
+}
+
+pub fn build_sql_context(config: &BenchmarkRuntimeConfig) -> 
DataFusionResult<SQLContext> {
+    let mut runtime = RuntimeEnvBuilder::new();
+    if let Some(memory_limit) = config.memory_limit_bytes {
+        runtime = runtime.with_memory_limit(memory_limit, 1.0);
+    }
+    if let Some(spill_dir) = &config.spill_dir {
+        runtime = runtime.with_temp_file_path(spill_dir);
+    }
+    if let Some(max_spill_bytes) = config.max_spill_bytes {
+        runtime = runtime.with_max_temp_directory_size(max_spill_bytes);
+    }
+    let sql = SQLContext::new();
+    let state_ref = sql.ctx().state_ref();
+    let current_state = state_ref.read().clone();
+    let session_config = current_state
+        .config()
+        .clone()
+        .with_target_partitions(config.target_partitions.max(1));
+    let state = SessionStateBuilder::from(current_state)
+        .with_config(session_config)
+        .with_runtime_env(Arc::new(runtime.build()?))
+        .build();
+    *state_ref.write() = state;
+    drop(state_ref);
+    Ok(sql)
+}
+
+pub struct CatalogSession {
+    pub sql: SQLContext,
+    pub catalog: Arc<FileSystemCatalog>,
+    pub catalog_name: String,
+    pub database: String,
+}
+
+pub async fn open_catalog_session(
+    runtime_config: &BenchmarkRuntimeConfig,
+    warehouse: &Path,
+    database: &str,
+) -> Result<CatalogSession, Box<dyn std::error::Error + Send + Sync>> {
+    std::fs::create_dir_all(warehouse)?;
+    let mut options = Options::new();
+    options.set(CatalogOptions::WAREHOUSE, warehouse.display().to_string());
+    let catalog = Arc::new(FileSystemCatalog::new(options)?);
+    let mut sql = build_sql_context(runtime_config)?;
+    sql.register_catalog_with_default_db("paimon", catalog.clone(), 
Some(database))
+        .await?;
+    Ok(CatalogSession {
+        sql,
+        catalog,
+        catalog_name: "paimon".to_string(),
+        database: database.to_string(),
+    })
+}
diff --git a/benchmarks/tpcds/src/lib.rs b/benchmarks/tpcds/src/lib.rs
new file mode 100644
index 00000000..6229b550
--- /dev/null
+++ b/benchmarks/tpcds/src/lib.rs
@@ -0,0 +1,292 @@
+// 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.
+
+use std::collections::BTreeSet;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use datafusion::sql::sqlparser::dialect::GenericDialect;
+use datafusion::sql::sqlparser::parser::Parser;
+
+mod cli;
+mod command;
+mod context;
+mod load;
+mod report;
+mod run;
+
+pub use cli::{Cli, Command, ExistingPolicyArg, LoadArgs, RunArgs, RuntimeArgs};
+pub use command::execute_command;
+pub use context::{
+    build_sql_context, open_catalog_session, BenchmarkRuntimeConfig, 
CatalogSession,
+};
+pub use load::{load_parquet_table, ExistingTablePolicy, LoadStatus, 
TableLoadResult};
+pub use report::{write_report, BenchmarkReport, SourceKind, 
NON_TPC_DISCLOSURE};
+pub use run::{
+    register_parquet_tables, run_query_file, IterationResult, PhysicalMetrics, 
QueryRunConfig,
+    QueryRunResult,
+};
+
+pub const TPCDS_TABLES: [&str; 24] = [
+    "call_center",
+    "catalog_page",
+    "catalog_returns",
+    "catalog_sales",
+    "customer",
+    "customer_address",
+    "customer_demographics",
+    "date_dim",
+    "time_dim",
+    "household_demographics",
+    "income_band",
+    "inventory",
+    "item",
+    "promotion",
+    "reason",
+    "ship_mode",
+    "store",
+    "store_returns",
+    "store_sales",
+    "warehouse",
+    "web_page",
+    "web_returns",
+    "web_sales",
+    "web_site",
+];
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct QueryFile {
+    pub number: u32,
+    pub path: PathBuf,
+    pub statements: Vec<String>,
+}
+
+pub fn load_query_files(query_dir: &Path, queries: &[u32]) -> 
Result<Vec<QueryFile>, String> {
+    queries
+        .iter()
+        .map(|number| {
+            let path = query_dir.join(format!("q{number}.sql"));
+            let sql = fs::read_to_string(&path)
+                .map_err(|error| format!("failed to read {}: {error}", 
path.display()))?;
+            let statements = Parser::parse_sql(&GenericDialect, &sql)
+                .map_err(|error| format!("failed to parse {}: {error}", 
path.display()))?
+                .into_iter()
+                .map(|statement| statement.to_string())
+                .collect::<Vec<_>>();
+            if statements.is_empty() {
+                return Err(format!("query file {} is empty", path.display()));
+            }
+            Ok(QueryFile {
+                number: *number,
+                path,
+                statements,
+            })
+        })
+        .collect()
+}
+
+pub fn parse_number_selection(
+    selection: Option<&str>,
+    min: u32,
+    max: u32,
+) -> Result<Vec<u32>, String> {
+    let mut values = BTreeSet::new();
+    let selection = selection.unwrap_or("");
+    for part in selection
+        .split(',')
+        .map(str::trim)
+        .filter(|s| !s.is_empty())
+    {
+        if let Some((start, end)) = part.split_once('-') {
+            let start = start
+                .trim()
+                .parse::<u32>()
+                .map_err(|_| format!("invalid selection value '{start}'"))?;
+            let end = end
+                .trim()
+                .parse::<u32>()
+                .map_err(|_| format!("invalid selection value '{end}'"))?;
+            if start > end {
+                return Err(format!("invalid descending range '{part}'"));
+            }
+            values.extend(start..=end);
+        } else {
+            values.insert(
+                part.parse::<u32>()
+                    .map_err(|_| format!("invalid selection value '{part}'"))?,
+            );
+        }
+    }
+
+    if values.is_empty() {
+        values.extend(min..=max);
+    }
+    if let Some(value) = values.iter().find(|value| **value < min || **value > 
max) {
+        return Err(format!("selection value {value} is outside 
{min}..={max}"));
+    }
+    Ok(values.into_iter().collect())
+}
+
+#[cfg(test)]
+mod tests {
+    use std::fs;
+
+    use clap::Parser;
+    use datafusion::execution::memory_pool::MemoryLimit;
+
+    use super::{
+        build_sql_context, load_query_files, parse_number_selection, 
BenchmarkReport,
+        BenchmarkRuntimeConfig, Cli, Command, QueryRunConfig, SourceKind, 
TPCDS_TABLES,
+    };
+    use tempfile::TempDir;
+
+    #[test]
+    fn query_selection_supports_lists_and_ranges() {
+        assert_eq!(
+            parse_number_selection(Some("1,3-5"), 1, 99).unwrap(),
+            vec![1, 3, 4, 5]
+        );
+    }
+
+    #[test]
+    fn query_files_are_external_and_can_contain_multiple_statements() {
+        let dir = TempDir::new().unwrap();
+        fs::write(dir.path().join("q1.sql"), "SELECT 1; SELECT 2;").unwrap();
+        fs::write(dir.path().join("q3.sql"), "SELECT 3;").unwrap();
+
+        let files = load_query_files(dir.path(), &[1, 3]).unwrap();
+
+        assert_eq!(
+            files.iter().map(|file| file.number).collect::<Vec<_>>(),
+            vec![1, 3]
+        );
+        assert_eq!(files[0].statements, vec!["SELECT 1", "SELECT 2"]);
+        assert_eq!(files[1].statements, vec!["SELECT 3"]);
+    }
+
+    #[test]
+    fn runtime_config_controls_parallelism_memory_and_spill_path() {
+        let spill_dir = TempDir::new().unwrap();
+        let ctx = build_sql_context(&BenchmarkRuntimeConfig {
+            target_partitions: 3,
+            memory_limit_bytes: Some(32 * 1024 * 1024),
+            spill_dir: Some(spill_dir.path().to_path_buf()),
+            max_spill_bytes: Some(64 * 1024 * 1024),
+        })
+        .unwrap();
+
+        assert_eq!(
+            ctx.ctx()
+                .state()
+                .config_options()
+                .execution
+                .target_partitions,
+            3
+        );
+        assert!(matches!(
+            ctx.ctx().runtime_env().memory_pool.memory_limit(),
+            MemoryLimit::Finite(size) if size == 32 * 1024 * 1024
+        ));
+        assert!(
+            
ctx.ctx().runtime_env().disk_manager.temp_dir_paths()[0].starts_with(spill_dir.path())
+        );
+        assert_eq!(
+            ctx.ctx()
+                .runtime_env()
+                .disk_manager
+                .max_temp_directory_size(),
+            64 * 1024 * 1024
+        );
+    }
+
+    #[test]
+    fn canonical_table_list_contains_all_24_tpcds_tables() {
+        assert_eq!(TPCDS_TABLES.len(), 24);
+        assert_eq!(TPCDS_TABLES.first(), Some(&"call_center"));
+        assert_eq!(TPCDS_TABLES.last(), Some(&"web_site"));
+        assert!(TPCDS_TABLES.contains(&"store_sales"));
+    }
+
+    #[test]
+    fn report_json_contains_non_tpc_disclosure_and_round_trips() {
+        let report = BenchmarkReport::new(
+            SourceKind::Paimon,
+            BenchmarkRuntimeConfig::default(),
+            QueryRunConfig::default(),
+            "/warehouse".to_string(),
+            "/data".to_string(),
+            "/queries".to_string(),
+            "tpcds".to_string(),
+            vec![],
+        );
+
+        let json = serde_json::to_string(&report).unwrap();
+        let decoded: BenchmarkReport = serde_json::from_str(&json).unwrap();
+
+        assert_eq!(decoded.source, SourceKind::Paimon);
+        assert!(decoded.disclosure.contains("non-TPC"));
+        assert_eq!(decoded.datafusion_version, "54.0.0");
+    }
+
+    #[test]
+    fn cli_parses_a_paimon_run_without_a_parquet_data_path() {
+        let cli = Cli::try_parse_from([
+            "paimon-tpcds-bench",
+            "run",
+            "--source",
+            "paimon",
+            "--warehouse",
+            "/warehouse",
+            "--queries",
+            "/queries",
+            "--output",
+            "/report.json",
+            "--query",
+            "1,3-5",
+        ])
+        .unwrap();
+
+        let Command::Run(args) = cli.command else {
+            panic!("expected run command");
+        };
+        assert_eq!(args.source, SourceKind::Paimon);
+        assert_eq!(args.query.as_deref(), Some("1,3-5"));
+        assert!(args.data.is_none());
+    }
+
+    #[test]
+    fn parquet_run_requires_a_data_path() {
+        let cli = Cli::try_parse_from([
+            "paimon-tpcds-bench",
+            "run",
+            "--source",
+            "parquet",
+            "--warehouse",
+            "/warehouse",
+            "--queries",
+            "/queries",
+            "--output",
+            "/report.json",
+        ])
+        .unwrap();
+        let Command::Run(args) = cli.command else {
+            panic!("expected run command");
+        };
+
+        assert!(args.validate().unwrap_err().contains("--data"));
+    }
+}
diff --git a/benchmarks/tpcds/src/load.rs b/benchmarks/tpcds/src/load.rs
new file mode 100644
index 00000000..4a52105c
--- /dev/null
+++ b/benchmarks/tpcds/src/load.rs
@@ -0,0 +1,169 @@
+// 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.
+
+use std::path::Path;
+use std::time::Instant;
+
+use datafusion::arrow::array::UInt64Array;
+use datafusion::prelude::ParquetReadOptions;
+use paimon::arrow::arrow_to_paimon_type;
+use paimon::catalog::Identifier;
+use paimon::spec::Schema;
+use paimon::{Catalog, Error as PaimonError};
+use serde::{Deserialize, Serialize};
+
+use crate::context::CatalogSession;
+
+type BoxError = Box<dyn std::error::Error + Send + Sync>;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ExistingTablePolicy {
+    Error,
+    Skip,
+    Overwrite,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum LoadStatus {
+    Loaded,
+    Skipped,
+    Overwritten,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct TableLoadResult {
+    pub table: String,
+    pub status: LoadStatus,
+    pub rows: u64,
+    pub elapsed_ms: u128,
+}
+
+pub async fn load_parquet_table(
+    session: &CatalogSession,
+    data_root: &Path,
+    table_name: &str,
+    existing_policy: ExistingTablePolicy,
+) -> Result<TableLoadResult, BoxError> {
+    let started = Instant::now();
+    let identifier = Identifier::new(&session.database, table_name);
+    let exists = match session.catalog.get_table(&identifier).await {
+        Ok(_) => true,
+        Err(PaimonError::TableNotExist { .. }) => false,
+        Err(error) => return Err(error.into()),
+    };
+
+    if exists && existing_policy == ExistingTablePolicy::Skip {
+        return Ok(TableLoadResult {
+            table: table_name.to_string(),
+            status: LoadStatus::Skipped,
+            rows: 0,
+            elapsed_ms: started.elapsed().as_millis(),
+        });
+    }
+    if exists && existing_policy == ExistingTablePolicy::Error {
+        return Err(PaimonError::TableAlreadyExist {
+            full_name: identifier.full_name(),
+        }
+        .into());
+    }
+
+    let source_path = data_root.join(format!("{table_name}.parquet"));
+    let source_path = source_path
+        .to_str()
+        .ok_or_else(|| format!("source path is not valid UTF-8: {}", 
source_path.display()))?;
+    let source = session
+        .sql
+        .ctx()
+        .read_parquet(source_path, ParquetReadOptions::default())
+        .await?;
+    let arrow_schema = source.schema().inner().clone();
+
+    if !exists {
+        let mut schema = Schema::builder();
+        for field in arrow_schema.fields() {
+            schema = schema.column(
+                field.name(),
+                arrow_to_paimon_type(field.data_type(), field.is_nullable())?,
+            );
+        }
+        session
+            .catalog
+            .create_table(&identifier, schema.build()?, false)
+            .await?;
+    }
+
+    let source_name = format!("__tpcds_source_{table_name}");
+    let source_reference = format!(
+        "{}.{}.{}",
+        quote_identifier(&session.catalog_name),
+        quote_identifier(&session.database),
+        quote_identifier(&source_name)
+    );
+    if session.sql.temp_table_exist(source_reference.as_str())? {
+        session
+            .sql
+            .deregister_temp_table(source_reference.as_str())?;
+    }
+    session
+        .sql
+        .register_temp_table(source_reference.as_str(), source.into_view())?;
+
+    let target_reference = format!(
+        "{}.{}.{}",
+        quote_identifier(&session.catalog_name),
+        quote_identifier(&session.database),
+        quote_identifier(table_name)
+    );
+    let operation = if exists {
+        "INSERT OVERWRITE"
+    } else {
+        "INSERT INTO"
+    };
+    let load_result = session
+        .sql
+        .sql(&format!(
+            "{operation} {target_reference} SELECT * FROM {source_reference}"
+        ))
+        .await;
+    let batches = match load_result {
+        Ok(frame) => frame.collect().await,
+        Err(error) => Err(error),
+    };
+    let _ = session.sql.deregister_temp_table(source_reference.as_str());
+    let batches = batches?;
+    let rows = batches
+        .first()
+        .and_then(|batch| 
batch.column(0).as_any().downcast_ref::<UInt64Array>())
+        .map(|counts| counts.value(0))
+        .ok_or("DataFusion INSERT did not return a UInt64 row count")?;
+
+    Ok(TableLoadResult {
+        table: table_name.to_string(),
+        status: if exists {
+            LoadStatus::Overwritten
+        } else {
+            LoadStatus::Loaded
+        },
+        rows,
+        elapsed_ms: started.elapsed().as_millis(),
+    })
+}
+
+fn quote_identifier(identifier: &str) -> String {
+    format!("\"{}\"", identifier.replace('"', "\"\""))
+}
diff --git a/benchmarks/tpcds/src/main.rs b/benchmarks/tpcds/src/main.rs
new file mode 100644
index 00000000..27f3267c
--- /dev/null
+++ b/benchmarks/tpcds/src/main.rs
@@ -0,0 +1,24 @@
+// 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.
+
+use clap::Parser;
+use paimon_tpcds_bench::{execute_command, Cli};
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
+    execute_command(Cli::parse().command).await
+}
diff --git a/benchmarks/tpcds/src/report.rs b/benchmarks/tpcds/src/report.rs
new file mode 100644
index 00000000..b026f19a
--- /dev/null
+++ b/benchmarks/tpcds/src/report.rs
@@ -0,0 +1,109 @@
+// 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.
+
+use std::path::Path;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use clap::ValueEnum;
+use serde::{Deserialize, Serialize};
+
+use crate::{BenchmarkRuntimeConfig, QueryRunConfig, QueryRunResult};
+
+pub const NON_TPC_DISCLOSURE: &str =
+    "TPC-DS-derived non-TPC benchmark; these results are not official TPC 
results.";
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
+#[serde(rename_all = "snake_case")]
+pub enum SourceKind {
+    Paimon,
+    Parquet,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct BenchmarkReport {
+    pub schema_version: u32,
+    pub disclosure: String,
+    pub engine: String,
+    pub datafusion_version: String,
+    pub paimon_version: String,
+    pub created_unix_ms: u128,
+    pub source: SourceKind,
+    pub runtime: BenchmarkRuntimeConfig,
+    pub query_run: QueryRunConfig,
+    pub warehouse: String,
+    pub data_root: String,
+    pub query_dir: String,
+    pub database: String,
+    pub queries: Vec<QueryRunResult>,
+}
+
+impl BenchmarkReport {
+    #[allow(clippy::too_many_arguments)]
+    pub fn new(
+        source: SourceKind,
+        runtime: BenchmarkRuntimeConfig,
+        query_run: QueryRunConfig,
+        warehouse: String,
+        data_root: String,
+        query_dir: String,
+        database: String,
+        queries: Vec<QueryRunResult>,
+    ) -> Self {
+        Self {
+            schema_version: 1,
+            disclosure: NON_TPC_DISCLOSURE.to_string(),
+            engine: "datafusion+paimon-rust".to_string(),
+            datafusion_version: datafusion::DATAFUSION_VERSION.to_string(),
+            paimon_version: env!("CARGO_PKG_VERSION").to_string(),
+            created_unix_ms: SystemTime::now()
+                .duration_since(UNIX_EPOCH)
+                .unwrap_or_default()
+                .as_millis(),
+            source,
+            runtime,
+            query_run,
+            warehouse,
+            data_root,
+            query_dir,
+            database,
+            queries,
+        }
+    }
+
+    pub fn has_failures(&self) -> bool {
+        self.queries.iter().any(|query| {
+            !query.warmup_failures.is_empty()
+                || query
+                    .iterations
+                    .iter()
+                    .any(|iteration| iteration.error.is_some())
+        })
+    }
+}
+
+pub fn write_report(
+    report: &BenchmarkReport,
+    path: &Path,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
+    if let Some(parent) = path.parent() {
+        if !parent.as_os_str().is_empty() {
+            std::fs::create_dir_all(parent)?;
+        }
+    }
+    std::fs::write(path, serde_json::to_vec_pretty(report)?)?;
+    Ok(())
+}
diff --git a/benchmarks/tpcds/src/run.rs b/benchmarks/tpcds/src/run.rs
new file mode 100644
index 00000000..af5b063a
--- /dev/null
+++ b/benchmarks/tpcds/src/run.rs
@@ -0,0 +1,273 @@
+// 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.
+
+use std::path::Path;
+use std::time::Instant;
+
+use datafusion::physical_plan::{collect, ExecutionPlan};
+use datafusion::prelude::ParquetReadOptions;
+use serde::{Deserialize, Serialize};
+
+use crate::context::CatalogSession;
+use crate::QueryFile;
+
+type BoxError = Box<dyn std::error::Error + Send + Sync>;
+
+pub async fn register_parquet_tables<S: AsRef<str>>(
+    session: &CatalogSession,
+    data_root: &Path,
+    tables: &[S],
+) -> Result<(), BoxError> {
+    for table in tables {
+        let table = table.as_ref();
+        let path = data_root.join(format!("{table}.parquet"));
+        let path = path
+            .to_str()
+            .ok_or_else(|| format!("source path is not valid UTF-8: {}", 
path.display()))?;
+        let frame = session
+            .sql
+            .ctx()
+            .read_parquet(path, ParquetReadOptions::default())
+            .await?;
+        let table_reference = format!(
+            "{}.{}.{}",
+            quote_identifier(&session.catalog_name),
+            quote_identifier(&session.database),
+            quote_identifier(table)
+        );
+        if session.sql.temp_table_exist(table_reference.as_str())? {
+            session
+                .sql
+                .deregister_temp_table(table_reference.as_str())?;
+        }
+        session
+            .sql
+            .register_temp_table(table_reference.as_str(), frame.into_view())?;
+    }
+    Ok(())
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct QueryRunConfig {
+    pub warmup_iterations: usize,
+    pub measured_iterations: usize,
+}
+
+impl Default for QueryRunConfig {
+    fn default() -> Self {
+        Self {
+            warmup_iterations: 1,
+            measured_iterations: 3,
+        }
+    }
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PhysicalMetrics {
+    pub spill_count: u64,
+    pub spilled_rows: u64,
+    pub spilled_bytes: u64,
+    pub bytes_scanned: u64,
+    pub operator_peak_memory_bytes: u64,
+}
+
+impl PhysicalMetrics {
+    fn add_plan(&mut self, plan: &dyn ExecutionPlan) {
+        if let Some(metrics) = plan.metrics() {
+            self.spill_count += metrics.spill_count().unwrap_or(0) as u64;
+            self.spilled_rows += metrics.spilled_rows().unwrap_or(0) as u64;
+            self.spilled_bytes += metrics.spilled_bytes().unwrap_or(0) as u64;
+            self.bytes_scanned += metrics
+                .sum_by_name("bytes_scanned")
+                .map(|value| value.as_usize() as u64)
+                .unwrap_or(0);
+            self.operator_peak_memory_bytes += metrics
+                .sum_by_name("peak_mem_used")
+                .map(|value| value.as_usize() as u64)
+                .unwrap_or(0);
+        }
+        for child in plan.children() {
+            self.add_plan(child.as_ref());
+        }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct IterationResult {
+    pub iteration: usize,
+    pub logical_planning_ms: f64,
+    pub physical_planning_ms: f64,
+    pub execution_ms: f64,
+    pub total_ms: f64,
+    pub output_rows: u64,
+    pub metrics: PhysicalMetrics,
+    pub error: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct QueryRunResult {
+    pub query: u32,
+    pub path: String,
+    pub warmup_failures: Vec<String>,
+    pub iterations: Vec<IterationResult>,
+}
+
+pub async fn run_query_file(
+    session: &CatalogSession,
+    query: &QueryFile,
+    config: &QueryRunConfig,
+) -> QueryRunResult {
+    let mut warmup_failures = Vec::new();
+    for iteration in 0..config.warmup_iterations {
+        let result = execute_iteration(session, query, iteration).await;
+        if let Some(error) = result.error {
+            warmup_failures.push(error);
+        }
+    }
+
+    let mut iterations = Vec::with_capacity(config.measured_iterations);
+    for iteration in 0..config.measured_iterations {
+        iterations.push(execute_iteration(session, query, iteration + 
1).await);
+    }
+
+    QueryRunResult {
+        query: query.number,
+        path: query.path.display().to_string(),
+        warmup_failures,
+        iterations,
+    }
+}
+
+async fn execute_iteration(
+    session: &CatalogSession,
+    query: &QueryFile,
+    iteration: usize,
+) -> IterationResult {
+    let total_started = Instant::now();
+    let mut logical_planning_ms = 0.0;
+    let mut physical_planning_ms = 0.0;
+    let mut execution_ms = 0.0;
+    let mut output_rows = 0u64;
+    let mut metrics = PhysicalMetrics::default();
+
+    for statement in &query.statements {
+        let logical_started = Instant::now();
+        let frame = match session.sql.sql(statement).await {
+            Ok(frame) => frame,
+            Err(error) => {
+                logical_planning_ms += elapsed_ms(logical_started);
+                return failed_iteration(
+                    iteration,
+                    logical_planning_ms,
+                    physical_planning_ms,
+                    execution_ms,
+                    total_started,
+                    output_rows,
+                    metrics,
+                    error.to_string(),
+                );
+            }
+        };
+        logical_planning_ms += elapsed_ms(logical_started);
+
+        let physical_started = Instant::now();
+        let plan = match frame.create_physical_plan().await {
+            Ok(plan) => plan,
+            Err(error) => {
+                physical_planning_ms += elapsed_ms(physical_started);
+                return failed_iteration(
+                    iteration,
+                    logical_planning_ms,
+                    physical_planning_ms,
+                    execution_ms,
+                    total_started,
+                    output_rows,
+                    metrics,
+                    error.to_string(),
+                );
+            }
+        };
+        physical_planning_ms += elapsed_ms(physical_started);
+
+        let execution_started = Instant::now();
+        let batches = match collect(plan.clone(), 
session.sql.ctx().task_ctx()).await {
+            Ok(batches) => batches,
+            Err(error) => {
+                execution_ms += elapsed_ms(execution_started);
+                metrics.add_plan(plan.as_ref());
+                return failed_iteration(
+                    iteration,
+                    logical_planning_ms,
+                    physical_planning_ms,
+                    execution_ms,
+                    total_started,
+                    output_rows,
+                    metrics,
+                    error.to_string(),
+                );
+            }
+        };
+        execution_ms += elapsed_ms(execution_started);
+        output_rows += batches
+            .iter()
+            .map(|batch| batch.num_rows() as u64)
+            .sum::<u64>();
+        metrics.add_plan(plan.as_ref());
+    }
+
+    IterationResult {
+        iteration,
+        logical_planning_ms,
+        physical_planning_ms,
+        execution_ms,
+        total_ms: elapsed_ms(total_started),
+        output_rows,
+        metrics,
+        error: None,
+    }
+}
+
+#[allow(clippy::too_many_arguments)]
+fn failed_iteration(
+    iteration: usize,
+    logical_planning_ms: f64,
+    physical_planning_ms: f64,
+    execution_ms: f64,
+    total_started: Instant,
+    output_rows: u64,
+    metrics: PhysicalMetrics,
+    error: String,
+) -> IterationResult {
+    IterationResult {
+        iteration,
+        logical_planning_ms,
+        physical_planning_ms,
+        execution_ms,
+        total_ms: elapsed_ms(total_started),
+        output_rows,
+        metrics,
+        error: Some(error),
+    }
+}
+
+fn elapsed_ms(started: Instant) -> f64 {
+    started.elapsed().as_secs_f64() * 1_000.0
+}
+
+fn quote_identifier(identifier: &str) -> String {
+    format!("\"{}\"", identifier.replace('"', "\"\""))
+}
diff --git a/benchmarks/tpcds/tests/smoke.rs b/benchmarks/tpcds/tests/smoke.rs
new file mode 100644
index 00000000..ff175b0f
--- /dev/null
+++ b/benchmarks/tpcds/tests/smoke.rs
@@ -0,0 +1,225 @@
+// 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.
+
+use std::fs::{self, File};
+use std::sync::Arc;
+
+use arrow_array::{Int32Array, Int64Array, RecordBatch, StringArray};
+use arrow_schema::{DataType, Field, Schema};
+use paimon_tpcds_bench::{
+    execute_command, load_parquet_table, load_query_files, 
open_catalog_session,
+    register_parquet_tables, run_query_file, BenchmarkReport, 
BenchmarkRuntimeConfig, Command,
+    ExistingPolicyArg, ExistingTablePolicy, LoadArgs, LoadStatus, 
QueryRunConfig, RunArgs,
+    RuntimeArgs, SourceKind,
+};
+use parquet::arrow::ArrowWriter;
+use tempfile::TempDir;
+
+fn write_fixture(root: &TempDir, table: &str) {
+    let table_dir = root.path().join(format!("{table}.parquet"));
+    fs::create_dir_all(&table_dir).unwrap();
+    let schema = Arc::new(Schema::new(vec![
+        Field::new("id", DataType::Int32, false),
+        Field::new("name", DataType::Utf8, true),
+    ]));
+    let batch = RecordBatch::try_new(
+        Arc::clone(&schema),
+        vec![
+            Arc::new(Int32Array::from(vec![1, 2, 3])),
+            Arc::new(StringArray::from(vec!["a", "b", "c"])),
+        ],
+    )
+    .unwrap();
+    let mut writer = ArrowWriter::try_new(
+        File::create(table_dir.join("part-0.parquet")).unwrap(),
+        schema,
+        None,
+    )
+    .unwrap();
+    writer.write(&batch).unwrap();
+    writer.close().unwrap();
+}
+
+#[tokio::test]
+async fn parquet_fixture_loads_into_paimon() {
+    let data = TempDir::new().unwrap();
+    let warehouse = TempDir::new().unwrap();
+    write_fixture(&data, "store_sales");
+    let session = open_catalog_session(
+        &BenchmarkRuntimeConfig::default(),
+        warehouse.path(),
+        "tpcds",
+    )
+    .await
+    .unwrap();
+
+    let loaded = load_parquet_table(
+        &session,
+        data.path(),
+        "store_sales",
+        ExistingTablePolicy::Error,
+    )
+    .await
+    .unwrap();
+
+    assert_eq!(loaded.status, LoadStatus::Loaded);
+    assert_eq!(loaded.rows, 3);
+    let batches = session
+        .sql
+        .sql("SELECT COUNT(*) FROM paimon.tpcds.store_sales")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    let counts = batches[0]
+        .column(0)
+        .as_any()
+        .downcast_ref::<Int64Array>()
+        .unwrap();
+    assert_eq!(counts.value(0), 3);
+}
+
+#[tokio::test]
+async fn loaded_paimon_table_runs_warmups_and_measured_iterations() {
+    let data = TempDir::new().unwrap();
+    let warehouse = TempDir::new().unwrap();
+    let queries = TempDir::new().unwrap();
+    write_fixture(&data, "store_sales");
+    fs::write(
+        queries.path().join("q1.sql"),
+        "SELECT COUNT(*), SUM(id) FROM store_sales;",
+    )
+    .unwrap();
+    let session = open_catalog_session(
+        &BenchmarkRuntimeConfig::default(),
+        warehouse.path(),
+        "tpcds",
+    )
+    .await
+    .unwrap();
+    load_parquet_table(
+        &session,
+        data.path(),
+        "store_sales",
+        ExistingTablePolicy::Error,
+    )
+    .await
+    .unwrap();
+    let query = load_query_files(queries.path(), &[1]).unwrap().remove(0);
+
+    let result = run_query_file(
+        &session,
+        &query,
+        &QueryRunConfig {
+            warmup_iterations: 1,
+            measured_iterations: 2,
+        },
+    )
+    .await;
+
+    assert_eq!(result.query, 1);
+    assert_eq!(result.warmup_failures.len(), 0);
+    assert_eq!(result.iterations.len(), 2);
+    assert!(result.iterations.iter().all(|iteration| {
+        iteration.error.is_none() && iteration.output_rows == 1 && 
iteration.total_ms > 0.0
+    }));
+}
+
+#[tokio::test]
+async fn parquet_baseline_uses_the_same_query_runner() {
+    let data = TempDir::new().unwrap();
+    let catalog = TempDir::new().unwrap();
+    let queries = TempDir::new().unwrap();
+    write_fixture(&data, "store_sales");
+    fs::write(
+        queries.path().join("q1.sql"),
+        "SELECT COUNT(*), SUM(id) FROM store_sales;",
+    )
+    .unwrap();
+    let session = open_catalog_session(&BenchmarkRuntimeConfig::default(), 
catalog.path(), "tpcds")
+        .await
+        .unwrap();
+    register_parquet_tables(&session, data.path(), &["store_sales"])
+        .await
+        .unwrap();
+    let query = load_query_files(queries.path(), &[1]).unwrap().remove(0);
+
+    let result = run_query_file(
+        &session,
+        &query,
+        &QueryRunConfig {
+            warmup_iterations: 0,
+            measured_iterations: 1,
+        },
+    )
+    .await;
+
+    assert_eq!(result.iterations[0].output_rows, 1);
+    assert!(result.iterations[0].error.is_none());
+}
+
+#[tokio::test]
+async fn command_orchestration_loads_and_writes_a_run_report() {
+    let data = TempDir::new().unwrap();
+    let warehouse = TempDir::new().unwrap();
+    let queries = TempDir::new().unwrap();
+    let output = warehouse.path().join("report.json");
+    write_fixture(&data, "store_sales");
+    fs::write(
+        queries.path().join("q1.sql"),
+        "SELECT COUNT(*) FROM store_sales;",
+    )
+    .unwrap();
+    let runtime = RuntimeArgs {
+        target_partitions: Some(2),
+        memory_limit_gib: None,
+        spill_dir: None,
+        max_spill_gib: None,
+    };
+
+    execute_command(Command::Load(LoadArgs {
+        data: data.path().to_path_buf(),
+        warehouse: warehouse.path().to_path_buf(),
+        database: "tpcds".to_string(),
+        tables: Some("store_sales".to_string()),
+        if_exists: ExistingPolicyArg::Error,
+        runtime: runtime.clone(),
+    }))
+    .await
+    .unwrap();
+    execute_command(Command::Run(RunArgs {
+        source: SourceKind::Paimon,
+        data: None,
+        warehouse: warehouse.path().to_path_buf(),
+        queries: queries.path().to_path_buf(),
+        output: output.clone(),
+        database: "tpcds".to_string(),
+        query: Some("1".to_string()),
+        tables: None,
+        warmup: 0,
+        iterations: 1,
+        runtime,
+    }))
+    .await
+    .unwrap();
+
+    let report: BenchmarkReport = 
serde_json::from_slice(&fs::read(output).unwrap()).unwrap();
+    assert_eq!(report.source, SourceKind::Paimon);
+    assert_eq!(report.queries.len(), 1);
+    assert!(!report.has_failures());
+}

Reply via email to