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/iceberg-rust.git
The following commit(s) were added to refs/heads/main by this push:
new acd610997 perf(reader): speed up row group filtering and name mapping
(#3269)
acd610997 is described below
commit acd610997de78f986ece9f89c3bf476de5e1f5a5
Author: Minh Vu <[email protected]>
AuthorDate: Sun Oct 11 18:29:22 2026 +0000
perf(reader): speed up row group filtering and name mapping (#3269)
* perf(reader): optimize row group filter intersection
* perf(reader): add row group intersection benchmark
* style: format iceberg benchmark manifest
* perf(reader): avoid allocations in name mapping lookup
* perf(reader): prune row groups from split candidates
* fix(reader): format tests and preallocate name mapping index
* fix(reader): tighten candidate pruning and name mapping coverage
---
crates/iceberg/src/arrow/reader/pipeline.rs | 53 ++++++++-------
crates/iceberg/src/arrow/reader/projection.rs | 49 ++++++++++++--
crates/iceberg/src/arrow/reader/row_filter.rs | 96 ++++++++++++++++++++++++++-
3 files changed, 161 insertions(+), 37 deletions(-)
diff --git a/crates/iceberg/src/arrow/reader/pipeline.rs
b/crates/iceberg/src/arrow/reader/pipeline.rs
index 18ba42d3b..84b4467d3 100644
--- a/crates/iceberg/src/arrow/reader/pipeline.rs
+++ b/crates/iceberg/src/arrow/reader/pipeline.rs
@@ -20,6 +20,7 @@
//! predicates, row-group / row selection, and delete handling into a stream
//! of transformed Arrow `RecordBatch`es.
+use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
@@ -133,6 +134,17 @@ struct FileScanTaskReader {
scan_metrics: ScanMetrics,
}
+// Borrow an existing selection, or enumerate all groups for a whole-file scan.
+fn candidate_row_groups(
+ selected_row_groups: Option<&[usize]>,
+ num_row_groups: usize,
+) -> Cow<'_, [usize]> {
+ match selected_row_groups {
+ Some(indices) => Cow::Borrowed(indices),
+ None => Cow::Owned((0..num_row_groups).collect()),
+ }
+}
+
impl FileScanTaskReader {
async fn process(self, task: FileScanTask) ->
Result<ArrowRecordBatchStream> {
let should_load_page_index = (self.row_selection_enabled &&
task.predicate().is_some())
@@ -525,48 +537,35 @@ impl FileScanTaskReader {
record_batch_stream_builder =
record_batch_stream_builder.with_row_filter(row_filter);
if self.row_group_filtering_enabled {
- let predicate_filtered_row_groups =
ArrowReader::get_selected_row_group_indices(
+ let candidates = candidate_row_groups(
+ selected_row_group_indices.as_deref(),
+ record_batch_stream_builder.metadata().num_row_groups(),
+ );
+
+ selected_row_group_indices =
Some(ArrowReader::get_selected_row_group_indices(
&predicate,
record_batch_stream_builder.metadata(),
&field_id_map,
task.schema(),
- )?;
-
- // Merge predicate-based filtering with byte range filtering
(if present)
- // by taking the intersection of both filters
- selected_row_group_indices = match selected_row_group_indices {
- Some(byte_range_filtered) => {
- // Keep only row groups that are in both filters
- let intersection: Vec<usize> = byte_range_filtered
- .into_iter()
- .filter(|idx|
predicate_filtered_row_groups.contains(idx))
- .collect();
- Some(intersection)
- }
- None => Some(predicate_filtered_row_groups),
- };
+ candidates.as_ref(),
+ )?);
}
if self.bloom_filter_enabled {
- let all_rgs;
- let candidate_rgs = match &selected_row_group_indices {
- Some(indices) => indices.as_slice(),
- None => {
- all_rgs =
(0..record_batch_stream_builder.metadata().num_row_groups())
- .collect::<Vec<_>>();
- &all_rgs
- }
- };
+ let candidates = candidate_row_groups(
+ selected_row_group_indices.as_deref(),
+ record_batch_stream_builder.metadata().num_row_groups(),
+ );
let bloom_filtered = Self::filter_row_groups_by_bloom_filter(
&predicate,
&mut record_batch_stream_builder,
- candidate_rgs,
+ candidates.as_ref(),
&field_id_map,
)
.await?;
- if bloom_filtered.len() < candidate_rgs.len() {
+ if bloom_filtered.len() < candidates.len() {
selected_row_group_indices = Some(bloom_filtered);
}
}
diff --git a/crates/iceberg/src/arrow/reader/projection.rs
b/crates/iceberg/src/arrow/reader/projection.rs
index 8f2afe79e..2fa6bf18f 100644
--- a/crates/iceberg/src/arrow/reader/projection.rs
+++ b/crates/iceberg/src/arrow/reader/projection.rs
@@ -421,6 +421,16 @@ pub(super) fn apply_name_mapping_to_arrow_schema(
"Schema already has field IDs - name mapping should not be applied"
);
+ let mut field_ids_by_name =
HashMap::with_capacity(name_mapping.fields().len());
+ for mapped_field in name_mapping.fields() {
+ for name in mapped_field.names() {
+ // Preserve the existing first-match behavior for duplicate
aliases.
+ field_ids_by_name
+ .entry(name.as_str())
+ .or_insert(mapped_field.field_id());
+ }
+ }
+
let fields_with_mapped_ids: Vec<_> = arrow_schema
.fields()
.iter()
@@ -432,15 +442,12 @@ pub(super) fn apply_name_mapping_to_arrow_schema(
// If the field isn't in the mapping, leave it WITHOUT assigning
an ID
// (matching Java's behavior of returning the field unchanged).
// Later, during projection, fields without IDs are filtered out.
- let mapped_field_opt = name_mapping
- .fields()
- .iter()
- .find(|f| f.names().contains(&field.name().to_string()));
-
let mut metadata = field.metadata().clone();
- if let Some(mapped_field) = mapped_field_opt
- && let Some(field_id) = mapped_field.field_id()
+ if let Some(field_id) = field_ids_by_name
+ .get(field.name().as_str())
+ .copied()
+ .flatten()
{
// Field found in mapping with a field_id → assign it
metadata.insert(PARQUET_FIELD_ID_META_KEY.to_string(),
field_id.to_string());
@@ -523,6 +530,34 @@ mod tests {
};
use crate::{ErrorKind, Runtime};
+ #[test]
+ fn test_name_mapping_duplicate_alias_preserves_first_match() {
+ let arrow_schema = Arc::new(ArrowSchema::new(vec![Field::new(
+ "name",
+ DataType::Utf8,
+ true,
+ )]));
+ for (first_id, expected) in [(Some(2), Some("2")), (None, None)] {
+ let name_mapping = NameMapping::new(vec![
+ MappedField::new(first_id, vec!["name".to_string()], vec![]),
+ MappedField::new(Some(4), vec!["name".to_string()], vec![]),
+ ]);
+
+ let mapped =
+
super::apply_name_mapping_to_arrow_schema(arrow_schema.clone(), &name_mapping)
+ .unwrap();
+
+ assert_eq!(
+ mapped.fields()[0]
+ .metadata()
+ .get(PARQUET_FIELD_ID_META_KEY)
+ .map(String::as_str),
+ expected,
+ "first matching field ID: {first_id:?}"
+ );
+ }
+ }
+
#[test]
fn test_arrow_projection_mask() {
let schema = Arc::new(
diff --git a/crates/iceberg/src/arrow/reader/row_filter.rs
b/crates/iceberg/src/arrow/reader/row_filter.rs
index 5d92f6bc7..4a99914a3 100644
--- a/crates/iceberg/src/arrow/reader/row_filter.rs
+++ b/crates/iceberg/src/arrow/reader/row_filter.rs
@@ -35,6 +35,7 @@ use crate::expr::visitors::bound_predicate_visitor::visit;
use crate::expr::visitors::page_index_evaluator::PageIndexEvaluator;
use
crate::expr::visitors::row_group_metrics_evaluator::RowGroupMetricsEvaluator;
use crate::spec::Schema;
+use crate::{Error, ErrorKind};
impl ArrowReader {
pub(super) fn get_row_filter(
@@ -71,11 +72,21 @@ impl ArrowReader {
parquet_metadata: &Arc<ParquetMetaData>,
field_id_map: &HashMap<i32, usize>,
snapshot_schema: &Schema,
+ candidate_row_groups: &[usize],
) -> Result<Vec<usize>> {
let row_groups_metadata = parquet_metadata.row_groups();
- let mut results = Vec::with_capacity(row_groups_metadata.len());
-
- for (idx, row_group_metadata) in
row_groups_metadata.iter().enumerate() {
+ let mut results = Vec::with_capacity(candidate_row_groups.len());
+
+ for &idx in candidate_row_groups {
+ let row_group_metadata =
row_groups_metadata.get(idx).ok_or_else(|| {
+ Error::new(
+ ErrorKind::DataInvalid,
+ format!(
+ "row group index {idx} out of range ({} row groups)",
+ row_groups_metadata.len()
+ ),
+ )
+ })?;
if RowGroupMetricsEvaluator::eval(
predicate,
row_group_metadata,
@@ -634,6 +645,67 @@ mod tests {
assert_eq!(first_val, 100, "Task 2 should start with id=100, not
id=0");
}
+
+ // Combining byte-range and predicate row-group filtering should
narrow the
+ // split's candidates instead of evaluating and intersecting
independent lists.
+ // This task owns row groups 1 and 2, while the predicate can match
row groups
+ // 0 and 2. Only row group 2 should be read.
+ let predicate = Reference::new("id")
+ .less_than(Datum::int(100))
+ .or(Reference::new("id").greater_than_or_equal_to(Datum::int(200)))
+ .bind(schema.clone(), true)
+ .unwrap();
+
+ // Test the pruning decision directly, not just the final filtered
rows.
+ let parquet_metadata = Arc::new((*metadata).clone());
+ let field_id_map = HashMap::from([(1, 0)]);
+ for (candidates, expected) in [(vec![1, 2], vec![2]), (vec![0, 1],
vec![0])] {
+ let selected = ArrowReader::get_selected_row_group_indices(
+ &predicate,
+ &parquet_metadata,
+ &field_id_map,
+ schema.as_ref(),
+ &candidates,
+ )
+ .unwrap();
+ assert_eq!(selected, expected);
+ }
+
+ let task = FileScanTask::builder()
+
.with_file_size_in_bytes(std::fs::metadata(&file_path).unwrap().len())
+ .with_start(rg1_start)
+ .with_length(file_end - rg1_start)
+ .with_data_file_path(file_path.clone())
+ .with_data_file_format(DataFileFormat::Parquet)
+ .with_schema(schema.clone())
+ .with_project_field_ids(vec![1])
+ .with_predicate(Some(predicate))
+ .with_record_count(Some(200))
+ .with_case_sensitive(false)
+ .build()
+ .unwrap();
+
+ let reader = ArrowReaderBuilder::new(FileIO::new_with_fs(),
Runtime::current())
+ .with_row_group_filtering_enabled(true)
+ .build();
+ let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as
FileScanTaskStream;
+ let result = reader
+ .read(tasks)
+ .unwrap()
+ .stream()
+ .try_collect::<Vec<RecordBatch>>()
+ .await
+ .unwrap();
+
+ let mut ids = Vec::new();
+ for batch in &result {
+ let id_col = batch
+ .column(0)
+ .as_primitive::<arrow_array::types::Int32Type>();
+ ids.extend((0..id_col.len()).map(|index| id_col.value(index)));
+ }
+
+ assert_eq!(ids, (200..300).collect::<Vec<_>>());
}
/// A single data file split into multiple sub-row-group byte ranges (as
Spark/Iceberg
@@ -903,6 +975,24 @@ mod tests {
)
}
+ #[test]
+ fn test_row_group_pruning_rejects_invalid_candidate() {
+ let schema = int_schema();
+ let predicate = simple_predicate(schema.clone());
+ let metadata = metadata_no_page_indexes();
+
+ let err = ArrowReader::get_selected_row_group_indices(
+ &predicate,
+ &metadata,
+ &field_id_map(),
+ schema.as_ref(),
+ &[0],
+ )
+ .unwrap_err();
+
+ assert_eq!(err.kind(), crate::ErrorKind::DataInvalid);
+ }
+
/// Testing suite regarding:
https://github.com/apache/iceberg-rust/issues/2452
/// Testing when: both indices are absent, some present, both present
#[test]