From 888d8befbcd3b82cd6c930fb19e62eed7c2d63b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 15:12:58 +0000 Subject: [PATCH] Prune scan conjuncts concurrently and extract length-only scan Evaluate every conjunct's pruning mask concurrently via FuturesUnordered, intersecting masks as they resolve and short-circuiting once no rows remain. Also extract the empty-projection length-only scan into a helper. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T4rTgJEA7EtawfSBNxN5Yk Signed-off-by: Claude --- vortex-layout/src/scan/layout.rs | 49 +++++++++++++++++++++----------- vortex-layout/src/scan/tasks.rs | 33 +++++++++++++-------- 2 files changed, 54 insertions(+), 28 deletions(-) diff --git a/vortex-layout/src/scan/layout.rs b/vortex-layout/src/scan/layout.rs index ae670958ff4..65e9e3c47bd 100644 --- a/vortex-layout/src/scan/layout.rs +++ b/vortex-layout/src/scan/layout.rs @@ -126,23 +126,15 @@ impl DataSource for LayoutReaderDataSource { .transpose()?; let dtype = projection.dtype().clone(); - // If the dtype is an empty struct, and there is no filter, we can return a special - // length-only scan. - if let DType::Struct(fields, Nullability::NonNullable) = &dtype - && fields.nfields() == 0 - && filter.is_none() + if filter.is_none() + && let Some(empty) = length_only_scan( + &dtype, + &scan_request.selection, + &row_range, + scan_request.limit, + ) { - // FIXME(ngates): extract out maybe? - let row_count = scan_request.selection.row_count_in_range(&row_range); - - // Apply the limit. - let row_count = if let Some(limit) = scan_request.limit { - row_count.min(limit) - } else { - row_count - }; - - return Ok(Box::new(Empty { dtype, row_count })); + return Ok(Box::new(empty)); } // Check file-level pruning: if the filter can be proven false for the entire row range @@ -364,6 +356,31 @@ impl Partition for LayoutReaderSplit { } } +/// Returns a length-only scan when the projection is an empty struct, since its output then +/// depends only on how many rows are selected. +/// +/// Callers must only use this for unfiltered scans, where the selected row count is exact. +fn length_only_scan( + dtype: &DType, + selection: &Selection, + row_range: &Range, + limit: Option, +) -> Option { + let DType::Struct(fields, Nullability::NonNullable) = dtype else { + return None; + }; + if fields.nfields() != 0 { + return None; + } + + let row_count = selection.row_count_in_range(row_range); + let row_count = limit.map_or(row_count, |limit| row_count.min(limit)); + Some(Empty { + dtype: dtype.clone(), + row_count, + }) +} + /// A scan that produces no data, only empty arrays with the correct row count. struct Empty { dtype: DType, diff --git a/vortex-layout/src/scan/tasks.rs b/vortex-layout/src/scan/tasks.rs index d10c1c656ec..8ee0460aa46 100644 --- a/vortex-layout/src/scan/tasks.rs +++ b/vortex-layout/src/scan/tasks.rs @@ -9,7 +9,9 @@ use std::sync::Arc; use bit_vec::BitVec; use futures::FutureExt; +use futures::TryStreamExt; use futures::future::BoxFuture; +use futures::stream::FuturesUnordered; use vortex_array::ArrayRef; use vortex_array::MaskFuture; use vortex_array::expr::BoundExpression; @@ -90,22 +92,29 @@ fn build_filter_mask( let filter_row_range = row_range.clone(); MaskFuture::new(row_mask.len(), async move { let mut mask = row_mask; - let mut dynamic_versions = vec![None; filter.conjuncts().len()]; + if mask.all_false() { + return Ok(mask); + } - // TODO(ngates): we could use FuturedUnordered to intersect the masks in parallel. - for (idx, conjunct) in filter.conjuncts().iter().enumerate() { + // Store the latest version of each dynamic expression prior to pruning. + // We will re-run the pruning later if the version has changed in the meantime. + let mut dynamic_versions: Vec<_> = (0..filter.conjuncts().len()) + .map(|idx| filter.dynamic_updates(idx).map(|du| du.version())) + .collect(); + + // Prune with every conjunct concurrently, intersecting the masks as they resolve. Each + // pruning sees only the input mask, and the remaining evaluations are dropped once the + // intersection rules out every row. + let mut pruning: FuturesUnordered<_> = filter + .conjuncts() + .iter() + .map(|conjunct| reader.pruning_evaluation(&filter_row_range, conjunct, mask.clone())) + .collect::>()?; + while let Some(conjunct_mask) = pruning.try_next().await? { + mask = mask.bitand(&conjunct_mask); if mask.all_false() { return Ok(mask); } - - // Store the latest version of the dynamic expression prior to pruning. - // We will re-run the pruning later if the version has changed in the meantime. - dynamic_versions[idx] = filter.dynamic_updates(idx).map(|du| du.version()); - - let conjunct_mask = reader - .pruning_evaluation(&filter_row_range, conjunct, mask.clone())? - .await?; - mask = mask.bitand(&conjunct_mask); } // Now we loop through the conjuncts in the preferred order and evaluate them.