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

alamb pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git


The following commit(s) were added to refs/heads/main by this push:
     new a7b89dd34e bench(parquet): add same-projection predicate chain 
benchmark (#11007)
a7b89dd34e is described below

commit a7b89dd34eb6bd17e99bdc61a60b37a7cc08f166
Author: Huaijin <[email protected]>
AuthorDate: Sat Sep 19 17:53:34 2026 +0800

    bench(parquet): add same-projection predicate chain benchmark (#11007)
    
    # Which issue does this PR close?
    
    Related to #10926.
    
    # Rationale for this change
    
    `RowFilter` evaluates each `ArrowPredicate` separately, so consecutive
    predicates on the same projection decode that column, or replay it from
    the predicate cache, once per predicate. #10859 fuses such chains. Per
    the contributing guide, the benchmark is submitted separately so it can
    run on the automated runner and serve as the baseline for that change.
    
    # What changes are included in this PR?
    
    A criterion benchmark,
    `parquet/benches/arrow_reader_predicate_fusion.rs`, that scans an
    in-memory Snappy Parquet file of 262,144 rows through the async reader.
    Case names are `type/layout/cache/predicates/profile`:
    
    - `4/all99` chains across both column types (`int64`, `string`), layouts
    (`fragmented`, `clustered`), and cache modes (`cached`, `uncached`): 8
    cases
    - fragmented `1/all99` controls, `2/all99`, and `2/all50` chains across
    both types and cache modes: 12 cases
    - clustered uncached `2/all50` and `4/all50` chains across both types,
    retaining cases where fusion has shown regressions: 4 cases
    - `int64/fragmented/4` with `early1` and `late1` across both cache
    modes, covering predicate ordering: 4 cases
    - a `selection_boundary` group with run lengths 16 and 64 on either side
    of the default row selection policy threshold: 2 cases
    
    30 cases in total. Each case is validated once outside measurement for
    the expected row count and predicate cache use. Run-length cases also
    validate the predicate mask run lengths.
    
    # Are these changes tested?
    
    The benchmark builds and all 30 cases pass with `cargo bench -p parquet
    --bench arrow_reader_predicate_fusion --features "arrow async snap"
    --locked -- --test`.
    
    # Are there any user-facing changes?
    
    No.
---
 parquet/Cargo.toml                               |   5 +
 parquet/benches/arrow_reader_predicate_fusion.rs | 431 +++++++++++++++++++++++
 2 files changed, 436 insertions(+)

diff --git a/parquet/Cargo.toml b/parquet/Cargo.toml
index d918be65d8..cbf3c74ec2 100644
--- a/parquet/Cargo.toml
+++ b/parquet/Cargo.toml
@@ -270,6 +270,11 @@ name = "arrow_reader_row_filter"
 required-features = ["arrow", "async"]
 harness = false
 
+[[bench]]
+name = "arrow_reader_predicate_fusion"
+required-features = ["arrow", "async", "snap"]
+harness = false
+
 [[bench]]
 name = "arrow_reader_row_selection_policy"
 required-features = ["arrow", "async"]
diff --git a/parquet/benches/arrow_reader_predicate_fusion.rs 
b/parquet/benches/arrow_reader_predicate_fusion.rs
new file mode 100644
index 0000000000..56d7b568c4
--- /dev/null
+++ b/parquet/benches/arrow_reader_predicate_fusion.rs
@@ -0,0 +1,431 @@
+// 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.
+
+//! Benchmark for chains of [`RowFilter`] predicates that share one projection.
+//!
+//! Consecutive same-projection predicates decode, or replay from the predicate
+//! cache, the same column once per predicate, unless the reader fuses them.
+//!
+//! Case names are `type/layout/cache/predicates/profile`: the filter column
+//! type; `fragmented` (one key per row) or `clustered` (each key repeated for
+//! 128 rows); whether the filter column is also projected and served by the
+//! predicate cache; the chain length, where `1` is a control; and the survivor
+//! rates, where `all<N>` keeps N% per predicate, `early1` / `late1` keep 1% in
+//! the first or last predicate and 99% elsewhere, and `run<N>` keeps half of
+//! the runs of `N` rows to probe the selection representation threshold of 32.
+//!
+//! The 30 cases cover four-predicate `all99` chains for every 
type/layout/cache
+//! combination. Fragmented layouts also cover single-predicate controls and
+//! two-predicate `all99` / `all50` chains. Clustered uncached `all50` chains 
with
+//! two and four predicates retain cases where fusion has shown regressions.
+//! Predicate ordering is covered by int64/fragmented `early1` and `late1`.
+//! Run lengths 16 and 64 cover either side of the selection representation
+//! threshold without a full cross product.
+
+use std::fmt::{Display, Formatter};
+use std::hint::black_box;
+use std::io::Cursor;
+use std::sync::Arc;
+
+use arrow_array::cast::AsArray;
+use arrow_array::types::Int64Type;
+use arrow_array::{Array, ArrayRef, BooleanArray, Int64Array, RecordBatch, 
StringArray};
+use arrow_buffer::BooleanBuffer;
+use arrow_schema::{DataType, Field, Schema};
+use bytes::Bytes;
+use criterion::measurement::WallTime;
+use criterion::{
+    BenchmarkGroup, BenchmarkId, Criterion, SamplingMode, Throughput, 
criterion_group,
+    criterion_main,
+};
+use futures::TryStreamExt;
+use parquet::arrow::arrow_reader::metrics::ArrowReaderMetrics;
+use parquet::arrow::arrow_reader::{
+    ArrowPredicate, ArrowPredicateFn, ArrowReaderMetadata, ArrowReaderOptions, 
RowFilter,
+};
+use parquet::arrow::{ArrowWriter, ParquetRecordBatchStreamBuilder, 
ProjectionMask};
+use parquet::basic::Compression;
+use parquet::file::properties::WriterProperties;
+
+const NUM_ROWS: usize = 262_144;
+const BATCH_SIZE: usize = 8192;
+const CLUSTER_SIZE: i64 = 128;
+/// Leaf index of the payload column projected by `uncached` cases.
+const PAYLOAD_COLUMN: usize = 4;
+const LAYOUTS: [&str; 4] = [
+    "int64/fragmented",
+    "int64/clustered",
+    "string/fragmented",
+    "string/clustered",
+];
+
+#[derive(Clone, Copy)]
+enum Profile {
+    Uniform(u64),
+    EarlySelective,
+    LateSelective,
+    RunLength(usize),
+}
+
+impl Profile {
+    fn value(self, key: u64, index: usize) -> u64 {
+        match self {
+            Self::RunLength(length) => (((key / length as u64) >> index) & 1) 
* 99,
+            _ => bucket(key, index),
+        }
+    }
+}
+
+impl Display for Profile {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::Uniform(percent) => write!(f, "all{percent}"),
+            Self::EarlySelective => write!(f, "early1"),
+            Self::LateSelective => write!(f, "late1"),
+            Self::RunLength(length) => write!(f, "run{length}"),
+        }
+    }
+}
+
+/// Pseudo-random bucket in `0..100`, independent per (key, predicate).
+fn bucket(key: u64, predicate: usize) -> u64 {
+    let mut value = key.wrapping_add((predicate as u64 + 
1).wrapping_mul(0x9e3779b97f4a7c15));
+    value = (value ^ (value >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
+    value = (value ^ (value >> 27)).wrapping_mul(0x94d049bb133111eb);
+    (value ^ (value >> 31)) % 100
+}
+
+fn string_value(key: i64, profile: Profile) -> String {
+    let mut value = String::with_capacity(64);
+    for predicate in 0..32 {
+        let digit = profile.value(key as u64, predicate) as u8;
+        value.push((b'0' + digit / 10) as char);
+        value.push((b'0' + digit % 10) as char);
+    }
+    value
+}
+
+/// An in-memory Parquet file with its metadata parsed once.
+struct Dataset {
+    data: Bytes,
+    metadata: ArrowReaderMetadata,
+}
+
+impl Dataset {
+    fn generate(runtime: &tokio::runtime::Runtime, profile: Profile) -> Self {
+        let schema = Arc::new(Schema::new(vec![
+            Field::new("int_fragmented", DataType::Int64, false),
+            Field::new("int_clustered", DataType::Int64, false),
+            Field::new("string_fragmented", DataType::Utf8, false),
+            Field::new("string_clustered", DataType::Utf8, false),
+            Field::new("payload", DataType::Int64, false),
+        ]));
+        let properties = WriterProperties::builder()
+            .set_compression(Compression::SNAPPY)
+            .set_dictionary_enabled(true)
+            .set_max_row_group_row_count(Some(NUM_ROWS / 2))
+            .build();
+        let mut buffer = Vec::new();
+        let mut writer =
+            ArrowWriter::try_new(&mut buffer, schema.clone(), 
Some(properties)).unwrap();
+        for start in (0..NUM_ROWS).step_by(BATCH_SIZE) {
+            let keys: Vec<i64> = (start..start + BATCH_SIZE).map(|row| row as 
i64).collect();
+            let clustered: Vec<i64> = keys.iter().map(|key| key / 
CLUSTER_SIZE).collect();
+            let columns: Vec<ArrayRef> = vec![
+                Arc::new(Int64Array::from(keys.clone())),
+                Arc::new(Int64Array::from(clustered.clone())),
+                Arc::new(StringArray::from_iter_values(
+                    keys.iter().map(|key| string_value(*key, profile)),
+                )),
+                Arc::new(StringArray::from_iter_values(
+                    clustered.iter().map(|key| string_value(*key, profile)),
+                )),
+                Arc::new(Int64Array::from(keys)),
+            ];
+            writer
+                .write(&RecordBatch::try_new(schema.clone(), columns).unwrap())
+                .unwrap();
+        }
+        writer.close().unwrap();
+
+        let data = Bytes::from(buffer);
+        let metadata = runtime
+            .block_on(ArrowReaderMetadata::load_async(
+                &mut Cursor::new(data.clone()),
+                ArrowReaderOptions::new(),
+            ))
+            .unwrap();
+        Self { data, metadata }
+    }
+
+    fn builder(&self) -> ParquetRecordBatchStreamBuilder<Cursor<Bytes>> {
+        ParquetRecordBatchStreamBuilder::new_with_metadata(
+            Cursor::new(self.data.clone()),
+            self.metadata.clone(),
+        )
+    }
+}
+
+struct Case {
+    column: usize,
+    project_filter: bool,
+    predicates: usize,
+    profile: Profile,
+}
+
+impl Case {
+    fn is_string(&self) -> bool {
+        self.column >= 2
+    }
+
+    fn is_clustered(&self) -> bool {
+        self.column % 2 == 1
+    }
+
+    fn threshold(&self, index: usize) -> u64 {
+        match self.profile {
+            Profile::Uniform(percent) => percent,
+            Profile::RunLength(_) => 50,
+            Profile::EarlySelective => {
+                if index == 0 {
+                    1
+                } else {
+                    99
+                }
+            }
+            Profile::LateSelective => {
+                if index + 1 == self.predicates {
+                    1
+                } else {
+                    99
+                }
+            }
+        }
+    }
+
+    fn expected_rows(&self) -> usize {
+        let run_length = if self.is_clustered() {
+            CLUSTER_SIZE as usize
+        } else {
+            1
+        };
+        (0..NUM_ROWS / run_length)
+            .filter(|key| {
+                (0..self.predicates)
+                    .all(|index| self.profile.value(*key as u64, index) < 
self.threshold(index))
+            })
+            .count()
+            * run_length
+    }
+
+    fn filter(&self, projection: ProjectionMask, validate: bool) -> RowFilter {
+        let predicates = (0..self.predicates)
+            .map(|index| {
+                let threshold = self.threshold(index);
+                let is_string = self.is_string();
+                let profile = self.profile;
+                Box::new(ArrowPredicateFn::new(projection.clone(), move 
|batch| {
+                    let filter = if is_string {
+                        let array = batch.column(0).as_string::<i32>();
+                        BooleanBuffer::collect_bool(array.len(), |row| {
+                            let bytes = array.value(row).as_bytes();
+                            let value = (bytes[index * 2] - b'0') as u64 * 10
+                                + (bytes[index * 2 + 1] - b'0') as u64;
+                            value < threshold
+                        })
+                    } else {
+                        let values = 
batch.column(0).as_primitive::<Int64Type>().values();
+                        BooleanBuffer::collect_bool(values.len(), |row| {
+                            profile.value(values[row] as u64, index) < 
threshold
+                        })
+                    };
+                    if let (true, Profile::RunLength(length)) = (validate, 
profile) {
+                        assert_run_lengths(&filter, length);
+                    }
+                    Ok(BooleanArray::new(filter, None))
+                })) as Box<dyn ArrowPredicate>
+            })
+            .collect();
+        RowFilter::new(predicates)
+    }
+}
+
+/// Interior runs must have the target length; batch-edge runs may be partial.
+fn assert_run_lengths(mask: &BooleanBuffer, length: usize) {
+    let mut iter = mask.iter();
+    let Some(mut value) = iter.next() else {
+        return;
+    };
+    let mut run_length = 1;
+    let mut first_run = true;
+    for next in iter {
+        if next != value {
+            if first_run {
+                assert!(run_length <= length);
+                first_run = false;
+            } else {
+                assert_eq!(run_length, length);
+            }
+            value = next;
+            run_length = 0;
+        }
+        run_length += 1;
+    }
+    assert!(run_length <= length);
+}
+
+async fn scan(
+    dataset: &Dataset,
+    case: &Case,
+    metrics: ArrowReaderMetrics,
+    validate: bool,
+) -> usize {
+    let builder = dataset.builder();
+    let filter = case.filter(
+        ProjectionMask::leaves(builder.parquet_schema(), [case.column]),
+        validate,
+    );
+    let output = if case.project_filter {
+        case.column
+    } else {
+        PAYLOAD_COLUMN
+    };
+    let projection = ProjectionMask::leaves(builder.parquet_schema(), 
[output]);
+    let mut reader = builder
+        .with_batch_size(BATCH_SIZE)
+        .with_metrics(metrics)
+        .with_projection(projection)
+        .with_row_filter(filter)
+        .build()
+        .unwrap();
+    let mut rows = 0;
+    while let Some(batch) = reader.try_next().await.unwrap() {
+        rows += batch.num_rows();
+        black_box(batch);
+    }
+    rows
+}
+
+/// Check the row count and predicate cache use once, outside measurement.
+fn validate_case(runtime: &tokio::runtime::Runtime, dataset: &Dataset, case: 
&Case, id: &str) {
+    let metrics = ArrowReaderMetrics::enabled();
+    let rows = runtime.block_on(scan(dataset, case, metrics.clone(), true));
+    assert_eq!(rows, case.expected_rows(), "{id}: unexpected row count");
+    let from_cache = metrics.records_read_from_cache().unwrap();
+    if case.project_filter {
+        assert!(from_cache > 0, "{id}: predicate cache unused");
+    } else {
+        assert_eq!(from_cache, 0, "{id}: predicate cache used");
+    }
+}
+
+fn register_case(
+    group: &mut BenchmarkGroup<'_, WallTime>,
+    runtime: &tokio::runtime::Runtime,
+    dataset: &Dataset,
+    function: String,
+    parameter: String,
+    case: Case,
+) {
+    validate_case(runtime, dataset, &case, &format!("{function}/{parameter}"));
+    let id = BenchmarkId::new(function, parameter);
+    group.bench_with_input(id, &case, |b, case| {
+        b.iter(|| {
+            black_box(runtime.block_on(scan(dataset, case, 
ArrowReaderMetrics::disabled(), false)))
+        });
+    });
+}
+
+fn configure(group: &mut BenchmarkGroup<'_, WallTime>) {
+    group.throughput(Throughput::Elements(NUM_ROWS as u64));
+    group.sampling_mode(SamplingMode::Flat);
+}
+
+fn benchmark(c: &mut Criterion) {
+    let runtime = tokio::runtime::Builder::new_current_thread()
+        .enable_all()
+        .build()
+        .unwrap();
+
+    // Only the run-length profiles change the string columns.
+    let dataset = Dataset::generate(&runtime, Profile::Uniform(99));
+    let mut group = c.benchmark_group("same_projection_filter");
+    configure(&mut group);
+    for (column, name) in LAYOUTS.iter().enumerate() {
+        for project_filter in [false, true] {
+            let cache = if project_filter { "cached" } else { "uncached" };
+            let mut cases = vec![(4, Profile::Uniform(99))];
+            if column % 2 == 0 {
+                // Controls, the smallest fusible chain, and compaction costs.
+                cases.extend([
+                    (1, Profile::Uniform(99)),
+                    (2, Profile::Uniform(99)),
+                    (2, Profile::Uniform(50)),
+                ]);
+            } else if !project_filter {
+                // Retain known regressions where compaction outweighs saved 
decoding.
+                cases.extend([(2, Profile::Uniform(50)), (4, 
Profile::Uniform(50))]);
+            }
+            // One type/layout is sufficient to isolate predicate ordering.
+            if column == 0 {
+                cases.extend([(4, Profile::EarlySelective), (4, 
Profile::LateSelective)]);
+            }
+            for (predicates, profile) in cases {
+                let case = Case {
+                    column,
+                    project_filter,
+                    predicates,
+                    profile,
+                };
+                register_case(
+                    &mut group,
+                    &runtime,
+                    &dataset,
+                    format!("{name}/{cache}"),
+                    format!("{predicates}/{profile}"),
+                    case,
+                );
+            }
+        }
+    }
+    group.finish();
+
+    let mut group = 
c.benchmark_group("same_projection_filter/selection_boundary");
+    configure(&mut group);
+    for length in [16, 64] {
+        let profile = Profile::RunLength(length);
+        let dataset = Dataset::generate(&runtime, profile);
+        let case = Case {
+            column: 0,
+            project_filter: false,
+            predicates: 4,
+            profile,
+        };
+        register_case(
+            &mut group,
+            &runtime,
+            &dataset,
+            "int64/uncached".to_string(),
+            format!("4/{profile}"),
+            case,
+        );
+    }
+    group.finish();
+}
+
+criterion_group!(benches, benchmark);
+criterion_main!(benches);

Reply via email to