From 5ac6e82dd240326999a46d55626e9b1041923329 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 21:51:24 +0000 Subject: [PATCH 1/4] Run slice kernels when a flat reader slices a chunk Splits of 65,536 rows over 131,072-row run-end chunks wrapped each chunk in a lazy slice. The rule that evaluates scalar functions on run-end values only matches a bare run-end array, and optimizing cannot remove the slice because run-end slicing is an execute-time kernel that reads the run ends. So every filter and the export decoded the whole column, four times per chunk for cs_order_number in TPC-DS q16. Add ArrayRef::execute_slice, which runs the slice kernel of the sliced encoding and optimizes the result, and use it in the flat reader. Signed-off-by: Robert Kruszewski Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011JxDQs8L3TZ6VhZV9Bt6cr (cherry picked from commit 5b0caae76008be74e86a9387f5056ab2e3dcf922) --- vortex-array/src/executor.rs | 52 ++++++++++++++++++++++++ vortex-layout/src/layouts/flat/reader.rs | 9 ++-- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/vortex-array/src/executor.rs b/vortex-array/src/executor.rs index b9e22a41e62..1896cb9ed6f 100644 --- a/vortex-array/src/executor.rs +++ b/vortex-array/src/executor.rs @@ -80,6 +80,29 @@ pub trait Executable: Sized { #[expect(clippy::same_name_method)] impl ArrayRef { + /// Slice the array, running the slice kernel of its encoding when slicing it needs to read + /// buffers. + /// + /// [`Self::slice`] leaves such encodings, run-end for example, under a lazy slice, which + /// hides them from the rewrite rules that would evaluate expressions on their compressed + /// form. The kernels slice structurally instead of decoding, so this costs about a search + /// of the array's metadata. + pub fn execute_slice( + &self, + range: std::ops::Range, + ctx: &mut ExecutionCtx, + ) -> VortexResult { + let sliced = self.slice(range)?; + if !sliced.is::() { + return Ok(sliced); + } + let kernels = Arc::clone(&ctx.execute_parent_kernels); + match try_execute_parent(&sliced, &kernels, ctx)? { + Some(executed) => executed.optimize_ctx(ctx.session()), + None => Ok(sliced), + } + } + /// Execute this array to produce an instance of `E`. /// /// See the [`Executable`] implementation for details on how this execution is performed. @@ -993,6 +1016,35 @@ mod tests { assert_impl_all!(ExecutionCtx: Send, Sync); + #[test] + fn execute_slice_runs_slice_kernels() -> VortexResult<()> { + use vortex_buffer::buffer; + + use crate::arrays::Chunked; + use crate::arrays::ChunkedArray; + use crate::arrays::PrimitiveArray; + use crate::arrays::Slice; + use crate::assert_arrays_eq; + use crate::dtype::Nullability; + use crate::dtype::PType; + + let chunked = ChunkedArray::try_new( + vec![ + buffer![1i32, 2, 3].into_array(), + buffer![4i32, 5, 6].into_array(), + ], + DType::Primitive(PType::I32, Nullability::NonNullable), + )? + .into_array(); + let mut ctx = crate::array_session().create_execution_ctx(); + + assert!(chunked.slice(1..5)?.is::()); + let sliced = chunked.execute_slice(1..5, &mut ctx)?; + assert!(sliced.is::()); + assert_arrays_eq!(sliced, PrimitiveArray::from_iter([2i32, 3, 4, 5]), &mut ctx); + Ok(()) + } + fn noop_execute_parent( _child: &ArrayRef, _parent: &ArrayRef, diff --git a/vortex-layout/src/layouts/flat/reader.rs b/vortex-layout/src/layouts/flat/reader.rs index 38a9bf0218d..9bd144dd658 100644 --- a/vortex-layout/src/layouts/flat/reader.rs +++ b/vortex-layout/src/layouts/flat/reader.rs @@ -145,10 +145,11 @@ impl LayoutReader for FlatReader { // to evaluating the expression. let mut array = array.clone().await?; let mask = mask.await?; + let mut ctx = session.create_execution_ctx(); // Slice the array based on the row mask. if row_range.start > 0 || row_range.end < array.len() { - array = array.slice(row_range.clone())?; + array = array.execute_slice(row_range.clone(), &mut ctx)?; } let mask_density = mask.density(); @@ -158,14 +159,12 @@ impl LayoutReader for FlatReader { // after this. let array = array.apply_bound(&expr)?; let array = array.filter(mask.clone())?; - let mut ctx = session.create_execution_ctx(); let array_mask = array.fill_null(false)?.execute::(&mut ctx)?; mask.intersect_by_rank(&array_mask) } else { // Run over the full array, with a simpler bitand at the end. let array = array.apply_bound(&expr)?; - let mut ctx = session.create_execution_ctx(); let array_mask = array.fill_null(false)?.execute::(&mut ctx)?; mask.bitand(&array_mask) @@ -196,6 +195,7 @@ impl LayoutReader for FlatReader { let name = Arc::clone(&self.name); let array = self.array_future(); let expr = expr.clone(); + let session = self.session.clone(); Ok(async move { trace!("Flat array evaluation {} - {}", name, expr); @@ -210,7 +210,8 @@ impl LayoutReader for FlatReader { // Slice the array based on the row mask. if row_range.start > 0 || row_range.end < array.len() { - array = array.slice(row_range.clone())?; + array = + array.execute_slice(row_range.clone(), &mut session.create_execution_ctx())?; } // First apply the filter to the array. From 1ce87a7e614cbdd51bb668bca6211addf51063ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 23:55:00 +0000 Subject: [PATCH 2/4] Export filtered run-end columns without decoding them A scan filters each projected column lazily, so the exporter received a filter over a run-end array and decoded it through the canonical path, although the run-end filter kernel keeps the runs and the exporter can export runs as they are. Run the kernels of lazy filters and slices before choosing an exporter. In TPC-DS q16 this exported cs_order_number as runs, saving 8% of the query's CPU. Signed-off-by: Robert Kruszewski Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011JxDQs8L3TZ6VhZV9Bt6cr (cherry picked from commit 2a0defd2ab160e72f73f3766f76b64c7313717d8) --- vortex-duckdb/src/exporter/mod.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/vortex-duckdb/src/exporter/mod.rs b/vortex-duckdb/src/exporter/mod.rs index 539a6d994a2..24d39a3b8e2 100644 --- a/vortex-duckdb/src/exporter/mod.rs +++ b/vortex-duckdb/src/exporter/mod.rs @@ -32,7 +32,9 @@ use vortex::array::ExecutionCtx; use vortex::array::arrays::Chunked; use vortex::array::arrays::Constant; use vortex::array::arrays::Dict; +use vortex::array::arrays::Filter; use vortex::array::arrays::List; +use vortex::array::arrays::Slice; use vortex::array::arrays::StructArray; use vortex::array::arrays::struct_::StructArrayExt; use vortex::buffer::BitChunks; @@ -220,6 +222,20 @@ fn cached_values_dict( Ok(dict) } +/// Run the kernels of lazy filters and slices, which a scan leaves over its columns, so that the +/// encodings below them, run-end for example, can be exported as they are instead of decoded. +fn resolve_lazy_wrappers(mut array: ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult { + while array.is::() || array.is::() { + let stepped = array.execute::(ctx)?; + // Without a kernel for the child a step only makes progress below the wrapper. + if stepped.is::() || stepped.is::() { + return Ok(stepped); + } + array = stepped; + } + Ok(array) +} + /// Create a DuckDB exporter for the given Vortex array. fn new_array_exporter_with_flatten( array: ArrayRef, @@ -227,6 +243,8 @@ fn new_array_exporter_with_flatten( ctx: &mut ExecutionCtx, flatten: bool, ) -> VortexResult> { + let array = resolve_lazy_wrappers(array, ctx)?; + let array = match array.try_downcast::() { Ok(array) => return constant::new_exporter_with_flatten(array, cache, ctx, flatten), Err(array) => array, From e50b70bba310f97c6a2c8b54992523448c60de81 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 23:56:08 +0000 Subject: [PATCH 3/4] Expect sliced run-length columns to export as dictionaries Exporting resolves lazy slices now, so a slice of a run-length array is exported as runs like the unsliced array, as a dictionary vector. Signed-off-by: Robert Kruszewski Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011JxDQs8L3TZ6VhZV9Bt6cr (cherry picked from commit a5c3155f58552d019ab3f6bafe877804cc085db1) --- vortex-duckdb/src/exporter/rle.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vortex-duckdb/src/exporter/rle.rs b/vortex-duckdb/src/exporter/rle.rs index 483c79f289c..fe3fbf64a59 100644 --- a/vortex-duckdb/src/exporter/rle.rs +++ b/vortex-duckdb/src/exporter/rle.rs @@ -254,7 +254,7 @@ mod tests { assert_eq!( chunk_str, r#"Chunk - [1 Columns] -- FLAT INTEGER: 10 = [ 20, 20, 20, 20, 20, 20, 20, 20, 20, 20] +- DICTIONARY INTEGER: 10 = [ 20, 20, 20, 20, 20, 20, 20, 20, 20, 20] "# ); Ok(()) From d89448de1e0067120290aa6a9aa420a350628234 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 06:00:09 +0000 Subject: [PATCH 4/4] Resolve lazy wrappers with execute_until and fix offset validity export Resolve lazy filters and slices before choosing an exporter with execute_until instead of a hand-written loop, and slice flat reader chunks lazily again, which removes ArrayRef::execute_slice. Copy validity bits from the bitmap's own offset when the bitmap cannot be shared with DuckDB. A bitmap that starts inside its first byte was read from the wrong bit. Signed-off-by: Robert Kruszewski Co-Authored-By: Claude Opus 5.5 --- vortex-array/src/executor.rs | 52 ------------------------ vortex-duckdb/src/exporter/mod.rs | 28 +++++++------ vortex-duckdb/src/exporter/primitive.rs | 28 +++++++++++++ vortex-duckdb/src/exporter/vector.rs | 6 +-- vortex-layout/src/layouts/flat/reader.rs | 6 +-- 5 files changed, 49 insertions(+), 71 deletions(-) diff --git a/vortex-array/src/executor.rs b/vortex-array/src/executor.rs index 1896cb9ed6f..b9e22a41e62 100644 --- a/vortex-array/src/executor.rs +++ b/vortex-array/src/executor.rs @@ -80,29 +80,6 @@ pub trait Executable: Sized { #[expect(clippy::same_name_method)] impl ArrayRef { - /// Slice the array, running the slice kernel of its encoding when slicing it needs to read - /// buffers. - /// - /// [`Self::slice`] leaves such encodings, run-end for example, under a lazy slice, which - /// hides them from the rewrite rules that would evaluate expressions on their compressed - /// form. The kernels slice structurally instead of decoding, so this costs about a search - /// of the array's metadata. - pub fn execute_slice( - &self, - range: std::ops::Range, - ctx: &mut ExecutionCtx, - ) -> VortexResult { - let sliced = self.slice(range)?; - if !sliced.is::() { - return Ok(sliced); - } - let kernels = Arc::clone(&ctx.execute_parent_kernels); - match try_execute_parent(&sliced, &kernels, ctx)? { - Some(executed) => executed.optimize_ctx(ctx.session()), - None => Ok(sliced), - } - } - /// Execute this array to produce an instance of `E`. /// /// See the [`Executable`] implementation for details on how this execution is performed. @@ -1016,35 +993,6 @@ mod tests { assert_impl_all!(ExecutionCtx: Send, Sync); - #[test] - fn execute_slice_runs_slice_kernels() -> VortexResult<()> { - use vortex_buffer::buffer; - - use crate::arrays::Chunked; - use crate::arrays::ChunkedArray; - use crate::arrays::PrimitiveArray; - use crate::arrays::Slice; - use crate::assert_arrays_eq; - use crate::dtype::Nullability; - use crate::dtype::PType; - - let chunked = ChunkedArray::try_new( - vec![ - buffer![1i32, 2, 3].into_array(), - buffer![4i32, 5, 6].into_array(), - ], - DType::Primitive(PType::I32, Nullability::NonNullable), - )? - .into_array(); - let mut ctx = crate::array_session().create_execution_ctx(); - - assert!(chunked.slice(1..5)?.is::()); - let sliced = chunked.execute_slice(1..5, &mut ctx)?; - assert!(sliced.is::()); - assert_arrays_eq!(sliced, PrimitiveArray::from_iter([2i32, 3, 4, 5]), &mut ctx); - Ok(()) - } - fn noop_execute_parent( _child: &ArrayRef, _parent: &ArrayRef, diff --git a/vortex-duckdb/src/exporter/mod.rs b/vortex-duckdb/src/exporter/mod.rs index 24d39a3b8e2..0714f6e8f4f 100644 --- a/vortex-duckdb/src/exporter/mod.rs +++ b/vortex-duckdb/src/exporter/mod.rs @@ -37,6 +37,7 @@ use vortex::array::arrays::List; use vortex::array::arrays::Slice; use vortex::array::arrays::StructArray; use vortex::array::arrays::struct_::StructArrayExt; +use vortex::array::matcher::Matcher; use vortex::buffer::BitChunks; use vortex::encodings::fastlanes::RLE; use vortex::encodings::runend::RunEnd; @@ -222,18 +223,21 @@ fn cached_values_dict( Ok(dict) } -/// Run the kernels of lazy filters and slices, which a scan leaves over its columns, so that the -/// encodings below them, run-end for example, can be exported as they are instead of decoded. -fn resolve_lazy_wrappers(mut array: ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult { - while array.is::() || array.is::() { - let stepped = array.execute::(ctx)?; - // Without a kernel for the child a step only makes progress below the wrapper. - if stepped.is::() || stepped.is::() { - return Ok(stepped); - } - array = stepped; +/// Matches arrays that are not lazy filters or slices. A scan leaves these over its columns, and +/// executing them lets the encodings below them, run-end for example, be exported as they are +/// instead of decoded. +struct NotSliceOrFilter; + +impl Matcher for NotSliceOrFilter { + type Match<'a> = &'a ArrayRef; + + fn matches(array: &ArrayRef) -> bool { + !array.is::() && !array.is::() + } + + fn try_match(array: &ArrayRef) -> Option> { + Self::matches(array).then_some(array) } - Ok(array) } /// Create a DuckDB exporter for the given Vortex array. @@ -243,7 +247,7 @@ fn new_array_exporter_with_flatten( ctx: &mut ExecutionCtx, flatten: bool, ) -> VortexResult> { - let array = resolve_lazy_wrappers(array, ctx)?; + let array = array.execute_until::(ctx)?; let array = match array.try_downcast::() { Ok(array) => return constant::new_exporter_with_flatten(array, cache, ctx, flatten), diff --git a/vortex-duckdb/src/exporter/primitive.rs b/vortex-duckdb/src/exporter/primitive.rs index 515ed3bbe24..d023b4616f1 100644 --- a/vortex-duckdb/src/exporter/primitive.rs +++ b/vortex-duckdb/src/exporter/primitive.rs @@ -62,6 +62,9 @@ impl ColumnExporter for PrimitiveExporter { #[cfg(test)] mod tests { + use vortex::array::validity::Validity; + use vortex::buffer::BitBuffer; + use vortex::buffer::buffer; use vortex_array::VortexSessionExecute; use super::*; @@ -113,6 +116,31 @@ mod tests { ); } + /// A validity bitmap that starts inside its first byte cannot be shared with DuckDB, so its + /// bits are copied from the bitmap's own offset. + #[test] + fn test_primitive_exporter_with_offset_validity() -> VortexResult<()> { + let validity = [true, true, true, false, true, false, false, true] + .into_iter() + .collect::() + .slice(3..); + let arr = PrimitiveArray::new(buffer![3i32, 4, 5, 6, 7], Validity::from(validity)); + + let mut chunk = DataChunk::new([LogicalType::new(cpp::duckdb_type::DUCKDB_TYPE_INTEGER)]); + let mut ctx = SESSION.create_execution_ctx(); + + new_exporter(arr, &mut ctx)?.export(0, 5, chunk.get_vector_mut(0), &mut ctx)?; + chunk.set_len(5); + + assert_eq!( + String::try_from(&*chunk)?, + r#"Chunk - [1 Columns] +- FLAT INTEGER: 5 = [ NULL, 4, NULL, NULL, 7] +"# + ); + Ok(()) + } + /// Export a large nullable primitive array over many chunks to exercise the /// zero-copy value and validity paths. #[test] diff --git a/vortex-duckdb/src/exporter/vector.rs b/vortex-duckdb/src/exporter/vector.rs index 1e457efed51..5cf4c7adad3 100644 --- a/vortex-duckdb/src/exporter/vector.rs +++ b/vortex-duckdb/src/exporter/vector.rs @@ -60,11 +60,11 @@ impl VectorRef { unsafe { self.set_validity_data(u64_offset, len, validity_data) }; } else { debug_assert!(zero_copy.is_none() || !offset.is_multiple_of(64)); - let source = values.bit_buffer().inner().as_slice(); + let bit_buffer = values.bit_buffer(); copy_from_slice( unsafe { self.ensure_validity_slice(len) }, - source, - offset, + bit_buffer.inner().as_slice(), + bit_buffer.offset() + offset, len, ); } diff --git a/vortex-layout/src/layouts/flat/reader.rs b/vortex-layout/src/layouts/flat/reader.rs index 9bd144dd658..2469aaa3dff 100644 --- a/vortex-layout/src/layouts/flat/reader.rs +++ b/vortex-layout/src/layouts/flat/reader.rs @@ -149,7 +149,7 @@ impl LayoutReader for FlatReader { // Slice the array based on the row mask. if row_range.start > 0 || row_range.end < array.len() { - array = array.execute_slice(row_range.clone(), &mut ctx)?; + array = array.slice(row_range.clone())?; } let mask_density = mask.density(); @@ -195,7 +195,6 @@ impl LayoutReader for FlatReader { let name = Arc::clone(&self.name); let array = self.array_future(); let expr = expr.clone(); - let session = self.session.clone(); Ok(async move { trace!("Flat array evaluation {} - {}", name, expr); @@ -210,8 +209,7 @@ impl LayoutReader for FlatReader { // Slice the array based on the row mask. if row_range.start > 0 || row_range.end < array.len() { - array = - array.execute_slice(row_range.clone(), &mut session.create_execution_ctx())?; + array = array.slice(row_range.clone())?; } // First apply the filter to the array.