This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion.git
The following commit(s) were added to refs/heads/main by this push:
new 24483dbeed bench: multi-conjunct shared-prefix struct row-filter
pushdown (#23524)
24483dbeed is described below
commit 24483dbeed7811a21480f307efd7becd87ce3111
Author: Subham Singhal <[email protected]>
AuthorDate: Thu Aug 6 19:10:30 2026 +0530
bench: multi-conjunct shared-prefix struct row-filter pushdown (#23524)
## Which issue does this PR close?
- Follow-up to review feedback on #23217.
## Rationale for this change
The row-filter planner in `datafusion-datasource-parquet` walks the
accessed struct-field paths for every filter, resolves the Parquet
leaves, and builds a pruned Arrow schema. When multiple predicates share
a common struct prefix, the planner can consolidate that traversal work
— this is the case the `StructAccessTree` refactor in #23217 is intended
to accelerate.
The existing bench suite does not cover this shape:
- `parquet_struct_query` — single-field struct predicates only
(`WHERE s['id'] = 5`).
- `parquet_struct_projection` — projection-only, no `WHERE` clause.
So multi-conjunct shared-prefix planning is currently uninstrumented,
and the effect of consolidating access-path traversals is invisible
against the existing suite.
## What changes are included in this PR?
## Are these changes tested?
## Are there any user-facing changes?
No. Benchmark-only; no library or API surface is touched.
---
datafusion/core/Cargo.toml | 5 +
.../parquet_struct_shared_prefix_pushdown.rs | 329 +++++++++++++++++++++
2 files changed, 334 insertions(+)
diff --git a/datafusion/core/Cargo.toml b/datafusion/core/Cargo.toml
index 8679dad9f9..16db28a6d7 100644
--- a/datafusion/core/Cargo.toml
+++ b/datafusion/core/Cargo.toml
@@ -257,6 +257,11 @@ harness = false
name = "parquet_struct_projection"
required-features = ["parquet"]
+[[bench]]
+harness = false
+name = "parquet_struct_shared_prefix_pushdown"
+required-features = ["parquet"]
+
[[bench]]
harness = false
name = "cse_projection_pushdown"
diff --git a/datafusion/core/benches/parquet_struct_shared_prefix_pushdown.rs
b/datafusion/core/benches/parquet_struct_shared_prefix_pushdown.rs
new file mode 100644
index 0000000000..f08c2b5547
--- /dev/null
+++ b/datafusion/core/benches/parquet_struct_shared_prefix_pushdown.rs
@@ -0,0 +1,329 @@
+// 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.
+
+//! Benchmarks for row-filter pushdown with predicates that reach several
struct
+//! leaves under a common prefix.
+//!
+//! The existing `parquet_struct_query` bench exercises single-field struct
+//! predicates only; `parquet_struct_projection` has no `WHERE` clause. Neither
+//! drives the row-filter planner with multiple accesses under the same struct
+//! root.
+//!
+//! Two properties of the planner shape these queries, and both are easy to get
+//! wrong:
+//!
+//! * `execution.parquet.pushdown_filters` must be enabled. It defaults to
+//! `false`, in which case no row filter is built and every case below
+//! degenerates to a plain scan.
+//! * The predicate must be a *single* conjunct. `build_row_filter` calls
+//! `split_conjunction` before building filter candidates, and each candidate
+//! collects its own access paths, so `s['a'] = 5 AND s['b'] = 5` becomes two
+//! independent single-access candidates and never reaches multi-access
+//! planning. The cases below use the `(s['a'] + s['b']) = 10` form so every
+//! access lands in one candidate.
+//!
+//! Nested access is written `s['inner']['x']`, which the planner represents
as a
+//! single flattened `get_field(s, 'inner', 'x')`. That form is
pushdown-eligible;
+//! a chained `get_field(get_field(s, 'inner'), 'x')` is not.
+//!
+//! Dataset schema:
+//!
+//! ```sql
+//! CREATE TABLE t (
+//! id INT,
+//! s STRUCT<
+//! a INT, b INT, c INT, d INT, e INT,
+//! inner STRUCT<x INT, y INT, z INT>
+//! >
+//! );
+//! ```
+//!
+//! All struct leaves mirror the top-level `id`, so a sum of `n` leaves equals
+//! `n * id` and every predicate is satisfied by exactly one row (`id = 5`).
+//! Holding the match count fixed keeps the cases comparable, and each is
+//! asserted to return that single row.
+
+use arrow::array::{ArrayRef, Int32Array, StructArray};
+use arrow::datatypes::{DataType, Field, Fields, Schema, SchemaRef};
+use arrow::record_batch::RecordBatch;
+use criterion::{Criterion, criterion_group, criterion_main};
+use datafusion::prelude::{SessionConfig, SessionContext};
+use datafusion_common::instant::Instant;
+use parquet::arrow::ArrowWriter;
+use parquet::file::properties::{WriterProperties, WriterVersion};
+use std::hint::black_box;
+use std::path::Path;
+use std::sync::Arc;
+use tempfile::NamedTempFile;
+use tokio::runtime::Runtime;
+
+/// The number of batches to write
+const NUM_BATCHES: usize = 128;
+/// The number of rows in each record batch to write
+const WRITE_RECORD_BATCH_SIZE: usize = 4096;
+/// The number of rows in a row group
+const ROW_GROUP_ROW_COUNT: usize = 65536;
+/// The number of row groups expected
+const EXPECTED_ROW_GROUPS: usize = 8;
+/// Number of rows every predicate is expected to match.
+const EXPECTED_MATCHES: usize = 1;
+
+fn inner_struct_fields() -> Fields {
+ Fields::from(vec![
+ Field::new("x", DataType::Int32, false),
+ Field::new("y", DataType::Int32, false),
+ Field::new("z", DataType::Int32, false),
+ ])
+}
+
+fn struct_fields() -> Fields {
+ Fields::from(vec![
+ Field::new("a", DataType::Int32, false),
+ Field::new("b", DataType::Int32, false),
+ Field::new("c", DataType::Int32, false),
+ Field::new("d", DataType::Int32, false),
+ Field::new("e", DataType::Int32, false),
+ Field::new("inner", DataType::Struct(inner_struct_fields()), false),
+ ])
+}
+
+fn schema() -> SchemaRef {
+ Arc::new(Schema::new(vec![
+ Field::new("id", DataType::Int32, false),
+ Field::new("s", DataType::Struct(struct_fields()), false),
+ ]))
+}
+
+fn generate_batch(batch_id: usize) -> RecordBatch {
+ let schema = schema();
+ let len = WRITE_RECORD_BATCH_SIZE;
+
+ // Sequential IDs give distinct per-row values so a predicate like
+ // `s['a'] = 5` matches exactly one row, mirroring parquet_struct_query.
+ let base_id = (batch_id * len) as i32;
+ let id_values: Vec<i32> = (0..len).map(|i| base_id + i as i32).collect();
+ let id_array = Arc::new(Int32Array::from(id_values.clone()));
+
+ let leaf = || Arc::new(Int32Array::from(id_values.clone())) as ArrayRef;
+
+ let inner_struct = StructArray::from(vec![
+ (Arc::new(Field::new("x", DataType::Int32, false)), leaf()),
+ (Arc::new(Field::new("y", DataType::Int32, false)), leaf()),
+ (Arc::new(Field::new("z", DataType::Int32, false)), leaf()),
+ ]);
+
+ let struct_array = StructArray::from(vec![
+ (Arc::new(Field::new("a", DataType::Int32, false)), leaf()),
+ (Arc::new(Field::new("b", DataType::Int32, false)), leaf()),
+ (Arc::new(Field::new("c", DataType::Int32, false)), leaf()),
+ (Arc::new(Field::new("d", DataType::Int32, false)), leaf()),
+ (Arc::new(Field::new("e", DataType::Int32, false)), leaf()),
+ (
+ Arc::new(Field::new(
+ "inner",
+ DataType::Struct(inner_struct_fields()),
+ false,
+ )),
+ Arc::new(inner_struct) as ArrayRef,
+ ),
+ ]);
+
+ RecordBatch::try_new(schema, vec![id_array,
Arc::new(struct_array)]).unwrap()
+}
+
+fn generate_file() -> NamedTempFile {
+ let now = Instant::now();
+ let mut named_file = tempfile::Builder::new()
+ .prefix("parquet_struct_shared_prefix_pushdown")
+ .suffix(".parquet")
+ .tempfile()
+ .unwrap();
+
+ println!("Generating parquet file - {}", named_file.path().display());
+ let schema = schema();
+
+ let properties = WriterProperties::builder()
+ .set_writer_version(WriterVersion::PARQUET_2_0)
+ .set_max_row_group_row_count(Some(ROW_GROUP_ROW_COUNT))
+ .build();
+
+ let mut writer =
+ ArrowWriter::try_new(&mut named_file, schema,
Some(properties)).unwrap();
+
+ for batch_id in 0..NUM_BATCHES {
+ let batch = generate_batch(batch_id);
+ writer.write(&batch).unwrap();
+ }
+
+ let metadata = writer.close().unwrap();
+ let file_metadata = metadata.file_metadata();
+ let expected_rows = WRITE_RECORD_BATCH_SIZE * NUM_BATCHES;
+ assert_eq!(
+ file_metadata.num_rows() as usize,
+ expected_rows,
+ "Expected {expected_rows} rows but got {}",
+ file_metadata.num_rows()
+ );
+ assert_eq!(
+ metadata.row_groups().len(),
+ EXPECTED_ROW_GROUPS,
+ "Expected {EXPECTED_ROW_GROUPS} row groups but got {}",
+ metadata.row_groups().len()
+ );
+
+ println!(
+ "Generated parquet file with {} rows and {} row groups in {:.2}s",
+ file_metadata.num_rows(),
+ metadata.row_groups().len(),
+ now.elapsed().as_secs_f32()
+ );
+
+ named_file
+}
+
+fn create_context(file_path: &str, rt: &Runtime) -> SessionContext {
+ let mut config = SessionConfig::new();
+ // Row-filter pushdown is off by default. Without it no row filter is
built,
+ // and these benchmarks would time a plain scan for every predicate shape.
+ config.options_mut().execution.parquet.pushdown_filters = true;
+
+ let ctx = SessionContext::new_with_config(config);
+ rt.block_on(ctx.register_parquet("t", file_path, Default::default()))
+ .unwrap();
+ ctx
+}
+
+fn query(ctx: &SessionContext, rt: &Runtime, sql: &str) {
+ let ctx = ctx.clone();
+ let sql = sql.to_string();
+ let df = rt.block_on(ctx.sql(&sql)).unwrap();
+ black_box(rt.block_on(df.collect()).unwrap());
+}
+
+/// Fails unless `sql` actually pushes a row filter into the Parquet decoder
and
+/// matches [`EXPECTED_MATCHES`] rows.
+///
+/// Guards the two silent-failure modes: a disabled `pushdown_filters` and a
+/// predicate shape that turns out not to be pushdown-eligible. Either would
+/// leave the benchmark timing a plain scan instead of row-filter pushdown.
+///
+/// Metrics are read off the executed plan rather than scraped from
+/// `EXPLAIN ANALYZE` text, so the check does not depend on output formatting.
+fn assert_pushdown_active(ctx: &SessionContext, rt: &Runtime, name: &str, sql:
&str) {
+ let (rows, pruned) = rt
+ .block_on(async {
+ let plan = ctx.sql(sql).await?.create_physical_plan().await?;
+ let batches =
+ datafusion::physical_plan::collect(Arc::clone(&plan),
ctx.task_ctx())
+ .await?;
+ let rows = batches.iter().map(|b| b.num_rows()).sum::<usize>();
+ Ok::<_, datafusion_common::DataFusionError>((rows,
rows_pruned(&plan)))
+ })
+ .unwrap();
+
+ assert_eq!(
+ rows, EXPECTED_MATCHES,
+ "`{name}` matched {rows} rows, expected {EXPECTED_MATCHES}"
+ );
+ assert!(
+ pruned > 0,
+ "`{name}` pruned no rows via the Parquet row filter, so it does not \
+ exercise row-filter pushdown (is `pushdown_filters` enabled, and is \
+ the predicate a single pushdown-eligible conjunct?)"
+ );
+}
+
+/// Total `pushdown_rows_pruned` reported anywhere in the executed plan.
+fn rows_pruned(plan: &Arc<dyn datafusion::physical_plan::ExecutionPlan>) ->
usize {
+ let mut total = plan
+ .metrics()
+ .and_then(|metrics| metrics.sum_by_name("pushdown_rows_pruned"))
+ .map(|value| value.as_usize())
+ .unwrap_or(0);
+
+ for child in plan.children() {
+ total += rows_pruned(child);
+ }
+
+ total
+}
+
+fn criterion_benchmark(c: &mut Criterion) {
+ let (file_path, temp_file) = match std::env::var("PARQUET_FILE") {
+ Ok(file) => (file, None),
+ Err(_) => {
+ let temp_file = generate_file();
+ (temp_file.path().display().to_string(), Some(temp_file))
+ }
+ };
+
+ assert!(Path::new(&file_path).exists(), "path not found");
+ println!("Using parquet file {file_path}");
+
+ let rt = Runtime::new().unwrap();
+ let ctx = create_context(&file_path, &rt);
+
+ // Baseline: one access on a single struct leaf.
+ let sql = "select id from t where s['a'] = 5";
+ assert_pushdown_active(&ctx, &rt, "1_access", sql);
+ c.bench_function("1_access", |b| b.iter(|| query(&ctx, &rt, sql)));
+
+ // Two accesses inside one conjunct, sharing the struct root `s`.
+ let sql = "select id from t where (s['a'] + s['b']) = 10";
+ assert_pushdown_active(&ctx, &rt, "2_access_shared_root", sql);
+ c.bench_function("2_access_shared_root", |b| b.iter(|| query(&ctx, &rt,
sql)));
+
+ // Three accesses sharing the struct root `s`.
+ let sql = "select id from t where (s['a'] + s['b'] + s['c']) = 15";
+ assert_pushdown_active(&ctx, &rt, "3_access_shared_root", sql);
+ c.bench_function("3_access_shared_root", |b| b.iter(|| query(&ctx, &rt,
sql)));
+
+ // Five accesses sharing the struct root `s`, amplifying planning cost.
+ let sql = "select id from t \
+ where (s['a'] + s['b'] + s['c'] + s['d'] + s['e']) = 25";
+ assert_pushdown_active(&ctx, &rt, "5_access_shared_root", sql);
+ c.bench_function("5_access_shared_root", |b| b.iter(|| query(&ctx, &rt,
sql)));
+
+ // Two accesses sharing the deeper prefix `s.inner`.
+ let sql = "select id from t where (s['inner']['x'] + s['inner']['y']) =
10";
+ assert_pushdown_active(&ctx, &rt, "2_access_shared_nested_prefix", sql);
+ c.bench_function("2_access_shared_nested_prefix", |b| {
+ b.iter(|| query(&ctx, &rt, sql))
+ });
+
+ // Three accesses sharing the deeper prefix `s.inner`.
+ let sql = "select id from t \
+ where (s['inner']['x'] + s['inner']['y'] + s['inner']['z']) =
15";
+ assert_pushdown_active(&ctx, &rt, "3_access_shared_nested_prefix", sql);
+ c.bench_function("3_access_shared_nested_prefix", |b| {
+ b.iter(|| query(&ctx, &rt, sql))
+ });
+
+ // Mix: two accesses on `s` leaves and two on `s.inner` leaves.
+ let sql = "select id from t \
+ where (s['a'] + s['b'] + s['inner']['x'] + s['inner']['y']) =
20";
+ assert_pushdown_active(&ctx, &rt, "mixed_depth_shared_prefix", sql);
+ c.bench_function("mixed_depth_shared_prefix", |b| {
+ b.iter(|| query(&ctx, &rt, sql))
+ });
+
+ // Temporary file must outlive the benchmarks, it is deleted when dropped
+ drop(temp_file);
+}
+
+criterion_group!(benches, criterion_benchmark);
+criterion_main!(benches);
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]